Compare commits

...

150 Commits

Author SHA1 Message Date
Simon Larsen
3658f0349e feat: add react-native-worklets dependency to package.json and package-lock.json 2025-10-12 12:29:29 +01:00
Simon Larsen
02133165d8 chore: update dependencies and configure Tailwind CSS with NativeWind
- Added @expo/metro-runtime, nativewind, prettier-plugin-tailwindcss, react-dom, and tailwindcss to dependencies.
- Updated react-native-safe-area-context to version 5.4.0.
- Added global.css for Tailwind CSS styles.
- Created metro.config.js to integrate NativeWind with Expo.
- Added nativewind-env.d.ts for TypeScript support.
- Created tailwind.config.js to configure Tailwind CSS with NativeWind.
- Updated tsconfig.json to include nativewind-env.d.ts.
2025-10-12 12:28:30 +01:00
Simon Larsen
837b841352 refactor: remove unused ObjectID generation and related state management 2025-10-12 10:29:00 +01:00
Simon Larsen
10e344dad5 feat: add react-native-get-random-values dependency and import in App component 2025-10-12 10:08:23 +01:00
Simon Larsen
848bfb358f chore: add @oneuptime/common dependency and update tsconfig to exclude Common directory 2025-10-12 10:05:18 +01:00
Simon Larsen
2ca1c64532 Implement new feature for user authentication and improve error handling 2025-10-12 09:55:28 +01:00
Simon Larsen
d9a79eafbd fix: clarify file naming conventions in mobile app development prompt 2025-10-12 09:26:31 +01:00
Simon Larsen
a1dc16f4b6 feat: refactor styles into separate Styles.ts file for better organization 2025-10-12 09:25:23 +01:00
Nawaz Dhandala
674613c0d6 feat: add initial mobile app development prompt in MobileAppPrompt.md 2025-10-12 09:20:09 +01:00
Nawaz Dhandala
e4e095798a fix: enhance type definitions and import statements in App.tsx 2025-10-11 12:22:15 +01:00
Nawaz Dhandala
2e07185584 fix: standardize formatting in App.tsx and tsconfig.json 2025-10-11 12:19:33 +01:00
Nawaz Dhandala
144001981a Add initial configuration for mobile app with Expo
- Created package.json for project dependencies and scripts
- Added TypeScript configuration with strict settings and React Native support
2025-10-11 12:16:39 +01:00
Nawaz Dhandala
cd096f66ea fix: update app approval status message to include manual sideloading instructions 2025-10-11 12:15:52 +01:00
Nawaz Dhandala
02cffe9f8b fix: update Microsoft Teams outline image 2025-10-10 20:39:12 +01:00
Nawaz Dhandala
9c52e8966f fix: update Microsoft Teams outline image 2025-10-10 17:38:24 +01:00
Nawaz Dhandala
9257a949fe fix: add Microsoft Teams app client ID and secret placeholders to config example 2025-10-10 16:34:25 +01:00
Nawaz Dhandala
7528ed8e0c fix: update Microsoft Teams app manifest version and improve error handling 2025-10-10 15:55:18 +01:00
Nawaz Dhandala
98b6f3eac3 fix: streamline text formatting in Microsoft Teams integration messages 2025-10-10 09:17:34 +01:00
Nawaz Dhandala
a2561b3ae8 fix: update acknowledge route to acknowledge-page in UserNotificationRuleService 2025-10-10 09:15:55 +01:00
Nawaz Dhandala
368f5c6bbc Merge branch 'master' of https://github.com/OneUptime/oneuptime 2025-10-10 09:01:23 +01:00
Nawaz Dhandala
0e2e30b0a9 fix: remove Microsoft identity association endpoint for cleanup 2025-10-10 09:00:42 +01:00
Simon Larsen
2664a24875 fix: update outline image for Microsoft Teams integration 2025-10-09 18:47:58 +01:00
Simon Larsen
40e486669f feat: enhance app description and support information in Microsoft Teams API 2025-10-09 18:36:45 +01:00
Simon Larsen
ae64cbc718 feat: implement welcome adaptive card for OneUptime bot in Microsoft Teams 2025-10-09 18:35:32 +01:00
Simon Larsen
e14f691cc4 feat: add comment for Microsoft identity verification endpoint 2025-10-09 18:21:40 +01:00
Simon Larsen
89c607a530 feat: add endpoint for Microsoft identity association JSON 2025-10-09 18:21:03 +01:00
Simon Larsen
90f4e7418f fix: streamline SCIM group schema definition and ensure proper syntax in migrations 2025-10-09 17:40:44 +01:00
Simon Larsen
79fed2da09 fix: remove deprecated SCIM 1.1 schema references for improved compatibility 2025-10-09 17:34:37 +01:00
Simon Larsen
1dd41c103f Merge branch 'release' of github.com:OneUptime/oneuptime into release 2025-10-09 17:34:18 +01:00
Simon Larsen
988e85affc fix: remove deprecated SCIM schema reference in formatTeamForSCIM function 2025-10-09 17:34:15 +01:00
Nawaz Dhandala
2810516987 Merge branch 'master' of https://github.com/OneUptime/oneuptime 2025-10-09 17:19:00 +01:00
Nawaz Dhandala
679c1971a2 fix: update WhatsApp setup instructions for token generation and template creation 2025-10-09 17:18:58 +01:00
Nawaz Dhandala
3abdbb7ef9 fix: correct typo in IncidentStateChangedOwnerNotification template ID 2025-10-09 17:12:17 +01:00
Simon Larsen
0d647f5dc1 fix: improve release retrieval by using pagination and handle missing release tags 2025-10-09 13:43:43 +01:00
Nawaz Dhandala
bd1df491a2 Remove MobileApp components, themes, and configuration files
- Deleted package.json, removing all dependencies and scripts for the mobile app.
- Removed FeatureCard, GlowingButton, MetricCard, SafeAreaContainer, and HomeScreen components.
- Eliminated color, layout, and typography theme files.
- Deleted TypeScript configuration file (tsconfig.json).
2025-10-09 11:59:34 +01:00
Nawaz Dhandala
feb872d05c feat: add E2E testing support with docker-compose configuration 2025-10-09 11:39:30 +01:00
Nawaz Dhandala
64b6b99a21 refactor: replace retry action with shell commands for preinstall and E2E tests 2025-10-08 20:10:19 +01:00
Nawaz Dhandala
8046c244b1 feat: add react-dom and react-native-web dependencies to package.json 2025-10-08 18:52:29 +01:00
Nawaz Dhandala
6928316ba0 feat: implement initial structure and components for OneUptime Mobile app 2025-10-08 18:33:50 +01:00
Nawaz Dhandala
36c74642f2 Initialize MobileApp with package.json and tsconfig.json for Expo project setup 2025-10-08 18:14:29 +01:00
Nawaz Dhandala
5a992e99c8 refactor: format publisherDocsUrl for better readability in Microsoft Teams app manifest 2025-10-08 18:06:52 +01:00
Nawaz Dhandala
ff9230f878 refactor: add publisherDocsUrl to Microsoft Teams app manifest 2025-10-08 17:08:45 +01:00
Nawaz Dhandala
9afa861ff2 refactor: remove obsolete migration files and update index 2025-10-08 17:04:59 +01:00
Nawaz Dhandala
6d7486e76d refactor: update developer name from "OneUptime" to "HackerBay Inc" in MicrosoftTeamsAPI 2025-10-08 17:02:59 +01:00
Nawaz Dhandala
8529012b19 refactor: remove "whatsAppText" column from WhatsAppLog table 2025-10-08 16:45:29 +01:00
Nawaz Dhandala
f704bd47a3 refactor: remove unused column "whatsAppText" from WhatsAppLog table 2025-10-08 14:58:24 +01:00
Nawaz Dhandala
239f2fc34e refactor: streamline actionLink resolution in createWhatsAppMessageFromTemplate 2025-10-08 14:35:31 +01:00
Nawaz Dhandala
5d5183b08e refactor: remove action_link from templateVariables in createWhatsAppMessageFromTemplate 2025-10-08 14:29:13 +01:00
Nawaz Dhandala
7cad0fab0f refactor: clean up whitespace and improve code formatting in multiple files 2025-10-08 14:20:41 +01:00
Nawaz Dhandala
98e0d55ee3 feat: update template variable iteration to include parameter names 2025-10-08 14:19:01 +01:00
Nawaz Dhandala
2d8f0d7a58 feat: add recipient_type to WhatsApp message payload for individual recipients 2025-10-08 13:46:55 +01:00
Nawaz Dhandala
643303cd7a feat: enhance error handling to include HTTPErrorResponse support 2025-10-08 13:27:26 +01:00
Nawaz Dhandala
dc692203be feat: add selectMoreFields for delivery channel options in notification settings 2025-10-07 22:40:29 +01:00
Nawaz Dhandala
648c51d007 feat: enhance notification settings with delivery channel options and icons 2025-10-07 22:37:40 +01:00
Nawaz Dhandala
0e5b106333 fix: update icon for resend code button from WhatsApp to SMS 2025-10-07 22:20:24 +01:00
Nawaz Dhandala
40e9ea2ab6 feat: add WhatsApp alert options to notification settings 2025-10-07 22:19:16 +01:00
Nawaz Dhandala
2157e228b9 Merge branch 'master' of https://github.com/OneUptime/oneuptime 2025-10-07 21:49:36 +01:00
Nawaz Dhandala
5c5534adb8 fix: add type annotation for response in sendWhatsAppMessage method 2025-10-07 21:49:34 +01:00
Nawaz Dhandala
379297cd7e fix: streamline async function signatures in API routes for consistency 2025-10-07 21:49:14 +01:00
Nawaz Dhandala
b3730e9708 fix: add user authorization middleware to /test endpoint 2025-10-07 21:48:31 +01:00
Nawaz Dhandala
2fbc44d5c3 Refactor API endpoints to include error handling middleware
- Updated multiple API routes to use NextFunction for error handling.
- Wrapped asynchronous route handlers in try-catch blocks to catch and pass errors to the next middleware.
- Ensured consistent error responses across various endpoints, improving maintainability and readability.
- Enhanced the structure of the code by reducing nested try-catch blocks and improving flow control.
2025-10-07 21:46:25 +01:00
Nawaz Dhandala
12f05937af fix: enhance error handling in API endpoints by adding NextFunction support 2025-10-07 21:17:59 +01:00
Simon Larsen
5ea440492b Merge branch 'master' of github.com:OneUptime/oneuptime 2025-10-07 21:16:14 +01:00
Simon Larsen
57b851a498 refactor: rename github-release job to draft-github-release and add finalize-github-release job for publishing 2025-10-07 21:15:45 +01:00
Nawaz Dhandala
00f806b077 fix: enhance error handling in WhatsApp message template rendering for missing variables 2025-10-07 21:13:40 +01:00
Nawaz Dhandala
975c20a788 fix: improve error handling in verification endpoints by adding NextFunction support 2025-10-07 20:49:27 +01:00
Nawaz Dhandala
948e2d93c1 fix: handle errors in resend verification code endpoint and add NextFunction support 2025-10-07 20:38:11 +01:00
Nawaz Dhandala
6de3c93745 fix: refactor error handling for WhatsApp verification and resend code modals 2025-10-07 20:31:47 +01:00
Nawaz Dhandala
1f63110561 feat: enhance WhatsApp verification code handling with error logging and improved response management 2025-10-07 20:25:32 +01:00
Nawaz Dhandala
8a94c35450 feat: enforce template key requirement for WhatsApp messages and add debug logging for API requests 2025-10-07 16:59:53 +01:00
Nawaz Dhandala
fdc97284a5 fix: update DEFAULT_META_WHATSAPP_API_VERSION to v23.0 2025-10-07 16:48:22 +01:00
Nawaz Dhandala
792ecfdbdc fix: remove 'From' column from WhatsApp logs table 2025-10-07 16:04:24 +01:00
Nawaz Dhandala
121b01dc08 feat: add WhatsApp notifications toggle to notification settings 2025-10-07 16:01:27 +01:00
Nawaz Dhandala
1aa49071eb feat: add UserWhatsAppAPI for handling WhatsApp verification and resend functionality 2025-10-07 15:57:06 +01:00
Nawaz Dhandala
66bef1284a feat: add documentation for canceling all GitHub actions jobs using GitHub CLI 2025-10-07 15:53:37 +01:00
Nawaz Dhandala
1526f708de fix: enhance error handling and logging for WhatsApp message sending failures 2025-10-07 15:49:34 +01:00
Nawaz Dhandala
53198e4486 fix: improve error logging for WhatsApp message sending failures 2025-10-07 15:39:10 +01:00
Nawaz Dhandala
a66bf2df2a feat: enhance WhatsApp icon SVG with stroke properties and viewBox 2025-10-07 15:31:20 +01:00
Nawaz Dhandala
34422721c3 feat: add WhatsApp logs table to Notification Logs 2025-10-07 15:19:36 +01:00
Nawaz Dhandala
b5008c2363 Merge branch 'whatsapp' 2025-10-07 15:15:48 +01:00
Nawaz Dhandala
1cbab2be08 refactor: remove unused dashboard link and update WhatsApp message template variables 2025-10-07 15:15:25 +01:00
Simon Larsen
1bdcfd71f7 Merge pull request #2026 from OneUptime/whatsapp
Whatsapp
2025-10-07 15:05:58 +01:00
Nawaz Dhandala
792271b146 Merge branch 'whatsapp' of https://github.com/OneUptime/oneuptime into whatsapp 2025-10-07 15:02:44 +01:00
Nawaz Dhandala
3de5fbd35c fix: correct variable assignment for templateKey in test message route 2025-10-07 15:02:19 +01:00
Nawaz Dhandala
6f8dc1ed59 feat: update test message sending functionality to use predefined template and language 2025-10-07 15:00:41 +01:00
Nawaz Dhandala
92309d8fb2 feat: add test notification template and implement test message sending functionality in WhatsApp setup 2025-10-07 14:57:00 +01:00
Simon Larsen
d3070975cb Merge branch 'whatsapp' of github.com:OneUptime/oneuptime into whatsapp 2025-10-07 14:37:26 +01:00
Simon Larsen
69c0da5b17 refactor: remove WhatsApp high-risk cost variable and update related configurations 2025-10-07 14:37:23 +01:00
Nawaz Dhandala
1736690f01 feat: enhance WhatsApp setup markdown with detailed instructions for Business Account ID, App ID, and App Secret 2025-10-07 14:22:43 +01:00
Nawaz Dhandala
78a92905e9 feat: update WhatsApp setup markdown for clarity and improved instructions 2025-10-07 14:21:24 +01:00
Nawaz Dhandala
801c1b4ccb feat: remove header from WhatsApp setup markdown for improved clarity 2025-10-07 14:15:37 +01:00
Nawaz Dhandala
703b1dca51 feat: enhance WhatsApp setup markdown formatting for better structure and clarity 2025-10-07 14:12:55 +01:00
Nawaz Dhandala
e5ef97abd9 feat: improve WhatsApp setup markdown formatting for clarity and structure 2025-10-07 13:58:28 +01:00
Nawaz Dhandala
3053856990 feat: enhance WhatsApp setup markdown formatting for improved readability 2025-10-07 13:45:12 +01:00
Nawaz Dhandala
b8e82e2801 feat: add WhatsApp configuration fields to GlobalConfig migration and update template IDs for type safety 2025-10-07 13:36:21 +01:00
Nawaz Dhandala
2187fe63f0 feat: refactor WhatsApp template IDs and messages for improved type safety and export structure 2025-10-07 13:29:36 +01:00
Nawaz Dhandala
b1f842f9e1 feat: enhance WhatsApp setup documentation with prerequisites and structured steps 2025-10-07 13:25:52 +01:00
Nawaz Dhandala
43e03fb61c feat: update WhatsApp icon SVG path for improved rendering 2025-10-07 13:19:29 +01:00
Nawaz Dhandala
2327ab84c2 feat: add migration for WhatsApp configuration fields in GlobalConfig 2025-10-07 13:16:19 +01:00
Nawaz Dhandala
93034b6018 refactor: update WhatsApp migration and enhance type definitions in WhatsApp settings 2025-10-07 13:10:54 +01:00
Nawaz Dhandala
e45022b5cb feat: add migration for WhatsApp notifications and related database changes 2025-10-07 13:06:47 +01:00
Nawaz Dhandala
c022b70e6d Merge branch 'whatsapp' of https://github.com/OneUptime/oneuptime into whatsapp 2025-10-07 13:06:11 +01:00
Nawaz Dhandala
5615ba2df7 Merge branch 'master' into whatsapp 2025-10-07 13:05:46 +01:00
Nawaz Dhandala
268960bc5b refactor: simplify destructuring of connection settings in getTransporter method 2025-10-07 12:58:19 +01:00
Nawaz Dhandala
508a713ecf refactor: enhance connection handling in TransporterPool for improved email server configuration 2025-10-07 12:57:43 +01:00
Simon Larsen
36e50b591a refactor: update WhatsApp template messages for improved clarity and conciseness 2025-10-07 12:17:04 +01:00
Nawaz Dhandala
05c1f95ba4 fix: enable tool-cache in release workflow for improved build efficiency 2025-10-07 11:52:13 +01:00
Simon Larsen
9d355691ae refactor: rename scheduled_maintenance_number to scheduled_event_number in WhatsApp templates and notification jobs for consistency 2025-10-07 11:32:54 +01:00
Simon Larsen
73c58186b6 refactor: enhance WhatsApp template messages with scheduled maintenance number for better context 2025-10-06 20:23:11 +01:00
Simon Larsen
eb8d3e4dfd refactor: update WhatsApp template messages for clarity and conciseness 2025-10-06 20:17:38 +01:00
Nawaz Dhandala
987f30e5c7 feat: add PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD environment variable to Dockerfiles for improved build performance 2025-10-06 19:45:46 +01:00
Simon Larsen
b7a0dbf81b refactor: replace action_link with corresponding link variables in notification templates 2025-10-06 18:25:59 +01:00
Simon Larsen
7368a0eb7c Refactor notification links in WhatsApp templates and services
- Updated variable names for links in various services to improve consistency:
  - Changed `*_link_on_dashboard` to `*_link` across multiple services including MonitorService, OnCallDutyPolicyEscalationRuleService, and UserNotificationRuleService.

- Modified WhatsApp template messages to reflect the new link variable names, ensuring that the notifications sent to users contain the correct links.

- Adjusted the link variable names in the WhatsAppTemplateUtil to match the updated naming convention.

- Ensured all related notification jobs (e.g., SendCreatedResourceNotification, SendNotePostedNotification) are updated to use the new link variables for better clarity and maintainability.
2025-10-06 18:07:39 +01:00
Nawaz Dhandala
ef0b6f3e14 refactor: replace docker login actions with retry mechanism for improved reliability 2025-10-06 16:36:28 +01:00
Simon Larsen
af8691f61d refactor: remove Meta WhatsApp API version handling and update documentation 2025-10-06 16:31:29 +01:00
Simon Larsen
f7aee2e253 feat: add Meta WhatsApp settings page and configuration options 2025-10-06 16:26:05 +01:00
Nawaz Dhandala
0a1b74d911 fix: update @simplewebauthn/server to version 13.2.2 in package.json and package-lock.json 2025-10-06 16:13:53 +01:00
Nawaz Dhandala
ab48da447d refactor: add test jobs to push-release-tags workflow for enhanced testing coverage 2025-10-06 16:08:43 +01:00
Nawaz Dhandala
1cc0630939 feat: add APP_TAG pinning to versioned release in config.env 2025-10-06 16:06:16 +01:00
Simon Larsen
5391ff4688 Add dashboard links to WhatsApp notifications for various resources
- Added `monitor_link_on_dashboard` to MonitorService notifications.
- Added `on_call_policy_link_on_dashboard` to OnCallDutyPolicyEscalationRule services.
- Added `on_call_schedule_link_on_dashboard` to OnCallDutyPolicyScheduleService.
- Added `probe_link_on_dashboard` to ProbeService notifications.
- Enhanced UserNotificationRuleService to include `alert_link_on_dashboard` and `incident_link_on_dashboard`.
- Updated WhatsApp templates to include links to the OneUptime dashboard for alerts, incidents, monitors, probes, scheduled maintenance, and status pages.
- Added `status_page_link_on_dashboard` to StatusPageOwners notifications.
2025-10-06 15:56:12 +01:00
Nawaz Dhandala
f6d96676fe refactor: update dependencies for test-e2e-release-saas job to include publish-mcp-server 2025-10-06 15:54:50 +01:00
Simon Larsen
cf02842ab1 Refactor WhatsApp message handling across services
- Updated `createWhatsAppMessageFromTemplate` to return a typed `WhatsAppMessagePayload`.
- Added type annotations for `WhatsAppTemplateId` in various services to improve type safety.
- Refactored WhatsApp message creation in `ProbeService`, `UserNotificationRuleService`, `UserNotificationSettingService`, and other worker jobs to ensure consistent typing.
- Enhanced readability and maintainability by using explicit types for event types and WhatsApp message payloads.
- Improved the structure of WhatsApp template ID definitions for better clarity and type inference.
2025-10-06 15:12:29 +01:00
Nawaz Dhandala
b63fcf6b99 refactor: update push-release-tags job dependencies for improved workflow order 2025-10-06 14:54:08 +01:00
Nawaz Dhandala
36069c1b4e refactor: update job dependencies for push-release-tags and github-release in release workflow 2025-10-06 14:48:05 +01:00
Simon Larsen
d2846decce refactor: improve code formatting and readability across multiple files 2025-10-06 14:47:15 +01:00
Simon Larsen
60a8a3f052 feat: include alert and incident identifiers in SMS notifications for better context 2025-10-06 14:45:46 +01:00
Simon Larsen
e2d15dc2e7 Merge branch 'master' of github.com:OneUptime/oneuptime into whatsapp 2025-10-06 14:39:00 +01:00
Simon Larsen
7cdefdeccd feat: add incident and alert numbers to WhatsApp notification templates and related services 2025-10-06 14:37:45 +01:00
Simon Larsen
684b8822af feat: simplify WhatsApp template messages for clarity and user engagement 2025-10-06 14:20:52 +01:00
Nawaz Dhandala
231bc47942 refactor: improve type annotations and formatting in TeamService for clarity 2025-10-06 14:14:11 +01:00
Nawaz Dhandala
965a497be3 feat: implement SCIM mutation checks for team creation and deletion 2025-10-06 14:12:19 +01:00
Nawaz Dhandala
f50a7fb99b refactor: adjust job dependencies and increase timeout for E2E tests in release workflows 2025-10-06 14:08:50 +01:00
Simon Larsen
50a5e75d1a feat: enhance WhatsApp template messages with additional information for user guidance 2025-10-06 14:00:01 +01:00
Simon Larsen
84e838a055 refactor: streamline WhatsApp message creation by removing unused template string imports 2025-10-04 13:49:12 +01:00
Simon Larsen
6d6c78e974 feat: Integrate WhatsApp notifications for various alert and incident events
- Added WhatsApp message creation for alert owner notifications in SendCreatedResourceNotification, SendNotePostedNotification, SendOwnerAddedNotification, and SendStateChangeNotification jobs.
- Implemented WhatsApp message functionality for incident owner notifications in SendCreatedResourceNotification, SendNotePostedNotification, SendOwnerAddedNotification, and SendStateChangeNotification jobs.
- Enhanced monitor owner notifications with WhatsApp messages in SendCreatedResourceNotification, SendOwnerAddedNotification, and SendStatusChangeNotification jobs.
- Included WhatsApp message creation for scheduled maintenance owner notifications in SendCreatedResourceNotification, SendNotePostedNotification, SendOwnerAddedNotification, and SendStateChangeNotification jobs.
- Added WhatsApp message functionality for status page owner notifications in SendAnnouncementCreatedNotification, SendCreatedResourceNotification, and SendOwnerAddedNotification jobs.
- Introduced WhatsAppTemplateUtil to manage WhatsApp message templates and creation logic.
2025-10-04 13:22:56 +01:00
Simon Larsen
778d5b7c6b feat: add UserWhatsAppService and integrate WhatsApp verification code template 2025-10-03 19:25:53 +01:00
Nawaz Dhandala
8051146f41 refactor: enhance type safety and improve variable naming in OnCallDutyScheduleSettings 2025-10-03 19:19:46 +01:00
Simon Larsen
86a359a230 feat: enhance WhatsApp notification handling with template support and error logging 2025-10-03 19:17:10 +01:00
Simon Larsen
c16dac65cc feat: add WhatsApp template IDs and rendering logic for notifications 2025-10-03 19:16:04 +01:00
Simon Larsen
437c9ecdbc feat: add support for WhatsApp notifications in project and user notification settings 2025-10-03 19:04:33 +01:00
Nawaz Dhandala
bf4eec2bdf refactor: improve code formatting and comments for better readability in SCIM and TimePicker components 2025-10-03 19:00:49 +01:00
Nawaz Dhandala
08367f3c7f refactor: update onDuplicateSuccess type to support Promise and implement duplication logic in OnCallDutyScheduleSettings 2025-10-03 18:23:29 +01:00
Simon Larsen
f5d077956a feat: integrate UserWhatsApp into notification settings and management 2025-10-03 18:12:07 +01:00
Simon Larsen
ca74005262 feat: add WhatsApp phone display in NotificationMethodView and create WhatsApp management component 2025-10-03 18:11:10 +01:00
Simon Larsen
52c936935e feat: add UserWhatsApp relation and ID to UserOnCallLogTimeline model 2025-10-03 17:51:34 +01:00
Simon Larsen
2951600ed9 feat: add UserWhatsApp relation and ID to UserNotificationRule model 2025-10-03 17:51:15 +01:00
Simon Larsen
d12c8c778c feat: Add UserWhatsApp and WhatsAppLog models with associated services
- Created UserWhatsApp model to manage WhatsApp numbers linked to users and projects.
- Implemented WhatsAppLog model to log messages sent via WhatsApp, including details like recipient, status, and associated incidents or alerts.
- Developed WhatsAppLogService for managing WhatsApp log entries, including automatic deletion of old logs if billing is enabled.
- Introduced WhatsAppService for sending WhatsApp messages with various contextual options.
2025-10-03 17:28:31 +01:00
Simon Larsen
77d4527a00 feat: add WhatsApp integration with API and configuration support 2025-10-03 17:24:59 +01:00
Nawaz Dhandala
1ef3353155 fix: update modelId retrieval to correctly use parameter for OnCallDutyScheduleSettings 2025-10-03 17:11:04 +01:00
Nawaz Dhandala
2c635c0d1e refactor: add modulePathIgnorePatterns to jest config for build directory 2025-10-03 16:17:45 +01:00
153 changed files with 34997 additions and 1990 deletions

View File

@@ -184,17 +184,20 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push Docker images
uses: nick-fields/retry@v3
@@ -257,18 +260,21 @@ jobs:
# Build and deploy nginx.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -324,18 +330,21 @@ jobs:
# Build and deploy e2e.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -391,18 +400,21 @@ jobs:
# Build and deploy isolated-vm.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -458,18 +470,21 @@ jobs:
# Build and deploy isolated-vm.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -528,18 +543,21 @@ jobs:
# Build and deploy test-server.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -595,18 +613,21 @@ jobs:
# Build and deploy otel-collector.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -664,18 +685,21 @@ jobs:
# Build and deploy status-page.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -731,18 +755,21 @@ jobs:
# Build and deploy test.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -798,18 +825,21 @@ jobs:
# Build and deploy probe-ingest.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -866,18 +896,21 @@ jobs:
# Build and deploy probe-ingest.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -935,18 +968,21 @@ jobs:
# Build and deploy open-telemetry-ingest.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1003,18 +1039,21 @@ jobs:
# Build and deploy incoming-request-ingest.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1070,18 +1109,21 @@ jobs:
# Build and deploy fluent-ingest.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1137,18 +1179,21 @@ jobs:
# Build and deploy probe.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1204,18 +1249,21 @@ jobs:
# Build and deploy admin-dashboard.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1272,18 +1320,21 @@ jobs:
# Build and deploy dashboard.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1339,18 +1390,21 @@ jobs:
# Build and deploy app.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1407,18 +1461,21 @@ jobs:
# Build and deploy app.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1474,18 +1531,21 @@ jobs:
# Build and deploy accounts.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1585,18 +1645,21 @@ jobs:
# Build and deploy nginx.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1654,18 +1717,21 @@ jobs:
# Build and deploy nginx.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1726,18 +1792,21 @@ jobs:
# Build and deploy nginx.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1798,18 +1867,21 @@ jobs:
# Build and deploy nginx.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1930,18 +2002,21 @@ jobs:
# Build and deploy nginx.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1960,8 +2035,37 @@ jobs:
.
push-release-tags:
name: Push release tags after GitHub release
needs: [github-release, read-version, generate-build-number]
name: Push release tags before GitHub release
needs:
- read-version
- generate-build-number
- publish-mcp-server
- nginx-docker-image-deploy
- e2e-docker-image-deploy
- isolated-vm-docker-image-deploy
- home-docker-image-deploy
- test-server-docker-image-deploy
- otel-collector-docker-image-deploy
- status-page-docker-image-deploy
- test-docker-image-deploy
- probe-ingest-docker-image-deploy
- server-monitor-ingest-docker-image-deploy
- open-telemetry-ingest-docker-image-deploy
- incoming-request-ingest-docker-image-deploy
- fluent-ingest-docker-image-deploy
- probe-docker-image-deploy
- admin-dashboard-docker-image-deploy
- dashboard-docker-image-deploy
- app-docker-image-deploy
- copilot-docker-image-deploy
- accounts-docker-image-deploy
- llm-docker-image-deploy
- docs-docker-image-deploy
- worker-docker-image-deploy
- workflow-docker-image-deploy
- api-reference-docker-image-deploy
- test-e2e-release-saas
- test-e2e-release-self-hosted
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -1998,17 +2102,20 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Create Docker Hub release tag from version
run: |
@@ -2029,7 +2136,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, 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, read-version, nginx-docker-image-deploy, incoming-request-ingest-docker-image-deploy]
needs: [open-telemetry-ingest-docker-image-deploy, publish-mcp-server, 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, read-version, nginx-docker-image-deploy, incoming-request-ingest-docker-image-deploy]
env:
CI_PIPELINE_ID: ${{github.run_number}}
steps:
@@ -2039,7 +2146,7 @@ jobs:
with:
# this might remove tools that are actually needed,
# if set to "true" but frees about 6 GB
tool-cache: false
tool-cache: true
android: true
dotnet: true
haskell: true
@@ -2051,11 +2158,22 @@ jobs:
with:
node-version: latest
- name: Preinstall and enable billing
uses: nick-fields/retry@v3
with:
timeout_minutes: 10
max_attempts: 3
command: npm run prerun && bash ./Tests/Scripts/enable-billing-env-var.sh
run: |
set -euo pipefail
npm run prerun
bash ./Tests/Scripts/enable-billing-env-var.sh
- name: Pin APP_TAG to versioned release
run: |
VERSION="${{needs.read-version.outputs.major_minor}}.${{needs.generate-build-number.outputs.build_number}}"
if [ -f config.env ]; then
if grep -q '^APP_TAG=' config.env; then
sed -i "s/^APP_TAG=.*/APP_TAG=${VERSION}/" config.env
else
echo "APP_TAG=${VERSION}" >> config.env
fi
else
echo "APP_TAG=${VERSION}" > config.env
fi
- name: Start Server with version tag
run: |
export $(grep -v '^#' config.env | xargs)
@@ -2064,12 +2182,22 @@ jobs:
npm run status-check
- name: Wait for server to start
run: bash ./Tests/Scripts/status-check.sh http://localhost
- name: Pull E2E test image
run: |
set -euo pipefail
export $(grep -v '^#' config.env | xargs)
export APP_TAG=${{needs.read-version.outputs.major_minor}}.${{needs.generate-build-number.outputs.build_number}}
docker compose -f docker-compose.e2e.yml pull e2e
- name: Run E2E Tests. Run docker container e2e in docker compose file
uses: nick-fields/retry@v3
with:
max_attempts: 3
on_retry_command: docker compose -f docker-compose.dev.yml down -v || true
command: export $(grep -v '^#' config.env | xargs) && docker compose -f docker-compose.dev.yml up --exit-code-from e2e --abort-on-container-exit e2e || (docker compose -f docker-compose.dev.yml logs e2e && exit 1)
run: |
set -euo pipefail
export $(grep -v '^#' config.env | xargs)
export APP_TAG=${{needs.read-version.outputs.major_minor}}.${{needs.generate-build-number.outputs.build_number}}
trap 'docker compose -f docker-compose.e2e.yml down -v || true' EXIT
if ! docker compose -f docker-compose.e2e.yml up --exit-code-from e2e --abort-on-container-exit e2e; then
docker compose -f docker-compose.e2e.yml logs e2e
exit 1
fi
- name: Upload test results
uses: actions/upload-artifact@v4
# Run this on failure
@@ -2105,7 +2233,7 @@ jobs:
with:
# this might remove tools that are actually needed,
# if set to "true" but frees about 6 GB
tool-cache: false
tool-cache: true
android: true
dotnet: true
haskell: true
@@ -2117,11 +2245,21 @@ jobs:
with:
node-version: latest
- name: Preinstall
uses: nick-fields/retry@v3
with:
timeout_minutes: 10
max_attempts: 3
command: npm run prerun
run: |
set -euo pipefail
npm run prerun
- name: Pin APP_TAG to versioned release
run: |
VERSION="${{needs.read-version.outputs.major_minor}}.${{needs.generate-build-number.outputs.build_number}}"
if [ -f config.env ]; then
if grep -q '^APP_TAG=' config.env; then
sed -i "s/^APP_TAG=.*/APP_TAG=${VERSION}/" config.env
else
echo "APP_TAG=${VERSION}" >> config.env
fi
else
echo "APP_TAG=${VERSION}" > config.env
fi
- name: Start Server with version tag
run: |
export $(grep -v '^#' config.env | xargs)
@@ -2130,12 +2268,22 @@ jobs:
npm run status-check
- name: Wait for server to start
run: bash ./Tests/Scripts/status-check.sh http://localhost
- name: Pull E2E test image
run: |
set -euo pipefail
export $(grep -v '^#' config.env | xargs)
export APP_TAG=${{needs.read-version.outputs.major_minor}}.${{needs.generate-build-number.outputs.build_number}}
docker compose -f docker-compose.e2e.yml pull e2e
- name: Run E2E Tests. Run docker container e2e in docker compose file
uses: nick-fields/retry@v3
with:
max_attempts: 3
on_retry_command: docker compose -f docker-compose.dev.yml down -v || true
command: export $(grep -v '^#' config.env | xargs) && docker compose -f docker-compose.dev.yml up --exit-code-from e2e --abort-on-container-exit e2e || (docker compose -f docker-compose.dev.yml logs e2e && exit 1)
run: |
set -euo pipefail
export $(grep -v '^#' config.env | xargs)
export APP_TAG=${{needs.read-version.outputs.major_minor}}.${{needs.generate-build-number.outputs.build_number}}
trap 'docker compose -f docker-compose.e2e.yml down -v || true' EXIT
if ! docker compose -f docker-compose.e2e.yml up --exit-code-from e2e --abort-on-container-exit e2e; then
docker compose -f docker-compose.e2e.yml logs e2e
exit 1
fi
- name: Upload test results
uses: actions/upload-artifact@v4
# Run this on failure
@@ -2157,8 +2305,9 @@ jobs:
# Optional. Defaults to repository settings.
retention-days: 7
github-release:
needs: [test-e2e-release-saas, test-e2e-release-self-hosted, generate-build-number, read-version]
draft-github-release:
name: Create draft GitHub release
needs: [test-e2e-release-saas, test-e2e-release-self-hosted, generate-build-number, read-version, push-release-tags]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/release'
permissions:
@@ -2216,12 +2365,15 @@ jobs:
with:
tag: "${{needs.read-version.outputs.major_minor}}.${{needs.generate-build-number.outputs.build_number}}"
artifactErrorsFailBuild: true
draft: true
allowUpdates: true
prerelease: false
body: |
${{steps.fallback_changelog.outputs.changelog}}
infrastructure-agent-deploy:
needs: [github-release, generate-build-number, read-version]
needs: [draft-github-release, generate-build-number, read-version]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -2259,7 +2411,49 @@ jobs:
files: |
InfrastructureAgent/dist/*
token: ${{ secrets.GITHUB_TOKEN }}
draft: false
draft: true
prerelease: false
tag_name: ${{needs.read-version.outputs.major_minor}}.${{needs.generate-build-number.outputs.build_number}}
finalize-github-release:
name: Publish GitHub release
needs: [infrastructure-agent-deploy, generate-build-number, read-version]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/release'
permissions:
contents: write
steps:
- name: Publish release
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const tag = '${{needs.read-version.outputs.major_minor}}.${{needs.generate-build-number.outputs.build_number}}';
try {
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const release = releases.find((item) => item.tag_name === tag);
if (!release) {
throw new Error(`Release with tag ${tag} not found in repository ${context.repo.owner}/${context.repo.repo}`);
}
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: release.id,
draft: false,
prerelease: false,
make_latest: 'true',
});
console.log(`Published release for ${tag}`);
} catch (error) {
throw new Error(`Failed to publish release for tag ${tag}: ${error.message ?? error}`);
}

View File

@@ -176,17 +176,20 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push Docker images (test)
uses: nick-fields/retry@v3
@@ -276,18 +279,21 @@ jobs:
# Build and deploy nginx.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -347,18 +353,21 @@ jobs:
# Build and deploy nginx.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -418,18 +427,21 @@ jobs:
# Build and deploy e2e.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -487,18 +499,21 @@ jobs:
# Build and deploy test-server.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -557,18 +572,21 @@ jobs:
# Build and deploy otel-collector.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -627,18 +645,21 @@ jobs:
# Build and deploy isolated-vm.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -697,18 +718,21 @@ jobs:
# Build and deploy isolated-vm.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -769,18 +793,21 @@ jobs:
# Build and deploy status-page.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -841,18 +868,21 @@ jobs:
# Build and deploy test.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -911,18 +941,21 @@ jobs:
# Build and deploy probe-ingest.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -983,18 +1016,21 @@ jobs:
# Build and deploy ServerMonitorIngest.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1056,18 +1092,21 @@ jobs:
# Build and deploy incoming-request-ingest.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1127,18 +1166,21 @@ jobs:
# Build and deploy incoming-request-ingest.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1197,18 +1239,21 @@ jobs:
# Build and deploy probe-ingest.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1267,18 +1312,21 @@ jobs:
# Build and deploy probe.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1337,18 +1385,21 @@ jobs:
# Build and deploy dashboard.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1407,18 +1458,21 @@ jobs:
# Build and deploy admin-dashboard.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1477,18 +1531,21 @@ jobs:
# Build and deploy app.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1550,18 +1607,21 @@ jobs:
# Build and deploy app.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1623,18 +1683,21 @@ jobs:
# Build and deploy accounts.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1694,18 +1757,21 @@ jobs:
# Build and deploy accounts.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1764,18 +1830,21 @@ jobs:
# Build and deploy accounts.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1835,18 +1904,21 @@ jobs:
# Build and deploy accounts.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1907,18 +1979,21 @@ jobs:
# Build and deploy accounts.
- name: Login to Docker Hub
uses: docker/login-action@v2.2.0
- name: Login to Docker Hub
uses: nick-fields/retry@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.2.0
uses: nick-fields/retry@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
timeout_minutes: 5
max_attempts: 3
command: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
- name: Build and push
uses: nick-fields/retry@v3
@@ -1997,6 +2072,7 @@ jobs:
- name: Run E2E Tests. Run docker container e2e in docker compose file
uses: nick-fields/retry@v3
with:
timeout_minutes: 90
max_attempts: 3
on_retry_command: docker compose -f docker-compose.dev.yml down -v || true
command: export $(grep -v '^#' config.env | xargs) && docker compose -f docker-compose.dev.yml up --exit-code-from e2e --abort-on-container-exit e2e || (docker compose -f docker-compose.dev.yml logs e2e && exit 1)
@@ -2054,6 +2130,7 @@ jobs:
- name: Run E2E Tests. Run docker container e2e in docker compose file
uses: nick-fields/retry@v3
with:
timeout_minutes: 90
max_attempts: 3
on_retry_command: docker compose -f docker-compose.dev.yml down -v || true
command: export $(grep -v '^#' config.env | xargs) && docker compose -f docker-compose.dev.yml up --exit-code-from e2e --abort-on-container-exit e2e || (docker compose -f docker-compose.dev.yml logs e2e && exit 1)

View File

@@ -17,6 +17,7 @@ ARG APP_VERSION
ENV GIT_SHA=${GIT_SHA}
ENV APP_VERSION=${APP_VERSION}
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
# IF APP_VERSION is not set, set it to 1.0.0

View File

@@ -17,6 +17,7 @@ ARG APP_VERSION
ENV GIT_SHA=${GIT_SHA}
ENV APP_VERSION=${APP_VERSION}
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
# IF APP_VERSION is not set, set it to 1.0.0

View File

@@ -17,6 +17,7 @@ ARG APP_VERSION
ENV GIT_SHA=${GIT_SHA}
ENV APP_VERSION=${APP_VERSION}
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
# IF APP_VERSION is not set, set it to 1.0.0

View File

@@ -5,6 +5,7 @@ import Projects from "./Pages/Projects/Index";
import SettingsAPIKey from "./Pages/Settings/APIKey/Index";
import SettingsAuthentication from "./Pages/Settings/Authentication/Index";
import SettingsCallSMS from "./Pages/Settings/CallSMS/Index";
import SettingsWhatsApp from "./Pages/Settings/WhatsApp/Index";
// Settings Pages.
import SettingsEmail from "./Pages/Settings/Email/Index";
import SettingsProbes from "./Pages/Settings/Probes/Index";
@@ -105,6 +106,11 @@ const App: () => JSX.Element = () => {
element={<SettingsCallSMS />}
/>
<PageRoute
path={RouteMap[PageMap.SETTINGS_WHATSAPP]?.toString() || ""}
element={<SettingsWhatsApp />}
/>
<PageRoute
path={RouteMap[PageMap.SETTINGS_PROBES]?.toString() || ""}
element={<SettingsProbes />}

View File

@@ -50,6 +50,15 @@ const DashboardSideMenu: () => JSX.Element = (): ReactElement => {
}}
icon={IconProp.Call}
/>
<SideMenuItem
link={{
title: "WhatsApp",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.SETTINGS_WHATSAPP] as Route,
),
}}
icon={IconProp.WhatsApp}
/>
</SideMenuSection>
<SideMenuSection title="Monitoring">

View File

@@ -0,0 +1,454 @@
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import DashboardSideMenu from "../SideMenu";
import Route from "Common/Types/API/Route";
import ObjectID from "Common/Types/ObjectID";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail";
import Page from "Common/UI/Components/Page/Page";
import FieldType from "Common/UI/Components/Types/FieldType";
import GlobalConfig from "Common/Models/DatabaseModels/GlobalConfig";
import React, { FunctionComponent, ReactElement, useState } from "react";
import Card from "Common/UI/Components/Card/Card";
import MarkdownViewer from "Common/UI/Components/Markdown.tsx/MarkdownViewer";
import BasicForm from "Common/UI/Components/Forms/BasicForm";
import Alert, { AlertType } from "Common/UI/Components/Alerts/Alert";
import { JSONObject } from "Common/Types/JSON";
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
import HTTPResponse from "Common/Types/API/HTTPResponse";
import URL from "Common/Types/API/URL";
import API from "Common/UI/Utils/API/API";
import { APP_API_URL } from "Common/UI/Config";
import WhatsAppTemplateMessages, {
WhatsAppTemplateId,
WhatsAppTemplateIds,
WhatsAppTemplateLanguage,
} from "Common/Types/WhatsApp/WhatsAppTemplates";
type ToFriendlyName = (value: string) => string;
const toFriendlyName: ToFriendlyName = (value: string): string => {
return value
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/_/g, " ")
.replace(/\s+/g, " ")
.trim();
};
type ExtractTemplateVariables = (template: string) => Array<string>;
const extractTemplateVariables: ExtractTemplateVariables = (
template: string,
): Array<string> => {
const matches: RegExpMatchArray | null = template.match(/\{\{(.*?)\}\}/g);
if (!matches) {
return [];
}
const uniqueVariables: Set<string> = new Set<string>();
for (const match of matches) {
const variable: string = match.replace("{{", "").replace("}}", "").trim();
if (variable) {
uniqueVariables.add(variable);
}
}
return Array.from(uniqueVariables).sort((a: string, b: string) => {
return a.localeCompare(b);
});
};
type BuildWhatsAppSetupMarkdown = () => string;
const buildWhatsAppSetupMarkdown: BuildWhatsAppSetupMarkdown = (): string => {
const templateKeys: Array<keyof typeof WhatsAppTemplateIds> = Object.keys(
WhatsAppTemplateIds,
) as Array<keyof typeof WhatsAppTemplateIds>;
const description: string =
"Follow these steps to connect Meta WhatsApp with OneUptime so notifications can be delivered via WhatsApp.";
const prerequisitesList: Array<string> = [
"Meta Business Manager admin access for the WhatsApp Business Account.",
"A WhatsApp Business phone number approved for API messaging.",
"Admin access to OneUptime with permission to edit global notification settings.",
];
const setupStepsList: Array<string> = [
"Sign in to the [Meta Business Manager](https://business.facebook.com/) with admin access to your WhatsApp Business Account.",
"From **Business Settings → Accounts → WhatsApp Accounts**, create or select the account that owns your sender phone number.",
"In Buisness Portfolio, create a system user and assign it to the WhatsApp Business Account with the role of **Admin**.",
"Generate a token for this system user and this will be your long-lived access token. Make sure to select the **whatsapp_business_management** and **whatsapp_business_messaging** permissions when generating the token.",
"Paste the access token and phone number ID into the **Meta WhatsApp Settings** card above, then save.",
"For the **Business Account ID**, go to **Business Settings → Business Info** (or **Business Settings → WhatsApp Accounts → Settings**) and copy the **WhatsApp Business Account ID** value.",
"To locate the **App ID** and **App Secret**, open [Meta for Developers](https://developers.facebook.com/apps/), select your WhatsApp app, then navigate to **Settings → Basic**. The App ID is shown at the top; click **Show** next to **App Secret** to reveal and copy it.",
"Create each template listed below in the Meta WhatsApp Manager. Make sure the template name, language, and variables match exactly. You can however change the content to your preference. Please make sure it's approved by Meta.",
"Send a test notification from OneUptime to confirm that WhatsApp delivery succeeds.",
];
const prerequisitesMarkdown: string = prerequisitesList
.map((item: string) => {
return `- ${item}`;
})
.join("\n");
const setupStepsMarkdown: string = setupStepsList
.map((item: string, index: number) => {
return `${index + 1}. ${item}`;
})
.join("\n");
const tableRows: string = templateKeys
.map((enumKey: keyof typeof WhatsAppTemplateIds) => {
const templateId: WhatsAppTemplateId = WhatsAppTemplateIds[enumKey];
const friendlyName: string = toFriendlyName(enumKey.toString());
const templateMessage: string = WhatsAppTemplateMessages[templateId];
const language: string = WhatsAppTemplateLanguage[templateId] || "en";
const variables: Array<string> =
extractTemplateVariables(templateMessage);
const variableList: string =
variables.length > 0
? variables
.map((variable: string) => {
return `\`${variable}\``;
})
.join(", ")
: "_None_";
return `| ${friendlyName} | \`${templateId}\` | ${language} | ${variableList} |`;
})
.join("\n");
const templateBodies: string = templateKeys
.map((enumKey: keyof typeof WhatsAppTemplateIds) => {
const templateId: WhatsAppTemplateId = WhatsAppTemplateIds[enumKey];
const friendlyName: string = toFriendlyName(enumKey.toString());
const templateMessage: string = WhatsAppTemplateMessages[templateId];
const language: string = WhatsAppTemplateLanguage[templateId] || "en";
const variables: Array<string> =
extractTemplateVariables(templateMessage);
const variableMarkdown: string =
variables.length > 0
? variables
.map((variable: string) => {
return `- \`${variable}\``;
})
.join("\n")
: "_None_";
const variablesHeading: string = variables.length
? `**Variables (${variables.length})**`
: "**Variables**";
return [
`#### ${friendlyName}`,
"",
`**Template Name:** \`${templateId}\``,
`**Language:** ${language}`,
"",
variablesHeading,
variableMarkdown,
"",
"**Body**",
"```plaintext",
templateMessage,
"```",
"",
"---",
].join("\n");
})
.join("\n\n");
const templateSummaryTable: string = [
"| Friendly Name | Template Name | Language | Variables |",
"| --- | --- | --- | --- |",
tableRows,
]
.filter(Boolean)
.join("\n");
return [
description,
"### Prerequisites",
prerequisitesMarkdown,
"### Setup Steps",
setupStepsMarkdown,
"### Required WhatsApp Templates",
templateSummaryTable,
"### Template Bodies",
"> Copy the exact template body below—including punctuation and spacing—when creating each template inside Meta. The variables list shows every placeholder that must be configured in WhatsApp Manager.",
templateBodies,
]
.filter(Boolean)
.join("\n\n");
};
const whatsappSetupMarkdown: string = buildWhatsAppSetupMarkdown();
const SettingsWhatsApp: FunctionComponent = (): ReactElement => {
const [isSendingTest, setIsSendingTest] = useState<boolean>(false);
const [testError, setTestError] = useState<string>("");
const [testSuccess, setTestSuccess] = useState<string>("");
return (
<Page
title={"Admin Settings"}
breadcrumbLinks={[
{
title: "Admin Dashboard",
to: RouteUtil.populateRouteParams(RouteMap[PageMap.HOME] as Route),
},
{
title: "Settings",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.SETTINGS] as Route,
),
},
{
title: "WhatsApp",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.SETTINGS_WHATSAPP] as Route,
),
},
]}
sideMenu={<DashboardSideMenu />}
>
<CardModelDetail
name="Meta WhatsApp Settings"
cardProps={{
title: "Meta WhatsApp Settings",
description:
"Configure Meta WhatsApp credentials. These values are used to send WhatsApp notifications from OneUptime.",
}}
isEditable={true}
editButtonText="Edit Meta WhatsApp Config"
formSteps={[
{
title: "Credentials",
id: "meta-credentials",
},
{
title: "Meta App",
id: "meta-app",
},
]}
formFields={[
{
field: {
metaWhatsAppAccessToken: true,
},
title: "Access Token",
stepId: "meta-credentials",
fieldType: FormFieldSchemaType.EncryptedText,
required: true,
description:
"Long-lived access token generated in the Meta WhatsApp Business Platform.",
placeholder: "EAAG...",
},
{
field: {
metaWhatsAppPhoneNumberId: true,
},
title: "Phone Number ID",
stepId: "meta-credentials",
fieldType: FormFieldSchemaType.Text,
required: true,
description:
"The WhatsApp Business phone number ID associated with your sending number.",
placeholder: "123456789012345",
},
{
field: {
metaWhatsAppBusinessAccountId: true,
},
title: "Business Account ID",
stepId: "meta-credentials",
fieldType: FormFieldSchemaType.Text,
required: false,
description:
"Optional Business Account ID that owns the WhatsApp templates.",
placeholder: "123456789012345",
},
{
field: {
metaWhatsAppAppId: true,
},
title: "App ID",
stepId: "meta-app",
fieldType: FormFieldSchemaType.Text,
required: false,
description:
"Optional Facebook App ID tied to your WhatsApp integration.",
placeholder: "987654321098765",
},
{
field: {
metaWhatsAppAppSecret: true,
},
title: "App Secret",
stepId: "meta-app",
fieldType: FormFieldSchemaType.EncryptedText,
required: false,
description:
"Optional Facebook App Secret used for webhook signature verification.",
placeholder: "Facebook App Secret",
},
]}
modelDetailProps={{
modelType: GlobalConfig,
id: "model-detail-global-config-meta-whatsapp",
fields: [
{
field: {
metaWhatsAppAccessToken: true,
},
title: "Access Token",
fieldType: FieldType.HiddenText,
placeholder: "Not Configured",
},
{
field: {
metaWhatsAppPhoneNumberId: true,
},
title: "Phone Number ID",
fieldType: FieldType.Text,
placeholder: "Not Configured",
},
{
field: {
metaWhatsAppBusinessAccountId: true,
},
title: "Business Account ID",
fieldType: FieldType.Text,
placeholder: "Not Configured",
},
{
field: {
metaWhatsAppAppId: true,
},
title: "App ID",
fieldType: FieldType.Text,
placeholder: "Not Configured",
},
{
field: {
metaWhatsAppAppSecret: true,
},
title: "App Secret",
fieldType: FieldType.HiddenText,
placeholder: "Not Configured",
},
],
modelId: ObjectID.getZeroObjectID(),
}}
/>
<Card
title="Send Test WhatsApp Message"
description="Send a test WhatsApp template message to confirm your Meta configuration."
>
{testSuccess ? (
<Alert
type={AlertType.SUCCESS}
title={testSuccess}
className="mb-4"
/>
) : (
<></>
)}
<BasicForm
id="send-test-whatsapp-form"
name="Send Test WhatsApp Message"
isLoading={isSendingTest}
error={testError || ""}
submitButtonText="Send Test Message"
maxPrimaryButtonWidth={true}
initialValues={{
phoneNumber: "",
}}
fields={[
{
field: {
phoneNumber: true,
},
title: "Recipient WhatsApp Number",
description:
"Enter the full international phone number (including country code) that should receive the test message.",
placeholder: "+11234567890",
required: true,
fieldType: FormFieldSchemaType.Phone,
disableSpellCheck: true,
},
]}
onSubmit={async (
values: JSONObject,
onSubmitSuccessful?: () => void,
) => {
const toPhone: string = String(values["phoneNumber"] || "").trim();
if (!toPhone) {
setTestSuccess("");
setTestError(
"Please enter a WhatsApp number before sending a test message.",
);
return;
}
setIsSendingTest(true);
setTestError("");
setTestSuccess("");
try {
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
await API.post({
url: URL.fromString(APP_API_URL.toString()).addRoute(
"/notification/whatsapp/test",
),
data: {
toPhone,
templateKey: WhatsAppTemplateIds.TestNotification,
templateLanguageCode:
WhatsAppTemplateLanguage[
WhatsAppTemplateIds.TestNotification
],
},
});
if (response instanceof HTTPErrorResponse) {
throw response;
}
if (response.isFailure()) {
throw new Error("Failed to send test WhatsApp message.");
}
setTestSuccess(
"Test WhatsApp message sent successfully. Check the recipient device to confirm delivery.",
);
if (onSubmitSuccessful) {
onSubmitSuccessful();
}
} catch (err) {
setTestError(API.getFriendlyMessage(err));
} finally {
setIsSendingTest(false);
}
}}
/>
</Card>
<Card
title="Meta WhatsApp Setup Guide"
description="Steps to connect Meta WhatsApp and the templates you must provision."
>
<MarkdownViewer text={whatsappSetupMarkdown} />
</Card>
</Page>
);
};
export default SettingsWhatsApp;

View File

@@ -15,6 +15,7 @@ enum PageMap {
SETTINGS_HOST = "SETTINGS_HOST",
SETTINGS_SMTP = "SETTINGS_SMTP",
SETTINGS_CALL_AND_SMS = "SETTINGS_CALL_AND_SMS",
SETTINGS_WHATSAPP = "SETTINGS_WHATSAPP",
SETTINGS_PROBES = "SETTINGS_PROBES",
SETTINGS_AUTHENTICATION = "SETTINGS_AUTHENTICATION",
SETTINGS_API_KEY = "SETTINGS_API_KEY",

View File

@@ -25,6 +25,7 @@ const RouteMap: Dictionary<Route> = {
[PageMap.SETTINGS_HOST]: new Route(`/admin/settings/host`),
[PageMap.SETTINGS_SMTP]: new Route(`/admin/settings/smtp`),
[PageMap.SETTINGS_CALL_AND_SMS]: new Route(`/admin/settings/call-and-sms`),
[PageMap.SETTINGS_WHATSAPP]: new Route(`/admin/settings/whatsapp`),
[PageMap.SETTINGS_PROBES]: new Route(`/admin/settings/probes`),
[PageMap.SETTINGS_AUTHENTICATION]: new Route(
`/admin/settings/authentication`,

View File

@@ -17,6 +17,7 @@ ARG APP_VERSION
ENV GIT_SHA=${GIT_SHA}
ENV APP_VERSION=${APP_VERSION}
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
# IF APP_VERSION is not set, set it to 1.0.0

View File

@@ -31,6 +31,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 UserWhatsAppAPI from "Common/Server/API/UserWhatsAppAPI";
import UserPushAPI from "Common/Server/API/UserPushAPI";
import ApiKeyPermissionService, {
Service as ApiKeyPermissionServiceType,
@@ -284,6 +285,9 @@ import ShortLinkService, {
import SmsLogService, {
Service as SmsLogServiceType,
} from "Common/Server/Services/SmsLogService";
import WhatsAppLogService, {
Service as WhatsAppLogServiceType,
} from "Common/Server/Services/WhatsAppLogService";
import PushNotificationLogService, {
Service as PushNotificationLogServiceType,
} from "Common/Server/Services/PushNotificationLogService";
@@ -458,6 +462,7 @@ import ServiceCatalogOwnerUser from "Common/Models/DatabaseModels/ServiceCatalog
import ServiceCopilotCodeRepository from "Common/Models/DatabaseModels/ServiceCopilotCodeRepository";
import ShortLink from "Common/Models/DatabaseModels/ShortLink";
import SmsLog from "Common/Models/DatabaseModels/SmsLog";
import WhatsAppLog from "Common/Models/DatabaseModels/WhatsAppLog";
import StatusPageAnnouncement from "Common/Models/DatabaseModels/StatusPageAnnouncement";
// Custom Fields API
import StatusPageCustomField from "Common/Models/DatabaseModels/StatusPageCustomField";
@@ -1538,6 +1543,14 @@ const BaseAPIFeatureSet: FeatureSet = {
new BaseAPI<SmsLog, SmsLogServiceType>(SmsLog, SmsLogService).getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<WhatsAppLog, WhatsAppLogServiceType>(
WhatsAppLog,
WhatsAppLogService,
).getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<PushNotificationLog, PushNotificationLogServiceType>(
@@ -1675,6 +1688,10 @@ 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 UserWhatsAppAPI().getRouter(),
);
app.use(`/${APP_NAME.toLocaleLowerCase()}`, new UserPushAPI().getRouter());
app.use(`/${APP_NAME.toLocaleLowerCase()}`, new ProbeAPI().getRouter());

View File

@@ -6,6 +6,7 @@ import Express, {
ExpressRequest,
ExpressResponse,
ExpressRouter,
NextFunction,
OneUptimeRequest,
} from "Common/Server/Utils/Express";
import Response from "Common/Server/Utils/Response";
@@ -166,13 +167,7 @@ const formatTeamForSCIM: (
}
return {
// Include both SCIM 2.0 Group schema and SCIM 1.1 core schema for broader compatibility
// Some provisioning agents (e.g., Okta On-Prem) expect 'urn:scim:schemas:core:1.0'
// to be present even when using SCIM v2 endpoints.
schemas: [
"urn:ietf:params:scim:schemas:core:2.0:Group",
"urn:scim:schemas:core:1.0",
],
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"],
id: team.id?.toString(),
displayName: team.name?.toString(),
members: members,
@@ -189,7 +184,11 @@ const formatTeamForSCIM: (
router.get(
"/scim/v2/:projectScimId/ServiceProviderConfig",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
logger.debug(
`Project SCIM ServiceProviderConfig - scimId: ${req.params["projectScimId"]!}`,
@@ -208,7 +207,7 @@ router.get(
return Response.sendJsonObjectResponse(req, res, serviceProviderConfig);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
return next(err);
}
},
);
@@ -217,7 +216,11 @@ router.get(
router.get(
"/scim/v2/:projectScimId/Users",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
logger.debug(
`Project SCIM Users list - scimId: ${req.params["projectScimId"]!}`,
@@ -390,7 +393,7 @@ router.get(
);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
return next(err);
}
},
);
@@ -399,7 +402,11 @@ router.get(
router.get(
"/scim/v2/:projectScimId/Users/:userId",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
logger.debug(
`SCIM Get individual user request for userId: ${req.params["userId"]}, projectScimId: ${req.params["projectScimId"]}`,
@@ -458,7 +465,7 @@ router.get(
return Response.sendJsonObjectResponse(req, res, user);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
return next(err);
}
},
);
@@ -467,7 +474,11 @@ router.get(
router.put(
"/scim/v2/:projectScimId/Users/:userId",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
logger.debug(
`SCIM Update user request for userId: ${req.params["userId"]}, projectScimId: ${req.params["projectScimId"]}`,
@@ -624,7 +635,7 @@ router.put(
return Response.sendJsonObjectResponse(req, res, user);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
return next(err);
}
},
);
@@ -633,7 +644,11 @@ router.put(
router.get(
"/scim/v2/:projectScimId/Groups",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
logger.debug(
`SCIM Groups list request for projectScimId: ${req.params["projectScimId"]}`,
@@ -713,7 +728,7 @@ router.get(
});
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
return next(err);
}
},
);
@@ -722,7 +737,11 @@ router.get(
router.get(
"/scim/v2/:projectScimId/Groups/:groupId",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
logger.debug(
`SCIM Get individual group request for groupId: ${req.params["groupId"]}, projectScimId: ${req.params["projectScimId"]}`,
@@ -777,7 +796,7 @@ router.get(
return Response.sendJsonObjectResponse(req, res, group);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
return next(err);
}
},
);
@@ -786,7 +805,11 @@ router.get(
router.post(
"/scim/v2/:projectScimId/Groups",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
logger.debug(
`SCIM Create group request for projectScimId: ${req.params["projectScimId"]}`,
@@ -909,7 +932,7 @@ router.post(
return Response.sendJsonObjectResponse(req, res, createdGroup);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
return next(err);
}
},
);
@@ -918,7 +941,11 @@ router.post(
router.put(
"/scim/v2/:projectScimId/Groups/:groupId",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
logger.debug(
`SCIM Update group request for groupId: ${req.params["groupId"]}, projectScimId: ${req.params["projectScimId"]}`,
@@ -1072,7 +1099,7 @@ router.put(
throw new NotFoundException("Failed to retrieve updated group");
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
return next(err);
}
},
);
@@ -1081,7 +1108,11 @@ router.put(
router.delete(
"/scim/v2/:projectScimId/Groups/:groupId",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
logger.debug(
`SCIM Delete group request for groupId: ${req.params["groupId"]}, projectScimId: ${req.params["projectScimId"]}`,
@@ -1159,7 +1190,7 @@ router.delete(
});
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
return next(err);
}
},
);
@@ -1168,7 +1199,11 @@ router.delete(
router.patch(
"/scim/v2/:projectScimId/Groups/:groupId",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
logger.debug(
`SCIM Patch group request for groupId: ${req.params["groupId"]}, projectScimId: ${req.params["projectScimId"]}`,
@@ -1403,7 +1438,7 @@ router.patch(
throw new NotFoundException("Failed to retrieve updated group");
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
return next(err);
}
},
);
@@ -1412,7 +1447,11 @@ router.patch(
router.post(
"/scim/v2/:projectScimId/Users",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
logger.debug(
`SCIM Create user request for projectScimId: ${req.params["projectScimId"]}`,
@@ -1499,7 +1538,7 @@ router.post(
return Response.sendJsonObjectResponse(req, res, createdUser);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
return next(err);
}
},
);
@@ -1508,7 +1547,11 @@ router.post(
router.delete(
"/scim/v2/:projectScimId/Users/:userId",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
logger.debug(
`SCIM Delete user request for userId: ${req.params["userId"]}, projectScimId: ${req.params["projectScimId"]}`,
@@ -1560,7 +1603,7 @@ router.delete(
});
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
return next(err);
}
},
);

View File

@@ -47,7 +47,11 @@ const router: ExpressRouter = Express.getRouter();
router.get(
"/service-provider-login",
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
if (!req.query["email"]) {
return Response.sendErrorResponse(
@@ -152,7 +156,11 @@ router.get(
} catch (err) {
logger.error(err);
Response.sendErrorResponse(req, res, err as Exception);
if (err instanceof Exception) {
return next(err);
}
return next(new ServerException());
}
},
);
@@ -162,7 +170,7 @@ router.get(
async (
req: ExpressRequest,
res: ExpressResponse,
_next: NextFunction,
next: NextFunction,
): Promise<void> => {
try {
if (!req.params["projectId"]) {
@@ -238,22 +246,42 @@ router.get(
} catch (err) {
logger.error(err);
Response.sendErrorResponse(req, res, err as Exception);
if (err instanceof Exception) {
return next(err);
}
return next(new ServerException());
}
},
);
router.get(
"/idp-login/:projectId/:projectSsoId",
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
return await loginUserWithSso(req, res);
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
await loginUserWithSso(req, res);
} catch (err) {
return next(err);
}
},
);
router.post(
"/idp-login/:projectId/:projectSsoId",
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
return await loginUserWithSso(req, res);
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
await loginUserWithSso(req, res);
} catch (err) {
return next(err);
}
},
);

View File

@@ -4,6 +4,7 @@ import Express, {
ExpressRequest,
ExpressResponse,
ExpressRouter,
NextFunction,
OneUptimeRequest,
} from "Common/Server/Utils/Express";
import Response from "Common/Server/Utils/Response";
@@ -29,7 +30,11 @@ const router: ExpressRouter = Express.getRouter();
router.get(
"/status-page-scim/v2/:statusPageScimId/ServiceProviderConfig",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
logger.debug(
`Status Page SCIM ServiceProviderConfig - scimId: ${req.params["statusPageScimId"]!}`,
@@ -44,7 +49,7 @@ router.get(
return Response.sendJsonObjectResponse(req, res, serviceProviderConfig);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
return next(err);
}
},
);
@@ -53,7 +58,11 @@ router.get(
router.get(
"/status-page-scim/v2/:statusPageScimId/Users",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
logger.debug(
`Status Page SCIM Users list request for statusPageScimId: ${req.params["statusPageScimId"]}`,
@@ -164,7 +173,7 @@ router.get(
});
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
return next(err);
}
},
);
@@ -173,7 +182,11 @@ router.get(
router.get(
"/status-page-scim/v2/:statusPageScimId/Users/:userId",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
logger.debug(
`Status Page SCIM Get individual user request for userId: ${req.params["userId"]}, statusPageScimId: ${req.params["statusPageScimId"]}`,
@@ -231,7 +244,7 @@ router.get(
return Response.sendJsonObjectResponse(req, res, user);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
return next(err);
}
},
);
@@ -240,7 +253,11 @@ router.get(
router.post(
"/status-page-scim/v2/:statusPageScimId/Users",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
logger.debug(
`Status Page SCIM Create user request for statusPageScimId: ${req.params["statusPageScimId"]}`,
@@ -333,7 +350,7 @@ router.post(
return Response.sendJsonObjectResponse(req, res, createdUser);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
return next(err);
}
},
);
@@ -342,7 +359,11 @@ router.post(
router.put(
"/status-page-scim/v2/:statusPageScimId/Users/:userId",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
logger.debug(
`Status Page SCIM Update user request for userId: ${req.params["userId"]}, statusPageScimId: ${req.params["statusPageScimId"]}`,
@@ -489,7 +510,7 @@ router.put(
return Response.sendJsonObjectResponse(req, res, user);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
return next(err);
}
},
);
@@ -498,7 +519,11 @@ router.put(
router.delete(
"/status-page-scim/v2/:statusPageScimId/Users/:userId",
SCIMMiddleware.isAuthorizedSCIMRequest,
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
logger.debug(
`Status Page SCIM Delete user request for userId: ${req.params["userId"]}, statusPageScimId: ${req.params["statusPageScimId"]}`,
@@ -562,7 +587,7 @@ router.delete(
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(req, res, err as BadRequestException);
return next(err);
}
},
);

View File

@@ -115,7 +115,11 @@ router.get(
router.post(
"/status-page-idp-login/:statusPageId/:statusPageSsoId",
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
async (
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
): Promise<void> => {
try {
const samlResponseBase64: string = req.body.SAMLResponse;
@@ -312,7 +316,11 @@ router.post(
);
} catch (err) {
logger.error(err);
Response.sendErrorResponse(req, res, new ServerException());
if (err instanceof Exception) {
return next(err);
}
return next(new ServerException());
}
},
);

View File

@@ -12,6 +12,7 @@ import Express, {
ExpressRequest,
ExpressResponse,
ExpressRouter,
NextFunction,
} from "Common/Server/Utils/Express";
import logger from "Common/Server/Utils/Logger";
import Response from "Common/Server/Utils/Response";
@@ -22,139 +23,146 @@ const router: ExpressRouter = Express.getRouter();
router.post(
"/make-call",
ClusterKeyAuthorization.isAuthorizedServiceMiddleware,
async (req: ExpressRequest, res: ExpressResponse) => {
const body: JSONObject = JSONFunctions.deserialize(req.body);
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
const body: JSONObject = JSONFunctions.deserialize(req.body);
await CallService.makeCall(body["callRequest"] as CallRequest, {
projectId: body["projectId"] as ObjectID,
isSensitive: (body["isSensitive"] as boolean) || false,
userOnCallLogTimelineId:
(body["userOnCallLogTimelineId"] as ObjectID) || undefined,
customTwilioConfig: body["customTwilioConfig"] as any,
incidentId: (body["incidentId"] as ObjectID) || undefined,
alertId: (body["alertId"] as ObjectID) || undefined,
scheduledMaintenanceId:
(body["scheduledMaintenanceId"] as ObjectID) || undefined,
statusPageId: (body["statusPageId"] as ObjectID) || undefined,
statusPageAnnouncementId:
(body["statusPageAnnouncementId"] as ObjectID) || undefined,
userId: (body["userId"] as ObjectID) || undefined,
onCallPolicyId: (body["onCallPolicyId"] as ObjectID) || undefined,
onCallPolicyEscalationRuleId:
(body["onCallPolicyEscalationRuleId"] as ObjectID) || undefined,
onCallDutyPolicyExecutionLogTimelineId:
(body["onCallDutyPolicyExecutionLogTimelineId"] as ObjectID) ||
undefined,
onCallScheduleId: (body["onCallScheduleId"] as ObjectID) || undefined,
teamId: (body["teamId"] as ObjectID) || undefined,
});
await CallService.makeCall(body["callRequest"] as CallRequest, {
projectId: body["projectId"] as ObjectID,
isSensitive: (body["isSensitive"] as boolean) || false,
userOnCallLogTimelineId:
(body["userOnCallLogTimelineId"] as ObjectID) || undefined,
customTwilioConfig: body["customTwilioConfig"] as any,
incidentId: (body["incidentId"] as ObjectID) || undefined,
alertId: (body["alertId"] as ObjectID) || undefined,
scheduledMaintenanceId:
(body["scheduledMaintenanceId"] as ObjectID) || undefined,
statusPageId: (body["statusPageId"] as ObjectID) || undefined,
statusPageAnnouncementId:
(body["statusPageAnnouncementId"] as ObjectID) || undefined,
userId: (body["userId"] as ObjectID) || undefined,
onCallPolicyId: (body["onCallPolicyId"] as ObjectID) || undefined,
onCallPolicyEscalationRuleId:
(body["onCallPolicyEscalationRuleId"] as ObjectID) || undefined,
onCallDutyPolicyExecutionLogTimelineId:
(body["onCallDutyPolicyExecutionLogTimelineId"] as ObjectID) ||
undefined,
onCallScheduleId: (body["onCallScheduleId"] as ObjectID) || undefined,
teamId: (body["teamId"] as ObjectID) || undefined,
});
return Response.sendEmptySuccessResponse(req, res);
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
return next(err);
}
},
);
router.post("/test", async (req: ExpressRequest, res: ExpressResponse) => {
const body: JSONObject = req.body;
router.post(
"/test",
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
const body: JSONObject = req.body;
const callSMSConfigId: ObjectID = new ObjectID(
body["callSMSConfigId"] as string,
);
const callSMSConfigId: ObjectID = new ObjectID(
body["callSMSConfigId"] as string,
);
const config: ProjectCallSMSConfig | null =
await ProjectCallSMSConfigService.findOneById({
id: callSMSConfigId,
props: {
isRoot: true,
},
select: {
_id: true,
twilioAccountSID: true,
twilioAuthToken: true,
twilioPrimaryPhoneNumber: true,
twilioSecondaryPhoneNumbers: true,
projectId: true,
},
});
const config: ProjectCallSMSConfig | null =
await ProjectCallSMSConfigService.findOneById({
id: callSMSConfigId,
props: {
isRoot: true,
},
select: {
_id: true,
twilioAccountSID: true,
twilioAuthToken: true,
twilioPrimaryPhoneNumber: true,
twilioSecondaryPhoneNumbers: true,
projectId: true,
},
});
if (!config) {
return Response.sendErrorResponse(
req,
res,
new BadDataException(
"call and sms config not found for id" + callSMSConfigId.toString(),
),
);
}
if (!config) {
return Response.sendErrorResponse(
req,
res,
new BadDataException(
"call and sms config not found for id" + callSMSConfigId.toString(),
),
);
}
const toPhone: Phone = new Phone(body["toPhone"] as string);
const toPhone: Phone = new Phone(body["toPhone"] as string);
if (!toPhone) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("toPhone is required"),
);
}
if (!toPhone) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("toPhone is required"),
);
}
// if any of the twilio config is missing, we will not send make the call
// if any of the twilio config is missing, we will not send make the call
if (!config.twilioAccountSID) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("twilioAccountSID is required"),
);
}
if (!config.twilioAccountSID) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("twilioAccountSID is required"),
);
}
if (!config.twilioAuthToken) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("twilioAuthToken is required"),
);
}
if (!config.twilioAuthToken) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("twilioAuthToken is required"),
);
}
if (!config.twilioPrimaryPhoneNumber) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("twilioPrimaryPhoneNumber is required"),
);
}
if (!config.twilioPrimaryPhoneNumber) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("twilioPrimaryPhoneNumber is required"),
);
}
const twilioConfig: TwilioConfig | undefined =
ProjectCallSMSConfigService.toTwilioConfig(config);
const twilioConfig: TwilioConfig | undefined =
ProjectCallSMSConfigService.toTwilioConfig(config);
try {
if (!twilioConfig) {
throw new BadDataException("twilioConfig is undefined");
try {
if (!twilioConfig) {
throw new BadDataException("twilioConfig is undefined");
}
const testCallRequest: CallRequest = {
data: [
{
sayMessage: "This is a test call from OneUptime.",
},
],
to: toPhone,
};
await CallService.makeCall(testCallRequest, {
projectId: config.projectId,
customTwilioConfig: twilioConfig,
});
} catch (err) {
logger.error(err);
throw new BadDataException(
"Error making test call. Please check the twilio logs for more details",
);
}
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
return next(err);
}
const testCallRequest: CallRequest = {
data: [
{
sayMessage: "This is a test call from OneUptime.",
},
],
to: toPhone,
};
await CallService.makeCall(testCallRequest, {
projectId: config.projectId,
customTwilioConfig: twilioConfig,
});
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(
req,
res,
new BadDataException(
"Error making test call. Please check the twilio logs for more details",
),
);
}
return Response.sendEmptySuccessResponse(req, res);
});
},
);
export default router;

View File

@@ -11,6 +11,7 @@ import Express, {
ExpressRequest,
ExpressResponse,
ExpressRouter,
NextFunction,
} from "Common/Server/Utils/Express";
import Response from "Common/Server/Utils/Response";
@@ -19,70 +20,74 @@ const router: ExpressRouter = Express.getRouter();
router.post(
"/send",
ClusterKeyAuthorization.isAuthorizedServiceMiddleware,
async (req: ExpressRequest, res: ExpressResponse) => {
const body: JSONObject = req.body;
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
const body: JSONObject = req.body;
const mail: EmailMessage = {
templateType: body["templateType"] as EmailTemplateType,
toEmail: new Email(body["toEmail"] as string),
subject: body["subject"] as string,
vars: body["vars"] as Dictionary<string>,
body: (body["body"] as string) || "",
};
const mail: EmailMessage = {
templateType: body["templateType"] as EmailTemplateType,
toEmail: new Email(body["toEmail"] as string),
subject: body["subject"] as string,
vars: body["vars"] as Dictionary<string>,
body: (body["body"] as string) || "",
};
let mailServer: EmailServer | undefined = undefined;
let mailServer: EmailServer | undefined = undefined;
if (hasMailServerSettingsInBody(body)) {
mailServer = MailService.getEmailServer(req.body);
if (hasMailServerSettingsInBody(body)) {
mailServer = MailService.getEmailServer(req.body);
}
await MailService.send(mail, {
projectId: body["projectId"]
? new ObjectID(body["projectId"] as string)
: undefined,
emailServer: mailServer,
userOnCallLogTimelineId:
(body["userOnCallLogTimelineId"] as ObjectID) || undefined,
incidentId: body["incidentId"]
? new ObjectID(body["incidentId"].toString())
: undefined,
alertId: body["alertId"]
? new ObjectID(body["alertId"].toString())
: undefined,
scheduledMaintenanceId: body["scheduledMaintenanceId"]
? new ObjectID(body["scheduledMaintenanceId"].toString())
: undefined,
statusPageId: body["statusPageId"]
? new ObjectID(body["statusPageId"].toString())
: undefined,
statusPageAnnouncementId: body["statusPageAnnouncementId"]
? new ObjectID(body["statusPageAnnouncementId"].toString())
: undefined,
userId: body["userId"]
? new ObjectID(body["userId"].toString())
: undefined,
onCallPolicyId: body["onCallPolicyId"]
? new ObjectID(body["onCallPolicyId"].toString())
: undefined,
onCallPolicyEscalationRuleId: body["onCallPolicyEscalationRuleId"]
? new ObjectID(body["onCallPolicyEscalationRuleId"].toString())
: undefined,
onCallDutyPolicyExecutionLogTimelineId: body[
"onCallDutyPolicyExecutionLogTimelineId"
]
? new ObjectID(
body["onCallDutyPolicyExecutionLogTimelineId"].toString(),
)
: undefined,
onCallScheduleId: body["onCallScheduleId"]
? new ObjectID(body["onCallScheduleId"].toString())
: undefined,
teamId: body["teamId"]
? new ObjectID(body["teamId"].toString())
: undefined,
});
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
return next(err);
}
await MailService.send(mail, {
projectId: body["projectId"]
? new ObjectID(body["projectId"] as string)
: undefined,
emailServer: mailServer,
userOnCallLogTimelineId:
(body["userOnCallLogTimelineId"] as ObjectID) || undefined,
incidentId: body["incidentId"]
? new ObjectID(body["incidentId"].toString())
: undefined,
alertId: body["alertId"]
? new ObjectID(body["alertId"].toString())
: undefined,
scheduledMaintenanceId: body["scheduledMaintenanceId"]
? new ObjectID(body["scheduledMaintenanceId"].toString())
: undefined,
statusPageId: body["statusPageId"]
? new ObjectID(body["statusPageId"].toString())
: undefined,
statusPageAnnouncementId: body["statusPageAnnouncementId"]
? new ObjectID(body["statusPageAnnouncementId"].toString())
: undefined,
userId: body["userId"]
? new ObjectID(body["userId"].toString())
: undefined,
onCallPolicyId: body["onCallPolicyId"]
? new ObjectID(body["onCallPolicyId"].toString())
: undefined,
onCallPolicyEscalationRuleId: body["onCallPolicyEscalationRuleId"]
? new ObjectID(body["onCallPolicyEscalationRuleId"].toString())
: undefined,
onCallDutyPolicyExecutionLogTimelineId: body[
"onCallDutyPolicyExecutionLogTimelineId"
]
? new ObjectID(
body["onCallDutyPolicyExecutionLogTimelineId"].toString(),
)
: undefined,
onCallScheduleId: body["onCallScheduleId"]
? new ObjectID(body["onCallScheduleId"].toString())
: undefined,
teamId: body["teamId"]
? new ObjectID(body["teamId"].toString())
: undefined,
});
return Response.sendEmptySuccessResponse(req, res);
},
);

View File

@@ -5,6 +5,7 @@ import Express, {
ExpressRequest,
ExpressResponse,
ExpressRouter,
NextFunction,
} from "Common/Server/Utils/Express";
import Response from "Common/Server/Utils/Response";
import { JSONObject } from "Common/Types/JSON";
@@ -15,50 +16,54 @@ const router: ExpressRouter = Express.getRouter();
router.post(
"/send",
ClusterKeyAuthorization.isAuthorizedServiceMiddleware,
async (req: ExpressRequest, res: ExpressResponse) => {
const body: JSONObject = JSONFunctions.deserialize(req.body);
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
const body: JSONObject = JSONFunctions.deserialize(req.body);
// Support both new devices format and legacy deviceTokens/deviceNames format
let devices: Array<{ token: string; name?: string }> = [];
// Support both new devices format and legacy deviceTokens/deviceNames format
let devices: Array<{ token: string; name?: string }> = [];
if (body["devices"]) {
// New format: devices as array of objects
devices = body["devices"] as Array<{ token: string; name?: string }>;
} else {
throw new Error("Invalid request format: 'devices' array is required.");
if (body["devices"]) {
// New format: devices as array of objects
devices = body["devices"] as Array<{ token: string; name?: string }>;
} else {
throw new Error("Invalid request format: 'devices' array is required.");
}
await PushService.send(
{
devices,
deviceType: (body["deviceType"] as any) || "web",
message: body["message"] as any,
},
{
projectId: (body["projectId"] as ObjectID) || undefined,
isSensitive: (body["isSensitive"] as boolean) || false,
userOnCallLogTimelineId:
(body["userOnCallLogTimelineId"] as ObjectID) || undefined,
incidentId: (body["incidentId"] as ObjectID) || undefined,
alertId: (body["alertId"] as ObjectID) || undefined,
scheduledMaintenanceId:
(body["scheduledMaintenanceId"] as ObjectID) || undefined,
statusPageId: (body["statusPageId"] as ObjectID) || undefined,
statusPageAnnouncementId:
(body["statusPageAnnouncementId"] as ObjectID) || undefined,
userId: (body["userId"] as ObjectID) || undefined,
onCallPolicyId: (body["onCallPolicyId"] as ObjectID) || undefined,
onCallPolicyEscalationRuleId:
(body["onCallPolicyEscalationRuleId"] as ObjectID) || undefined,
onCallDutyPolicyExecutionLogTimelineId:
(body["onCallDutyPolicyExecutionLogTimelineId"] as ObjectID) ||
undefined,
onCallScheduleId: (body["onCallScheduleId"] as ObjectID) || undefined,
teamId: (body["teamId"] as ObjectID) || undefined,
},
);
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
return next(err);
}
await PushService.send(
{
devices,
deviceType: (body["deviceType"] as any) || "web",
message: body["message"] as any,
},
{
projectId: (body["projectId"] as ObjectID) || undefined,
isSensitive: (body["isSensitive"] as boolean) || false,
userOnCallLogTimelineId:
(body["userOnCallLogTimelineId"] as ObjectID) || undefined,
incidentId: (body["incidentId"] as ObjectID) || undefined,
alertId: (body["alertId"] as ObjectID) || undefined,
scheduledMaintenanceId:
(body["scheduledMaintenanceId"] as ObjectID) || undefined,
statusPageId: (body["statusPageId"] as ObjectID) || undefined,
statusPageAnnouncementId:
(body["statusPageAnnouncementId"] as ObjectID) || undefined,
userId: (body["userId"] as ObjectID) || undefined,
onCallPolicyId: (body["onCallPolicyId"] as ObjectID) || undefined,
onCallPolicyEscalationRuleId:
(body["onCallPolicyEscalationRuleId"] as ObjectID) || undefined,
onCallDutyPolicyExecutionLogTimelineId:
(body["onCallDutyPolicyExecutionLogTimelineId"] as ObjectID) ||
undefined,
onCallScheduleId: (body["onCallScheduleId"] as ObjectID) || undefined,
teamId: (body["teamId"] as ObjectID) || undefined,
},
);
return Response.sendEmptySuccessResponse(req, res);
},
);

View File

@@ -11,6 +11,7 @@ import Express, {
ExpressRequest,
ExpressResponse,
ExpressRouter,
NextFunction,
} from "Common/Server/Utils/Express";
import logger from "Common/Server/Utils/Logger";
import Response from "Common/Server/Utils/Response";
@@ -21,130 +22,141 @@ const router: ExpressRouter = Express.getRouter();
router.post(
"/send",
ClusterKeyAuthorization.isAuthorizedServiceMiddleware,
async (req: ExpressRequest, res: ExpressResponse) => {
const body: JSONObject = JSONFunctions.deserialize(req.body);
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
const body: JSONObject = JSONFunctions.deserialize(req.body);
await SmsService.sendSms(body["to"] as Phone, body["message"] as string, {
projectId: body["projectId"] as ObjectID,
isSensitive: (body["isSensitive"] as boolean) || false,
userOnCallLogTimelineId:
(body["userOnCallLogTimelineId"] as ObjectID) || undefined,
customTwilioConfig: body["customTwilioConfig"] as any,
incidentId: (body["incidentId"] as ObjectID) || undefined,
alertId: (body["alertId"] as ObjectID) || undefined,
scheduledMaintenanceId:
(body["scheduledMaintenanceId"] as ObjectID) || undefined,
statusPageId: (body["statusPageId"] as ObjectID) || undefined,
statusPageAnnouncementId:
(body["statusPageAnnouncementId"] as ObjectID) || undefined,
userId: (body["userId"] as ObjectID) || undefined,
onCallPolicyId: (body["onCallPolicyId"] as ObjectID) || undefined,
onCallPolicyEscalationRuleId:
(body["onCallPolicyEscalationRuleId"] as ObjectID) || undefined,
onCallDutyPolicyExecutionLogTimelineId:
(body["onCallDutyPolicyExecutionLogTimelineId"] as ObjectID) ||
undefined,
onCallScheduleId: (body["onCallScheduleId"] as ObjectID) || undefined,
teamId: (body["teamId"] as ObjectID) || undefined,
});
await SmsService.sendSms(body["to"] as Phone, body["message"] as string, {
projectId: body["projectId"] as ObjectID,
isSensitive: (body["isSensitive"] as boolean) || false,
userOnCallLogTimelineId:
(body["userOnCallLogTimelineId"] as ObjectID) || undefined,
customTwilioConfig: body["customTwilioConfig"] as any,
incidentId: (body["incidentId"] as ObjectID) || undefined,
alertId: (body["alertId"] as ObjectID) || undefined,
scheduledMaintenanceId:
(body["scheduledMaintenanceId"] as ObjectID) || undefined,
statusPageId: (body["statusPageId"] as ObjectID) || undefined,
statusPageAnnouncementId:
(body["statusPageAnnouncementId"] as ObjectID) || undefined,
userId: (body["userId"] as ObjectID) || undefined,
onCallPolicyId: (body["onCallPolicyId"] as ObjectID) || undefined,
onCallPolicyEscalationRuleId:
(body["onCallPolicyEscalationRuleId"] as ObjectID) || undefined,
onCallDutyPolicyExecutionLogTimelineId:
(body["onCallDutyPolicyExecutionLogTimelineId"] as ObjectID) ||
undefined,
onCallScheduleId: (body["onCallScheduleId"] as ObjectID) || undefined,
teamId: (body["teamId"] as ObjectID) || undefined,
});
return Response.sendEmptySuccessResponse(req, res);
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
return next(err);
}
},
);
router.post("/test", async (req: ExpressRequest, res: ExpressResponse) => {
const body: JSONObject = req.body;
router.post(
"/test",
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
const body: JSONObject = req.body;
const callSMSConfigId: ObjectID = new ObjectID(
body["callSMSConfigId"] as string,
);
const callSMSConfigId: ObjectID = new ObjectID(
body["callSMSConfigId"] as string,
);
const config: ProjectCallSMSConfig | null =
await ProjectCallSMSConfigService.findOneById({
id: callSMSConfigId,
props: {
isRoot: true,
},
select: {
_id: true,
twilioAccountSID: true,
twilioAuthToken: true,
twilioPrimaryPhoneNumber: true,
twilioSecondaryPhoneNumbers: true,
projectId: true,
},
});
const config: ProjectCallSMSConfig | null =
await ProjectCallSMSConfigService.findOneById({
id: callSMSConfigId,
props: {
isRoot: true,
},
select: {
_id: true,
twilioAccountSID: true,
twilioAuthToken: true,
twilioPrimaryPhoneNumber: true,
twilioSecondaryPhoneNumbers: true,
projectId: true,
},
});
if (!config) {
return Response.sendErrorResponse(
req,
res,
new BadDataException(
"call and sms config not found for id" + callSMSConfigId.toString(),
),
);
}
if (!config) {
return Response.sendErrorResponse(
req,
res,
new BadDataException(
"call and sms config not found for id" + callSMSConfigId.toString(),
),
);
}
const toPhone: Phone = new Phone(body["toPhone"] as string);
const toPhone: Phone = new Phone(body["toPhone"] as string);
if (!toPhone) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("toPhone is required"),
);
}
if (!toPhone) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("toPhone is required"),
);
}
// if any of the twilio config is missing, we will not send make the call
// if any of the twilio config is missing, we will not send make the call
if (!config.twilioAccountSID) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("twilioAccountSID is required"),
);
}
if (!config.twilioAccountSID) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("twilioAccountSID is required"),
);
}
if (!config.twilioAuthToken) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("twilioAuthToken is required"),
);
}
if (!config.twilioAuthToken) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("twilioAuthToken is required"),
);
}
if (!config.twilioPrimaryPhoneNumber) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("twilioPrimaryPhoneNumber is required"),
);
}
if (!config.twilioPrimaryPhoneNumber) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("twilioPrimaryPhoneNumber is required"),
);
}
const twilioConfig: TwilioConfig | undefined =
ProjectCallSMSConfigService.toTwilioConfig(config);
const twilioConfig: TwilioConfig | undefined =
ProjectCallSMSConfigService.toTwilioConfig(config);
try {
if (!twilioConfig) {
throw new BadDataException("twilioConfig is undefined");
try {
if (!twilioConfig) {
throw new BadDataException("twilioConfig is undefined");
}
await SmsService.sendSms(
toPhone,
"This is a test SMS from OneUptime.",
{
projectId: config.projectId,
customTwilioConfig: twilioConfig,
},
);
} catch (err) {
logger.error(err);
throw new BadDataException(
"Failed to send test SMS. Please check the twilio logs for more details.",
);
}
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
return next(err);
}
await SmsService.sendSms(toPhone, "This is a test SMS from OneUptime.", {
projectId: config.projectId,
customTwilioConfig: twilioConfig,
});
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(
req,
res,
new BadDataException(
"Failed to send test SMS. Please check the twilio logs for more details.",
),
);
}
return Response.sendEmptySuccessResponse(req, res);
});
},
);
export default router;

View File

@@ -11,6 +11,7 @@ import Express, {
ExpressRequest,
ExpressResponse,
ExpressRouter,
NextFunction,
} from "Common/Server/Utils/Express";
import logger from "Common/Server/Utils/Logger";
import Response from "Common/Server/Utils/Response";
@@ -18,87 +19,92 @@ import ProjectSmtpConfig from "Common/Models/DatabaseModels/ProjectSmtpConfig";
const router: ExpressRouter = Express.getRouter();
router.post("/test", async (req: ExpressRequest, res: ExpressResponse) => {
const body: JSONObject = req.body;
router.post(
"/test",
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
const body: JSONObject = req.body;
const smtpConfigId: ObjectID = new ObjectID(body["smtpConfigId"] as string);
const smtpConfigId: ObjectID = new ObjectID(
body["smtpConfigId"] as string,
);
const config: ProjectSmtpConfig | null =
await ProjectSMTPConfigService.findOneById({
id: smtpConfigId,
props: {
isRoot: true,
},
select: {
_id: true,
hostname: true,
port: true,
username: true,
password: true,
fromEmail: true,
fromName: true,
secure: true,
projectId: true,
},
});
const config: ProjectSmtpConfig | null =
await ProjectSMTPConfigService.findOneById({
id: smtpConfigId,
props: {
isRoot: true,
},
select: {
_id: true,
hostname: true,
port: true,
username: true,
password: true,
fromEmail: true,
fromName: true,
secure: true,
projectId: true,
},
});
if (!config) {
return Response.sendErrorResponse(
req,
res,
new BadDataException(
"smtp-config not found for id" + smtpConfigId.toString(),
),
);
}
if (!config) {
return Response.sendErrorResponse(
req,
res,
new BadDataException(
"smtp-config not found for id" + smtpConfigId.toString(),
),
);
}
const toEmail: Email = new Email(body["toEmail"] as string);
const toEmail: Email = new Email(body["toEmail"] as string);
if (!toEmail) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("toEmail is required"),
);
}
if (!toEmail) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("toEmail is required"),
);
}
const mail: EmailMessage = {
templateType: EmailTemplateType.SMTPTest,
toEmail: new Email(body["toEmail"] as string),
subject: "Test Email from OneUptime",
vars: {},
body: "",
};
const mail: EmailMessage = {
templateType: EmailTemplateType.SMTPTest,
toEmail: new Email(body["toEmail"] as string),
subject: "Test Email from OneUptime",
vars: {},
body: "",
};
const mailServer: EmailServer = {
id: config.id!,
host: config.hostname!,
port: config.port!,
username: config.username!,
password: config.password!,
fromEmail: config.fromEmail!,
fromName: config.fromName!,
secure: Boolean(config.secure),
};
const mailServer: EmailServer = {
id: config.id!,
host: config.hostname!,
port: config.port!,
username: config.username!,
password: config.password!,
fromEmail: config.fromEmail!,
fromName: config.fromName!,
secure: Boolean(config.secure),
};
try {
await MailService.send(mail, {
emailServer: mailServer,
projectId: config.projectId!,
timeout: 4000,
});
} catch (err) {
logger.error(err);
return Response.sendErrorResponse(
req,
res,
new BadDataException(
"Cannot send email. Please check your SMTP config. If you are using Google or Gmail, please dont since it does not support machine access to their mail servers. If you are still having issues, please uncheck SSL/TLS toggle and try again. We recommend using SendGrid or Mailgun or any large volume mail provider for SMTP.",
),
);
}
try {
await MailService.send(mail, {
emailServer: mailServer,
projectId: config.projectId!,
timeout: 4000,
});
} catch (err) {
logger.error(err);
throw new BadDataException(
"Cannot send email. Please check your SMTP config. If you are using Google or Gmail, please dont since it does not support machine access to their mail servers. If you are still having issues, please uncheck SSL/TLS toggle and try again. We recommend using SendGrid or Mailgun or any large volume mail provider for SMTP.",
);
}
return Response.sendEmptySuccessResponse(req, res);
});
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
return next(err);
}
},
);
export default router;

View File

@@ -0,0 +1,171 @@
import WhatsAppService from "../Services/WhatsAppService";
import BadDataException from "Common/Types/Exception/BadDataException";
import { JSONObject } from "Common/Types/JSON";
import ObjectID from "Common/Types/ObjectID";
import Phone from "Common/Types/Phone";
import WhatsAppMessage from "Common/Types/WhatsApp/WhatsAppMessage";
import {
WhatsAppTemplateId,
WhatsAppTemplateIds,
WhatsAppTemplateLanguage,
} from "Common/Types/WhatsApp/WhatsAppTemplates";
import ClusterKeyAuthorization from "Common/Server/Middleware/ClusterKeyAuthorization";
import Express, {
ExpressRequest,
ExpressResponse,
ExpressRouter,
NextFunction,
} from "Common/Server/Utils/Express";
import Response from "Common/Server/Utils/Response";
const router: ExpressRouter = Express.getRouter();
const toTemplateVariables: (
rawVariables: JSONObject | undefined,
) => Record<string, string> | undefined = (
rawVariables: JSONObject | undefined,
): Record<string, string> | undefined => {
if (!rawVariables) {
return undefined;
}
const result: Record<string, string> = {};
for (const key of Object.keys(rawVariables)) {
const value: unknown = rawVariables[key];
if (value !== null && value !== undefined) {
result[key] = String(value);
}
}
return Object.keys(result).length > 0 ? result : undefined;
};
router.post(
"/send",
ClusterKeyAuthorization.isAuthorizedServiceMiddleware,
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
const body: JSONObject = req.body as JSONObject;
if (!body["to"]) {
throw new BadDataException("`to` phone number is required");
}
const toPhone: Phone = new Phone(body["to"] as string);
const message: WhatsAppMessage = {
to: toPhone,
body: (body["body"] as string) || "",
templateKey: (body["templateKey"] as string) || undefined,
templateVariables: toTemplateVariables(
body["templateVariables"] as JSONObject | undefined,
),
templateLanguageCode:
(body["templateLanguageCode"] as string) || undefined,
};
try {
await WhatsAppService.sendWhatsApp(message, {
projectId: body["projectId"]
? new ObjectID(body["projectId"] as string)
: undefined,
isSensitive: (body["isSensitive"] as boolean) || false,
userOnCallLogTimelineId: body["userOnCallLogTimelineId"]
? new ObjectID(body["userOnCallLogTimelineId"] as string)
: undefined,
incidentId: body["incidentId"]
? new ObjectID(body["incidentId"] as string)
: undefined,
alertId: body["alertId"]
? new ObjectID(body["alertId"] as string)
: undefined,
scheduledMaintenanceId: body["scheduledMaintenanceId"]
? new ObjectID(body["scheduledMaintenanceId"] as string)
: undefined,
statusPageId: body["statusPageId"]
? new ObjectID(body["statusPageId"] as string)
: undefined,
statusPageAnnouncementId: body["statusPageAnnouncementId"]
? new ObjectID(body["statusPageAnnouncementId"] as string)
: undefined,
userId: body["userId"]
? new ObjectID(body["userId"] as string)
: undefined,
onCallPolicyId: body["onCallPolicyId"]
? new ObjectID(body["onCallPolicyId"] as string)
: undefined,
onCallPolicyEscalationRuleId: body["onCallPolicyEscalationRuleId"]
? new ObjectID(body["onCallPolicyEscalationRuleId"] as string)
: undefined,
onCallDutyPolicyExecutionLogTimelineId: body[
"onCallDutyPolicyExecutionLogTimelineId"
]
? new ObjectID(
body["onCallDutyPolicyExecutionLogTimelineId"] as string,
)
: undefined,
onCallScheduleId: body["onCallScheduleId"]
? new ObjectID(body["onCallScheduleId"] as string)
: undefined,
teamId: body["teamId"]
? new ObjectID(body["teamId"] as string)
: undefined,
});
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
return next(err);
}
},
);
router.post(
"/test",
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
const body: JSONObject = req.body as JSONObject;
if (!body["toPhone"]) {
throw new BadDataException("toPhone is required");
}
const toPhone: Phone = new Phone(body["toPhone"] as string);
const templateKey: WhatsAppTemplateId =
WhatsAppTemplateIds.TestNotification;
const templateLanguageCode: string =
WhatsAppTemplateLanguage[templateKey] || "en";
const message: WhatsAppMessage = {
to: toPhone,
body: "",
templateKey,
templateVariables: undefined,
templateLanguageCode,
};
try {
await WhatsAppService.sendWhatsApp(message, {
projectId: body["projectId"]
? new ObjectID(body["projectId"] as string)
: undefined,
isSensitive: false,
});
} catch (err) {
const errorMsg: string =
err instanceof Error && err.message
? err.message
: "Failed to send test WhatsApp message.";
throw new BadDataException(errorMsg);
}
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
return next(err);
}
},
);
export default router;

View File

@@ -12,6 +12,8 @@ import Phone from "Common/Types/Phone";
type GetGlobalSMTPConfig = () => Promise<EmailServer | null>;
export const DEFAULT_META_WHATSAPP_API_VERSION: string = "v23.0";
export const getGlobalSMTPConfig: GetGlobalSMTPConfig =
async (): Promise<EmailServer | null> => {
const globalConfig: GlobalConfig | null =
@@ -222,6 +224,83 @@ export const SMSHighRiskCostInCents: number = process.env[
? parseInt(process.env["SMS_HIGH_RISK_COST_IN_CENTS"])
: 0;
export interface MetaWhatsAppConfig {
accessToken: string;
phoneNumberId: string;
businessAccountId?: string | undefined;
appId?: string | undefined;
appSecret?: string | undefined;
apiVersion?: string | undefined;
}
type GetMetaWhatsAppConfigFunction = () => Promise<MetaWhatsAppConfig>;
export const getMetaWhatsAppConfig: GetMetaWhatsAppConfigFunction =
async (): Promise<MetaWhatsAppConfig> => {
const globalConfig: GlobalConfig | null =
await GlobalConfigService.findOneBy({
query: {
_id: ObjectID.getZeroObjectID().toString(),
},
props: {
isRoot: true,
},
select: {
metaWhatsAppAccessToken: true,
metaWhatsAppPhoneNumberId: true,
metaWhatsAppBusinessAccountId: true,
metaWhatsAppAppId: true,
metaWhatsAppAppSecret: true,
},
});
if (!globalConfig) {
throw new BadDataException("Global Config not found");
}
const accessToken: string | undefined =
globalConfig.metaWhatsAppAccessToken?.trim();
const phoneNumberId: string | undefined =
globalConfig.metaWhatsAppPhoneNumberId?.trim();
if (!accessToken) {
throw new BadDataException(
"Meta WhatsApp access token not configured. Please set it in the Admin Dashboard: " +
AdminDashboardClientURL.toString(),
);
}
if (!phoneNumberId) {
throw new BadDataException(
"Meta WhatsApp phone number ID not configured. Please set it in the Admin Dashboard: " +
AdminDashboardClientURL.toString(),
);
}
const businessAccountId: string | undefined =
globalConfig.metaWhatsAppBusinessAccountId?.trim() || undefined;
const appId: string | undefined =
globalConfig.metaWhatsAppAppId?.trim() || undefined;
const appSecret: string | undefined =
globalConfig.metaWhatsAppAppSecret?.trim() || undefined;
const apiVersion: string = DEFAULT_META_WHATSAPP_API_VERSION;
return {
accessToken,
phoneNumberId,
businessAccountId,
appId,
appSecret,
apiVersion,
};
};
export const WhatsAppTextDefaultCostInCents: number = process.env[
"WHATSAPP_TEXT_DEFAULT_COST_IN_CENTS"
]
? parseInt(process.env["WHATSAPP_TEXT_DEFAULT_COST_IN_CENTS"])
: 0;
export const CallHighRiskCostInCentsPerMinute: number = process.env[
"CALL_HIGH_RISK_COST_IN_CENTS_PER_MINUTE"
]

View File

@@ -2,6 +2,7 @@ import CallAPI from "./API/Call";
// API
import MailAPI from "./API/Mail";
import SmsAPI from "./API/SMS";
import WhatsAppAPI from "./API/WhatsApp";
import PushNotificationAPI from "./API/PushNotification";
import SMTPConfigAPI from "./API/SMTPConfig";
import "./Utils/Handlebars";
@@ -16,6 +17,7 @@ const NotificationFeatureSet: FeatureSet = {
app.use([`/${APP_NAME}/email`, "/email"], MailAPI);
app.use([`/${APP_NAME}/sms`, "/sms"], SmsAPI);
app.use([`/${APP_NAME}/whatsapp`, "/whatsapp"], WhatsAppAPI);
app.use([`/${APP_NAME}/push`, "/push"], PushNotificationAPI);
app.use([`/${APP_NAME}/call`, "/call"], CallAPI);
app.use([`/${APP_NAME}/smtp-config`, "/smtp-config"], SMTPConfigAPI);

View File

@@ -37,11 +37,49 @@ class TransporterPool {
private static semaphore: Map<string, number> = new Map();
private static readonly MAX_CONCURRENT_CONNECTIONS = 100;
private static resolveConnectionSettings(emailServer: EmailServer): {
portNumber: number;
wantsSecureConnection: boolean;
secureConnection: boolean;
requireTLS: boolean;
mode: "implicit-tls" | "starttls" | "plain";
} {
const portNumber: number = emailServer.port.toNumber();
const wantsSecureConnection: boolean = emailServer.secure;
const isImplicitTLSPort: boolean = portNumber === 465;
const secureConnection: boolean = isImplicitTLSPort;
const requireTLS: boolean = wantsSecureConnection && !isImplicitTLSPort;
let mode: "implicit-tls" | "starttls" | "plain" = "plain";
if (secureConnection) {
mode = "implicit-tls";
} else if (requireTLS) {
mode = "starttls";
}
return {
portNumber,
wantsSecureConnection,
secureConnection,
requireTLS,
mode,
};
}
private static getPoolKey(emailServer: EmailServer): string {
const { portNumber, mode } = this.resolveConnectionSettings(emailServer);
const username: string = emailServer.username || "noauth";
return `${emailServer.host.toString()}:${portNumber}:${username}:${mode}`;
}
public static getTransporter(
emailServer: EmailServer,
options: { timeout?: number | undefined },
): Transporter {
const key: string = `${emailServer.host.toString()}:${emailServer.port.toNumber()}:${emailServer.username || "noauth"}`;
const key: string = this.getPoolKey(emailServer);
if (!this.pools.has(key)) {
const transporter: Transporter = this.createTransporter(
@@ -59,9 +97,12 @@ class TransporterPool {
emailServer: EmailServer,
options: { timeout?: number | undefined },
): Transporter {
const { portNumber, wantsSecureConnection, secureConnection, requireTLS } =
this.resolveConnectionSettings(emailServer);
let tlsOptions: tls.ConnectionOptions | undefined = undefined;
if (!emailServer.secure) {
if (!wantsSecureConnection) {
tlsOptions = {
rejectUnauthorized: false,
};
@@ -69,8 +110,9 @@ class TransporterPool {
return nodemailer.createTransport({
host: emailServer.host.toString(),
port: emailServer.port.toNumber(),
secure: emailServer.secure,
port: portNumber,
secure: secureConnection,
requireTLS,
tls: tlsOptions,
auth:
emailServer.username && emailServer.password
@@ -88,7 +130,7 @@ class TransporterPool {
public static async acquireConnection(
emailServer: EmailServer,
): Promise<void> {
const key: string = `${emailServer.host.toString()}:${emailServer.port.toNumber()}:${emailServer.username || "noauth"}`;
const key: string = this.getPoolKey(emailServer);
while ((this.semaphore.get(key) || 0) >= this.MAX_CONCURRENT_CONNECTIONS) {
await new Promise<void>((resolve: () => void) => {
@@ -100,7 +142,7 @@ class TransporterPool {
}
public static releaseConnection(emailServer: EmailServer): void {
const key: string = `${emailServer.host.toString()}:${emailServer.port.toNumber()}:${emailServer.username || "noauth"}`;
const key: string = this.getPoolKey(emailServer);
const current: number = this.semaphore.get(key) || 0;
this.semaphore.set(key, Math.max(0, current - 1));
}

View File

@@ -0,0 +1,489 @@
import {
WhatsAppTextDefaultCostInCents,
getMetaWhatsAppConfig,
MetaWhatsAppConfig,
DEFAULT_META_WHATSAPP_API_VERSION,
} from "../Config";
import BadDataException from "Common/Types/Exception/BadDataException";
import ObjectID from "Common/Types/ObjectID";
import UserNotificationStatus from "Common/Types/UserNotification/UserNotificationStatus";
import WhatsAppMessage from "Common/Types/WhatsApp/WhatsAppMessage";
import WhatsAppStatus from "Common/Types/WhatsAppStatus";
import {
AuthenticationTemplates,
WhatsAppTemplateId,
} from "Common/Types/WhatsApp/WhatsAppTemplates";
import { JSONArray, JSONObject } from "Common/Types/JSON";
import { IsBillingEnabled } from "Common/Server/EnvironmentConfig";
import NotificationService from "Common/Server/Services/NotificationService";
import ProjectService from "Common/Server/Services/ProjectService";
import UserOnCallLogTimelineService from "Common/Server/Services/UserOnCallLogTimelineService";
import WhatsAppLogService from "Common/Server/Services/WhatsAppLogService";
import logger from "Common/Server/Utils/Logger";
import Project from "Common/Models/DatabaseModels/Project";
import WhatsAppLog from "Common/Models/DatabaseModels/WhatsAppLog";
import API from "Common/Utils/API";
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
import HTTPResponse from "Common/Types/API/HTTPResponse";
import Protocol from "Common/Types/API/Protocol";
import Route from "Common/Types/API/Route";
import URL from "Common/Types/API/URL";
const SENSITIVE_MESSAGE_PLACEHOLDER: string =
"This message is sensitive and is not logged";
export default class WhatsAppService {
public static async sendWhatsApp(
message: WhatsAppMessage,
options: {
projectId?: ObjectID | undefined;
isSensitive?: boolean | undefined;
userOnCallLogTimelineId?: ObjectID | undefined;
incidentId?: ObjectID | undefined;
alertId?: ObjectID | undefined;
scheduledMaintenanceId?: ObjectID | undefined;
statusPageId?: ObjectID | undefined;
statusPageAnnouncementId?: ObjectID | undefined;
userId?: ObjectID | undefined;
onCallPolicyId?: ObjectID | undefined;
onCallPolicyEscalationRuleId?: ObjectID | undefined;
onCallDutyPolicyExecutionLogTimelineId?: ObjectID | undefined;
onCallScheduleId?: ObjectID | undefined;
teamId?: ObjectID | undefined;
} = {},
): Promise<void> {
let sendError: Error | null = null;
const whatsAppLog: WhatsAppLog = new WhatsAppLog();
try {
if (!message.to) {
throw new BadDataException(
"WhatsApp recipient phone number is required",
);
}
if (!message.body && !message.templateKey) {
throw new BadDataException(
"Either WhatsApp message body or template key must be provided",
);
}
const config: MetaWhatsAppConfig = await getMetaWhatsAppConfig();
const isSensitiveMessage: boolean = Boolean(options.isSensitive);
const messageSummary: string = isSensitiveMessage
? SENSITIVE_MESSAGE_PLACEHOLDER
: message.body ||
(message.templateKey
? `Template: ${message.templateKey}${
message.templateVariables
? " Variables: " + JSON.stringify(message.templateVariables)
: ""
}`
: "");
whatsAppLog.toNumber = message.to;
whatsAppLog.messageText = messageSummary;
whatsAppLog.whatsAppCostInUSDCents = 0;
if (options.projectId) {
whatsAppLog.projectId = options.projectId;
}
if (options.incidentId) {
whatsAppLog.incidentId = options.incidentId;
}
if (options.alertId) {
whatsAppLog.alertId = options.alertId;
}
if (options.scheduledMaintenanceId) {
whatsAppLog.scheduledMaintenanceId = options.scheduledMaintenanceId;
}
if (options.statusPageId) {
whatsAppLog.statusPageId = options.statusPageId;
}
if (options.statusPageAnnouncementId) {
whatsAppLog.statusPageAnnouncementId = options.statusPageAnnouncementId;
}
if (options.userId) {
whatsAppLog.userId = options.userId;
}
if (options.teamId) {
whatsAppLog.teamId = options.teamId;
}
if (options.onCallPolicyId) {
whatsAppLog.onCallDutyPolicyId = options.onCallPolicyId;
}
if (options.onCallPolicyEscalationRuleId) {
whatsAppLog.onCallDutyPolicyEscalationRuleId =
options.onCallPolicyEscalationRuleId;
}
if (options.onCallScheduleId) {
whatsAppLog.onCallDutyPolicyScheduleId = options.onCallScheduleId;
}
let messageCost: number = 0;
const shouldChargeForMessage: boolean = IsBillingEnabled;
if (shouldChargeForMessage) {
messageCost = WhatsAppTextDefaultCostInCents / 100;
}
let project: Project | null = null;
if (options.projectId) {
project = await ProjectService.findOneById({
id: options.projectId,
select: {
smsOrCallCurrentBalanceInUSDCents: true,
lowCallAndSMSBalanceNotificationSentToOwners: true,
name: true,
notEnabledSmsOrCallNotificationSentToOwners: true,
},
props: {
isRoot: true,
},
});
if (!project) {
whatsAppLog.status = WhatsAppStatus.Error;
whatsAppLog.statusMessage = `Project ${options.projectId.toString()} not found.`;
logger.error(whatsAppLog.statusMessage);
await WhatsAppLogService.create({
data: whatsAppLog,
props: {
isRoot: true,
},
});
return;
}
if (shouldChargeForMessage) {
let updatedBalance: number =
project.smsOrCallCurrentBalanceInUSDCents || 0;
try {
updatedBalance = await NotificationService.rechargeIfBalanceIsLow(
project.id!,
);
} catch (err) {
logger.error(err);
}
project.smsOrCallCurrentBalanceInUSDCents = updatedBalance;
if (!project.smsOrCallCurrentBalanceInUSDCents) {
whatsAppLog.status = WhatsAppStatus.LowBalance;
whatsAppLog.statusMessage = `Project ${options.projectId.toString()} does not have enough balance for WhatsApp messages.`;
logger.error(whatsAppLog.statusMessage);
await WhatsAppLogService.create({
data: whatsAppLog,
props: {
isRoot: true,
},
});
if (!project.lowCallAndSMSBalanceNotificationSentToOwners) {
await ProjectService.updateOneById({
id: project.id!,
data: {
lowCallAndSMSBalanceNotificationSentToOwners: true,
},
props: {
isRoot: true,
},
});
await ProjectService.sendEmailToProjectOwners(
project.id!,
`Low WhatsApp message balance for ${project.name || ""}`,
`We tried to send a WhatsApp message to ${message.to.toString()} with message:<br/><br/>${messageSummary}<br/><br/>The message was not sent because your project does not have enough balance for WhatsApp messages. Current balance is ${
(project.smsOrCallCurrentBalanceInUSDCents || 0) / 100
} USD. Required balance for this message is ${messageCost} USD. Please enable auto recharge or recharge manually.`,
);
}
return;
}
if (project.smsOrCallCurrentBalanceInUSDCents < messageCost * 100) {
whatsAppLog.status = WhatsAppStatus.LowBalance;
whatsAppLog.statusMessage = `Project does not have enough balance to send WhatsApp message. Current balance is ${
project.smsOrCallCurrentBalanceInUSDCents / 100
} USD. Required balance is ${messageCost} USD.`;
logger.error(whatsAppLog.statusMessage);
await WhatsAppLogService.create({
data: whatsAppLog,
props: {
isRoot: true,
},
});
if (!project.lowCallAndSMSBalanceNotificationSentToOwners) {
await ProjectService.updateOneById({
id: project.id!,
data: {
lowCallAndSMSBalanceNotificationSentToOwners: true,
},
props: {
isRoot: true,
},
});
await ProjectService.sendEmailToProjectOwners(
project.id!,
`Low WhatsApp message balance for ${project.name || ""}`,
`We tried to send a WhatsApp message to ${message.to.toString()} with message:<br/><br/>${messageSummary}<br/><br/>The message was not sent because your project does not have enough balance for WhatsApp messages. Current balance is ${
project.smsOrCallCurrentBalanceInUSDCents / 100
} USD. Required balance is ${messageCost} USD. Please enable auto recharge or recharge manually.`,
);
}
return;
}
}
}
const payload: JSONObject = {
messaging_product: "whatsapp",
recipient_type: "individual",
to: message.to.toString(),
} as JSONObject;
if (!message.templateKey) {
throw new BadDataException("WhatsApp message template key is required");
}
if (message.templateKey) {
const template: JSONObject = {
name: message.templateKey,
language: {
code: message.templateLanguageCode || "en",
},
} as JSONObject;
const components: JSONArray = [];
if (
message.templateVariables &&
Object.keys(message.templateVariables).length > 0
) {
const parameters: JSONArray = [];
for (const [key, value] of Object.entries(
message.templateVariables,
)) {
parameters.push({
type: "text",
parameter_name: key,
text: value,
} as JSONObject);
}
if (parameters.length > 0) {
components.push({
type: "body",
parameters,
} as JSONObject);
}
}
/*
* Check if this is an authentication template
* Authentication templates may have special requirements including button components
*/
const isAuthTemplate: boolean = AuthenticationTemplates.has(
message.templateKey as WhatsAppTemplateId,
);
if (isAuthTemplate) {
logger.info(
`Sending authentication template: ${message.templateKey}`,
);
/*
* Authentication templates in WhatsApp may have a button component for "Copy Code"
* If the template was created with a button, we need to provide button parameters
*/
if (message.templateVariables) {
const otpCode: string | undefined =
message.templateVariables["1"] ||
message.templateVariables["otp"] ||
message.templateVariables["code"];
if (otpCode) {
/*
* Add button component - the index should match the button position in the template
* Usually authentication templates have the button as the first (and only) button
*/
components.push({
type: "button",
sub_type: "url",
index: 0,
parameters: [
{
type: "text",
text: otpCode,
},
],
} as JSONObject);
}
}
}
if (components.length > 0) {
template["components"] = components;
}
payload["type"] = "template";
payload["template"] = template;
} else {
payload["type"] = "text";
payload["text"] = {
body: message.body || "",
} as JSONObject;
}
const apiVersion: string =
config.apiVersion?.trim() || DEFAULT_META_WHATSAPP_API_VERSION;
const url: URL = new URL(
Protocol.HTTPS,
"graph.facebook.com",
new Route(`${apiVersion}/${config.phoneNumberId}/messages`),
);
logger.debug(`WhatsApp API request: ${JSON.stringify(payload)}`);
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
await API.post<JSONObject>({
url,
data: payload,
headers: {
Authorization: `Bearer ${config.accessToken}`,
"Content-Type": "application/json",
},
});
if (response instanceof HTTPErrorResponse) {
logger.error("Failed to send WhatsApp message.");
logger.error(response);
const responseDataAsJSONObject: JSONObject = response.data;
const responseJsonAsJSONObject: JSONObject | undefined =
(response.jsonData as JSONObject | undefined) || undefined;
// Log full error details for debugging
const errorObject: JSONObject | undefined =
(responseDataAsJSONObject["error"] as JSONObject | undefined) ||
(responseJsonAsJSONObject?.["error"] as JSONObject | undefined);
if (errorObject) {
logger.error("WhatsApp API Error Details:");
logger.error(JSON.stringify(errorObject, null, 2));
}
const detailedErrorMessage: string | undefined =
((responseDataAsJSONObject["error"] as JSONObject | undefined)?.[
"message"
] as string | undefined) ||
((responseJsonAsJSONObject?.["error"] as JSONObject | undefined)?.[
"message"
] as string | undefined);
throw new BadDataException(
detailedErrorMessage || "Failed to send WhatsApp message.",
);
}
const responseData: JSONObject = (response.jsonData || {}) as JSONObject;
let messageId: string | undefined = undefined;
const messagesArray: JSONArray | undefined =
(responseData["messages"] as JSONArray) || undefined;
if (Array.isArray(messagesArray) && messagesArray.length > 0) {
const firstMessage: JSONObject = messagesArray[0] as JSONObject;
if (firstMessage["id"]) {
messageId = firstMessage["id"] as string;
}
}
whatsAppLog.status = WhatsAppStatus.Success;
whatsAppLog.statusMessage = messageId
? `Message ID: ${messageId}`
: "WhatsApp message sent successfully";
if (shouldChargeForMessage && project) {
const deduction: number = Math.floor(messageCost * 100);
whatsAppLog.whatsAppCostInUSDCents = deduction;
project.smsOrCallCurrentBalanceInUSDCents = Math.max(
0,
Math.floor(
(project.smsOrCallCurrentBalanceInUSDCents || 0) - deduction,
),
);
await ProjectService.updateOneById({
id: project.id!,
data: {
smsOrCallCurrentBalanceInUSDCents:
project.smsOrCallCurrentBalanceInUSDCents,
notEnabledSmsOrCallNotificationSentToOwners: false,
},
props: {
isRoot: true,
},
});
}
} catch (error: any) {
logger.error("Failed to send WhatsApp message.");
logger.error(error);
whatsAppLog.whatsAppCostInUSDCents = 0;
whatsAppLog.status = WhatsAppStatus.Error;
const errorMessage: string =
error && error.message ? error.message.toString() : `${error}`;
whatsAppLog.statusMessage = errorMessage;
sendError = error;
}
if (options.projectId) {
await WhatsAppLogService.create({
data: whatsAppLog,
props: {
isRoot: true,
},
});
}
if (options.userOnCallLogTimelineId) {
await UserOnCallLogTimelineService.updateOneById({
id: options.userOnCallLogTimelineId,
data: {
status:
whatsAppLog.status === WhatsAppStatus.Success
? UserNotificationStatus.Sent
: UserNotificationStatus.Error,
statusMessage: whatsAppLog.statusMessage,
},
props: {
isRoot: true,
},
});
}
if (sendError) {
throw sendError;
}
}
}

View File

@@ -262,6 +262,96 @@ export default class GlobalConfig extends GlobalConfigModel {
})
public twilioSecondaryPhoneNumbers?: string = undefined; // phone numbers separated by comma
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.VeryLongText,
title: "Meta WhatsApp Access Token",
description:
"Access token generated from Meta for sending WhatsApp messages.",
})
@Column({
type: ColumnType.VeryLongText,
nullable: true,
unique: true,
})
public metaWhatsAppAccessToken?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Meta WhatsApp Phone Number ID",
description: "The WhatsApp Business phone number ID from Meta.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
unique: true,
})
public metaWhatsAppPhoneNumberId?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Meta WhatsApp Business Account ID",
description: "Business account ID associated with your WhatsApp setup.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
unique: true,
})
public metaWhatsAppBusinessAccountId?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
title: "Meta WhatsApp App ID",
description:
"Facebook App ID used for the WhatsApp Business Platform integration.",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
nullable: true,
unique: true,
})
public metaWhatsAppAppId?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.VeryLongText,
title: "Meta WhatsApp App Secret",
description: "Facebook App Secret for the WhatsApp Business Platform.",
})
@Column({
type: ColumnType.VeryLongText,
nullable: true,
unique: true,
})
public metaWhatsAppAppSecret?: string = undefined;
@ColumnAccessControl({
create: [],
read: [],

View File

@@ -100,6 +100,7 @@ import ServiceCopilotCodeRepository from "./ServiceCopilotCodeRepository";
import ShortLink from "./ShortLink";
// SMS
import SmsLog from "./SmsLog";
import WhatsAppLog from "./WhatsAppLog";
import PushNotificationLog from "./PushNotificationLog";
import WorkspaceNotificationLog from "./WorkspaceNotificationLog";
// Status Page
@@ -131,6 +132,7 @@ import UserCall from "./UserCall";
// Notification Methods
import UserEmail from "./UserEmail";
import UserPush from "./UserPush";
import UserWhatsApp from "./UserWhatsApp";
// User Notification Rules
import UserNotificationRule from "./UserNotificationRule";
import UserNotificationSetting from "./UserNotificationSetting";
@@ -297,6 +299,7 @@ const AllModelTypes: Array<{
StatusPageOwnerUser,
SmsLog,
WhatsAppLog,
PushNotificationLog,
WorkspaceNotificationLog,
CallLog,
@@ -306,6 +309,7 @@ const AllModelTypes: Array<{
UserSms,
UserCall,
UserPush,
UserWhatsApp,
UserNotificationRule,
UserOnCallLog,

View File

@@ -798,6 +798,33 @@ export default class Project extends TenantModel {
})
public enableSmsNotifications?: boolean = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProject,
Permission.UnAuthorizedSsoUser,
Permission.ProjectUser,
],
update: [Permission.ProjectOwner, Permission.ManageProjectBilling],
})
@TableColumn({
required: true,
isDefaultValueColumn: true,
type: TableColumnType.Boolean,
title: "Enable WhatsApp Notifications",
description: "Enable WhatsApp notifications for this project.",
defaultValue: false,
})
@Column({
nullable: false,
default: false,
type: ColumnType.Boolean,
})
public enableWhatsAppNotifications?: boolean = undefined;
@ColumnAccessControl({
create: [],
read: [

View File

@@ -6,6 +6,7 @@ import UserCall from "./UserCall";
import UserEmail from "./UserEmail";
import UserPush from "./UserPush";
import UserSMS from "./UserSMS";
import UserWhatsApp from "./UserWhatsApp";
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
import Route from "../../Types/API/Route";
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
@@ -383,6 +384,53 @@ class UserNotificationRule extends BaseModel {
})
public userSmsId?: ObjectID = undefined;
@ColumnAccessControl({
create: [Permission.CurrentUser],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "userWhatsAppId",
type: TableColumnType.Entity,
modelType: UserWhatsApp,
title: "User WhatsApp",
description:
"Relation to User WhatsApp Resource in which this object belongs",
})
@ManyToOne(
() => {
return UserWhatsApp;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "userWhatsAppId" })
public userWhatsApp?: UserWhatsApp = undefined;
@ColumnAccessControl({
create: [Permission.CurrentUser],
read: [Permission.CurrentUser],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
canReadOnRelationQuery: true,
title: "User WhatsApp ID",
description: "ID of User WhatsApp in which this object belongs",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public userWhatsAppId?: ObjectID = undefined;
@ColumnAccessControl({
create: [Permission.CurrentUser],
read: [Permission.CurrentUser],

View File

@@ -273,6 +273,22 @@ class UserNotificationSetting extends BaseModel {
})
public alertBySMS?: 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 alertByWhatsApp?: boolean = undefined;
@ColumnAccessControl({
create: [Permission.CurrentUser],
read: [Permission.CurrentUser],

View File

@@ -13,6 +13,7 @@ import UserNotificationRule from "./UserNotificationRule";
import UserPush from "./UserPush";
import UserOnCallLog from "./UserOnCallLog";
import UserSMS from "./UserSMS";
import UserWhatsApp from "./UserWhatsApp";
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
import Route from "../../Types/API/Route";
import { PlanType } from "../../Types/Billing/SubscriptionPlan";
@@ -834,6 +835,53 @@ export default class UserOnCallLogTimeline extends BaseModel {
})
public userSmsId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "userWhatsAppId",
type: TableColumnType.Entity,
modelType: UserWhatsApp,
title: "User WhatsApp",
description:
"Relation to User WhatsApp Resource in which this object belongs",
})
@ManyToOne(
() => {
return UserWhatsApp;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "userWhatsAppId" })
public userWhatsApp?: UserWhatsApp = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
canReadOnRelationQuery: true,
title: "User WhatsApp ID",
description: "ID of User WhatsApp in which this object belongs",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public userWhatsAppId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [Permission.CurrentUser],

View File

@@ -0,0 +1,288 @@
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 Phone from "../../Types/Phone";
import Text from "../../Types/Text";
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-whatsapp"))
@Entity({
name: "UserWhatsApp",
})
@TableMetadata({
tableName: "UserWhatsApp",
singularName: "WhatsApp Number",
pluralName: "WhatsApp Numbers",
icon: IconProp.WhatsApp,
tableDescription: "WhatsApp numbers used for WhatsApp notifications.",
})
@CurrentUserCanAccessRecordBy("userId")
class UserWhatsApp 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: "WhatsApp Number",
required: true,
unique: false,
type: TableColumnType.Phone,
canReadOnRelationQuery: true,
})
@Column({
type: ColumnType.Phone,
length: ColumnLength.Phone,
unique: false,
nullable: false,
transformer: Phone.getDatabaseTransformer(),
})
public phone?: Phone = 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 WhatsApp number 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 WhatsApp number 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 WhatsApp number verified?",
isDefaultValueColumn: true,
type: TableColumnType.Boolean,
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
default: false,
})
public isVerified?: boolean = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
title: "Verification Code",
description: "Temporary Verification Code",
isDefaultValueColumn: true,
computed: true,
required: true,
type: TableColumnType.ShortText,
forceGetDefaultValueOnCreate: () => {
return Text.generateRandomNumber(6);
},
})
@Column({
type: ColumnType.ShortText,
nullable: false,
length: ColumnLength.ShortText,
})
public verificationCode?: string = undefined;
}
export default UserWhatsApp;

View File

@@ -0,0 +1,884 @@
import Project from "./Project";
import Incident from "./Incident";
import Alert from "./Alert";
import ScheduledMaintenance from "./ScheduledMaintenance";
import StatusPage from "./StatusPage";
import StatusPageAnnouncement from "./StatusPageAnnouncement";
import User from "./User";
import OnCallDutyPolicy from "./OnCallDutyPolicy";
import OnCallDutyPolicyEscalationRule from "./OnCallDutyPolicyEscalationRule";
import OnCallDutyPolicySchedule from "./OnCallDutyPolicySchedule";
import Team from "./Team";
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
import Route from "../../Types/API/Route";
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 EnableDocumentation from "../../Types/Database/EnableDocumentation";
import EnableWorkflow from "../../Types/Database/EnableWorkflow";
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 Phone from "../../Types/Phone";
import WhatsAppStatus from "../../Types/WhatsAppStatus";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
@EnableDocumentation()
@TenantColumn("projectId")
@TableAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
delete: [],
update: [],
})
@CrudApiEndpoint(new Route("/whatsapp-log"))
@Entity({
name: "WhatsAppLog",
})
@EnableWorkflow({
create: true,
delete: false,
update: false,
})
@TableMetadata({
tableName: "WhatsAppLog",
singularName: "WhatsApp Log",
pluralName: "WhatsApp Logs",
icon: IconProp.WhatsApp,
tableDescription:
"Logs of all the WhatsApp messages sent out to all users and subscribers for this project.",
})
export default class WhatsAppLog extends BaseModel {
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
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: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
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: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@Index()
@TableColumn({
required: true,
type: TableColumnType.Phone,
title: "To Number",
description: "Phone Number WhatsApp message was sent to",
canReadOnRelationQuery: false,
})
@Column({
nullable: false,
type: ColumnType.Phone,
length: ColumnLength.Phone,
transformer: Phone.getDatabaseTransformer(),
})
public toNumber?: Phone = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@Index()
@TableColumn({
required: false,
type: TableColumnType.Phone,
title: "From Number",
description:
"Phone Number WhatsApp message was sent from (Business Number ID)",
canReadOnRelationQuery: false,
})
@Column({
nullable: true,
type: ColumnType.Phone,
length: ColumnLength.Phone,
transformer: Phone.getDatabaseTransformer(),
})
public fromNumber?: Phone = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@TableColumn({
required: false,
type: TableColumnType.VeryLongText,
title: "Message Text",
description: "Text content of the WhatsApp message",
canReadOnRelationQuery: false,
})
@Column({
nullable: true,
type: ColumnType.VeryLongText,
})
public messageText?: string = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@TableColumn({
required: false,
type: TableColumnType.LongText,
title: "Status Message",
description: "Status Message (if any)",
canReadOnRelationQuery: false,
})
@Column({
nullable: true,
type: ColumnType.LongText,
length: ColumnLength.LongText,
})
public statusMessage?: string = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@TableColumn({
required: true,
type: TableColumnType.ShortText,
title: "Status of the WhatsApp Message",
description: "Status of the WhatsApp message sent",
canReadOnRelationQuery: false,
})
@Column({
nullable: false,
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
})
public status?: WhatsAppStatus = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@Index()
@TableColumn({
required: true,
type: TableColumnType.Number,
title: "WhatsApp Cost",
description: "WhatsApp Message Cost in USD Cents",
canReadOnRelationQuery: false,
isDefaultValueColumn: true,
defaultValue: 0,
})
@Column({
nullable: false,
default: 0,
type: ColumnType.Number,
})
public whatsAppCostInUSDCents?: number = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "incidentId",
type: TableColumnType.Entity,
modelType: Incident,
title: "Incident",
description: "Incident associated with this message (if any)",
})
@ManyToOne(
() => {
return Incident;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "incidentId" })
public incident?: Incident = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
canReadOnRelationQuery: true,
title: "Incident ID",
description: "ID of Incident associated with this message (if any)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public incidentId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "userId",
type: TableColumnType.Entity,
modelType: User,
title: "User",
description: "User who initiated this message (if any)",
})
@ManyToOne(
() => {
return User;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "userId" })
public user?: User = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
canReadOnRelationQuery: true,
title: "User ID",
description: "ID of User who initiated this message (if any)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public userId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "alertId",
type: TableColumnType.Entity,
modelType: Alert,
title: "Alert",
description: "Alert associated with this message (if any)",
})
@ManyToOne(
() => {
return Alert;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "alertId" })
public alert?: Alert = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
canReadOnRelationQuery: true,
title: "Alert ID",
description: "ID of Alert associated with this message (if any)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public alertId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "scheduledMaintenanceId",
type: TableColumnType.Entity,
modelType: ScheduledMaintenance,
title: "Scheduled Maintenance",
description: "Scheduled Maintenance associated with this message (if any)",
})
@ManyToOne(
() => {
return ScheduledMaintenance;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "scheduledMaintenanceId" })
public scheduledMaintenance?: ScheduledMaintenance = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
canReadOnRelationQuery: true,
title: "Scheduled Maintenance ID",
description:
"ID of Scheduled Maintenance associated with this message (if any)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public scheduledMaintenanceId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "statusPageId",
type: TableColumnType.Entity,
modelType: StatusPage,
title: "Status Page",
description: "Status Page associated with this message (if any)",
})
@ManyToOne(
() => {
return StatusPage;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "statusPageId" })
public statusPage?: StatusPage = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
canReadOnRelationQuery: true,
title: "Status Page ID",
description: "ID of Status Page associated with this message (if any)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public statusPageId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "statusPageAnnouncementId",
type: TableColumnType.Entity,
modelType: StatusPageAnnouncement,
title: "Status Page Announcement",
description:
"Status Page Announcement associated with this message (if any)",
})
@ManyToOne(
() => {
return StatusPageAnnouncement;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "statusPageAnnouncementId" })
public statusPageAnnouncement?: StatusPageAnnouncement = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
canReadOnRelationQuery: true,
title: "Status Page Announcement ID",
description:
"ID of Status Page Announcement associated with this message (if any)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public statusPageAnnouncementId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "teamId",
type: TableColumnType.Entity,
modelType: Team,
title: "Team",
description: "Team associated with this message (if any)",
})
@ManyToOne(
() => {
return Team;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "teamId" })
public team?: Team = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
canReadOnRelationQuery: true,
title: "Team ID",
description: "ID of Team associated with this message (if any)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public teamId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "onCallDutyPolicyId",
type: TableColumnType.Entity,
modelType: OnCallDutyPolicy,
title: "On-Call Duty Policy",
description: "On-Call Duty Policy associated with this message (if any)",
})
@ManyToOne(
() => {
return OnCallDutyPolicy;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "onCallDutyPolicyId" })
public onCallDutyPolicy?: OnCallDutyPolicy = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
canReadOnRelationQuery: true,
title: "On-Call Duty Policy ID",
description:
"ID of On-Call Duty Policy associated with this message (if any)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public onCallDutyPolicyId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "onCallDutyPolicyEscalationRuleId",
type: TableColumnType.Entity,
modelType: OnCallDutyPolicyEscalationRule,
title: "On-Call Duty Policy Escalation Rule",
description:
"On-Call Duty Policy Escalation Rule associated with this message (if any)",
})
@ManyToOne(
() => {
return OnCallDutyPolicyEscalationRule;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "onCallDutyPolicyEscalationRuleId" })
public onCallDutyPolicyEscalationRule?: OnCallDutyPolicyEscalationRule =
undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
canReadOnRelationQuery: true,
title: "On-Call Duty Policy Escalation Rule ID",
description:
"ID of On-Call Duty Policy Escalation Rule associated with this message (if any)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public onCallDutyPolicyEscalationRuleId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "onCallDutyPolicyScheduleId",
type: TableColumnType.Entity,
modelType: OnCallDutyPolicySchedule,
title: "On-Call Duty Policy Schedule",
description:
"On-Call Duty Policy Schedule associated with this message (if any)",
})
@ManyToOne(
() => {
return OnCallDutyPolicySchedule;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "onCallDutyPolicyScheduleId" })
public onCallDutyPolicySchedule?: OnCallDutyPolicySchedule = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadSmsLog,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
canReadOnRelationQuery: true,
title: "On-Call Duty Policy Schedule ID",
description:
"ID of On-Call Duty Policy Schedule associated with this message (if any)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public onCallDutyPolicyScheduleId?: 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;
}

View File

@@ -58,22 +58,30 @@ export default class MicrosoftTeamsAPI {
"https://developer.microsoft.com/json-schemas/teams/v1.23/MicrosoftTeams.schema.json",
manifestVersion: "1.23",
version: AppVersion.toLowerCase().includes("unknown")
? "1.1.0"
? "1.3.0"
: AppVersion,
id: MicrosoftTeamsAppClientId,
developer: {
name: "OneUptime",
name: "HackerBay Inc",
websiteUrl: "https://oneuptime.com",
privacyUrl: "https://oneuptime.com/legal/privacy",
termsOfUseUrl: "https://oneuptime.com/legal/terms",
},
publisherDocsUrl:
"https://oneuptime.com/docs/workspace-connections/microsoft-teams",
name: {
short: "OneUptime",
full: "OneUptime - Complete Observability Platform",
},
description: {
short: "Monitor your apps, websites, APIs, and more with OneUptime",
full: "OneUptime is a complete open-source observability platform that helps you monitor your applications, websites, APIs, and infrastructure. Get alerted when things go wrong and maintain your SLAs.",
short: "Complete open-source monitoring and observability platform. ",
full: `OneUptime is a comprehensive solution for monitoring and managing your online services. Whether you need to check the availability of your website, dashboard, API, or any other online resource, OneUptime can alert your team when downtime happens and keep your customers informed with a status page. OneUptime also helps you handle incidents, set up on-call rotations, run tests, secure your services, analyze logs, track performance, and debug errors.
In order to use the app, you need to have an active account with OneUptime at https://oneuptime.com. Please send an email to support@oneupitme.com if you need more details.
Create a new OneUptime Account: If you wish to sign up for a new account, you can do so at https://oneuptime.com and click on Sign up.
Help and Support: You can reach out to help and support here: https://oneuptime.com/support or contact support@oneuptime.com
`,
},
// Default to size-specific names; route will adjust if fallbacks are used
icons: {

View File

@@ -4,11 +4,11 @@ import Express, {
ExpressRequest,
ExpressResponse,
ExpressRouter,
NextFunction,
OneUptimeRequest,
} from "../Utils/Express";
import Response from "../Utils/Response";
import BadDataException from "../../Types/Exception/BadDataException";
import Exception from "../../Types/Exception/Exception";
import JSONFunctions from "../../Types/JSONFunctions";
import ObjectID from "../../Types/ObjectID";
import Permission, { UserPermission } from "../../Types/Permission";
@@ -19,7 +19,7 @@ const router: ExpressRouter = Express.getRouter();
router.post(
"/notification/recharge",
UserMiddleware.getUserMiddleware,
async (req: ExpressRequest, res: ExpressResponse) => {
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
let amount: number | PositiveNumber = JSONFunctions.deserializeValue(
req.body.amount,
@@ -106,8 +106,8 @@ router.post(
),
);
}
} catch (err: any) {
return Response.sendErrorResponse(req, res, err as Exception);
} catch (err) {
return next(err);
}
return Response.sendEmptySuccessResponse(req, res);

View File

@@ -1,7 +1,11 @@
import ProjectSsoService, {
Service as ProjectSsoServiceType,
} from "../Services/ProjectSsoService";
import { ExpressRequest, ExpressResponse } from "../Utils/Express";
import {
ExpressRequest,
ExpressResponse,
NextFunction,
} from "../Utils/Express";
import Response from "../Utils/Response";
import BaseAPI from "./BaseAPI";
import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
@@ -22,43 +26,47 @@ export default class ProjectSsoAPI extends BaseAPI<
`${new this.entityType()
.getCrudApiPath()
?.toString()}/:projectId/sso-list`,
async (req: ExpressRequest, res: ExpressResponse) => {
const projectId: ObjectID = new ObjectID(
req.params["projectId"] as string,
);
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
const projectId: ObjectID = new ObjectID(
req.params["projectId"] as string,
);
if (!projectId) {
return Response.sendErrorResponse(
if (!projectId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid project id."),
);
}
const sso: Array<ProjectSSO> = 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,
new BadDataException("Invalid project id."),
sso,
new PositiveNumber(sso.length),
ProjectSSO,
);
} catch (err) {
return next(err);
}
const sso: Array<ProjectSSO> = 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,
sso,
new PositiveNumber(sso.length),
ProjectSSO,
);
},
);
}

View File

@@ -1,7 +1,11 @@
import ShortLinkService, {
Service as ShortLinkServiceType,
} from "../Services/ShortLinkService";
import { ExpressRequest, ExpressResponse } from "../Utils/Express";
import {
ExpressRequest,
ExpressResponse,
NextFunction,
} from "../Utils/Express";
import Response from "../Utils/Response";
import BaseAPI from "./BaseAPI";
import BadDataException from "../../Types/Exception/BadDataException";
@@ -18,34 +22,38 @@ export default class ShortLinkAPI extends BaseAPI<
`${new this.entityType()
.getCrudApiPath()
?.toString()}/redirect-to-shortlink/:id`,
async (req: ExpressRequest, res: ExpressResponse) => {
if (!req.params["id"]) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("id is required"),
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
if (!req.params["id"]) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("id is required"),
);
}
if (req.params["id"] === "status") {
return Response.sendJsonObjectResponse(req, res, {
status: "ok",
});
}
const link: ShortLink | null = await ShortLinkService.getShortLinkFor(
req.params["id"],
);
if (!link || !link.link) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("This URL is invalid or expired"),
);
}
return Response.redirect(req, res, link.link);
} catch (err) {
return next(err);
}
if (req.params["id"] === "status") {
return Response.sendJsonObjectResponse(req, res, {
status: "ok",
});
}
const link: ShortLink | null = await ShortLinkService.getShortLinkFor(
req.params["id"],
);
if (!link || !link.link) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("This URL is invalid or expired"),
);
}
return Response.redirect(req, res, link.link);
},
);
}

View File

@@ -5,6 +5,7 @@ import UserCallService, {
import {
ExpressRequest,
ExpressResponse,
NextFunction,
OneUptimeRequest,
} from "../Utils/Express";
import Response from "../Utils/Response";
@@ -23,97 +24,105 @@ export default class UserCallAPI extends BaseAPI<
this.router.post(
`/user-call/verify`,
UserMiddleware.getUserMiddleware,
async (req: ExpressRequest, res: ExpressResponse) => {
req = req as OneUptimeRequest;
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
req = req as OneUptimeRequest;
if (!req.body.itemId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item ID"),
);
if (!req.body.itemId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item ID"),
);
}
if (!req.body.code) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid code"),
);
}
// Check if the code matches and verify the phone number.
const item: UserSMS | null = await this.service.findOneById({
id: req.body["itemId"],
props: {
isRoot: true,
},
select: {
userId: true,
verificationCode: true,
},
});
if (!item) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Item not found"),
);
}
//check user id
if (
item.userId?.toString() !==
(req as OneUptimeRequest)?.userAuthorization?.userId?.toString()
) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid user ID"),
);
}
if (item.verificationCode !== req.body["code"]) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid code"),
);
}
await this.service.updateOneById({
id: item.id!,
props: {
isRoot: true,
},
data: {
isVerified: true,
},
});
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
return next(err);
}
if (!req.body.code) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid code"),
);
}
// Check if the code matches and verify the phone number.
const item: UserSMS | null = await this.service.findOneById({
id: req.body["itemId"],
props: {
isRoot: true,
},
select: {
userId: true,
verificationCode: true,
},
});
if (!item) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Item not found"),
);
}
//check user id
if (
item.userId?.toString() !==
(req as OneUptimeRequest)?.userAuthorization?.userId?.toString()
) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid user ID"),
);
}
if (item.verificationCode !== req.body["code"]) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid code"),
);
}
await this.service.updateOneById({
id: item.id!,
props: {
isRoot: true,
},
data: {
isVerified: true,
},
});
return Response.sendEmptySuccessResponse(req, res);
},
);
this.router.post(
`/user-call/resend-verification-code`,
UserMiddleware.getUserMiddleware,
async (req: ExpressRequest, res: ExpressResponse) => {
req = req as OneUptimeRequest;
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
req = req as OneUptimeRequest;
if (!req.body.itemId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item ID"),
);
if (!req.body.itemId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item ID"),
);
}
await this.service.resendVerificationCode(req.body.itemId);
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
return next(err);
}
await this.service.resendVerificationCode(req.body.itemId);
return Response.sendEmptySuccessResponse(req, res);
},
);
}

View File

@@ -5,6 +5,7 @@ import UserEmailService, {
import {
ExpressRequest,
ExpressResponse,
NextFunction,
OneUptimeRequest,
} from "../Utils/Express";
import Response from "../Utils/Response";
@@ -22,77 +23,81 @@ export default class UserEmailAPI extends BaseAPI<
this.router.post(
`${new this.entityType().getCrudApiPath()?.toString()}/verify`,
UserMiddleware.getUserMiddleware,
async (req: ExpressRequest, res: ExpressResponse) => {
req = req as OneUptimeRequest;
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
req = req as OneUptimeRequest;
if (!req.body.itemId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item ID"),
);
if (!req.body.itemId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item ID"),
);
}
if (!req.body.code) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid code"),
);
}
// Check if the code matches and verify the email.
const item: UserEmail | null = await this.service.findOneById({
id: req.body["itemId"],
props: {
isRoot: true,
},
select: {
userId: true,
verificationCode: true,
},
});
if (!item) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Item not found"),
);
}
//check user id
if (
item.userId?.toString() !==
(req as OneUptimeRequest)?.userAuthorization?.userId?.toString()
) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid user ID"),
);
}
if (item.verificationCode !== req.body["code"]) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid code"),
);
}
await this.service.updateOneById({
id: item.id!,
props: {
isRoot: true,
},
data: {
isVerified: true,
},
});
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
return next(err);
}
if (!req.body.code) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid code"),
);
}
// Check if the code matches and verify the email.
const item: UserEmail | null = await this.service.findOneById({
id: req.body["itemId"],
props: {
isRoot: true,
},
select: {
userId: true,
verificationCode: true,
},
});
if (!item) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Item not found"),
);
}
//check user id
if (
item.userId?.toString() !==
(req as OneUptimeRequest)?.userAuthorization?.userId?.toString()
) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid user ID"),
);
}
if (item.verificationCode !== req.body["code"]) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid code"),
);
}
await this.service.updateOneById({
id: item.id!,
props: {
isRoot: true,
},
data: {
isVerified: true,
},
});
return Response.sendEmptySuccessResponse(req, res);
},
);
@@ -101,20 +106,24 @@ export default class UserEmailAPI extends BaseAPI<
.getCrudApiPath()
?.toString()}/resend-verification-code`,
UserMiddleware.getUserMiddleware,
async (req: ExpressRequest, res: ExpressResponse) => {
req = req as OneUptimeRequest;
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
req = req as OneUptimeRequest;
if (!req.body.itemId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item ID"),
);
if (!req.body.itemId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item ID"),
);
}
await this.service.resendVerificationCode(req.body.itemId);
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
return next(err);
}
await this.service.resendVerificationCode(req.body.itemId);
return Response.sendEmptySuccessResponse(req, res);
},
);
}

View File

@@ -6,6 +6,7 @@ import UserOnCallLogTimelineService, {
import {
ExpressRequest,
ExpressResponse,
NextFunction,
OneUptimeRequest,
} from "../Utils/Express";
import Response from "../Utils/Response";
@@ -34,62 +35,66 @@ export default class UserNotificationLogTimelineAPI extends BaseAPI<
.getCrudApiPath()
?.toString()}/call/gather-input/:itemId`,
NotificationMiddleware.isValidCallNotificationRequest,
async (req: ExpressRequest, res: ExpressResponse) => {
req = req as OneUptimeRequest;
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
req = req as OneUptimeRequest;
if (!req.params["itemId"]) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item ID"),
);
if (!req.params["itemId"]) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item ID"),
);
}
const token: JSONObject = (req as any).callTokenData;
const itemId: ObjectID = new ObjectID(req.params["itemId"]);
const timelineItem: UserOnCallLogTimeline | null =
await this.service.findOneById({
id: itemId,
select: {
_id: true,
projectId: true,
triggeredByIncidentId: true,
triggeredByAlertId: true,
},
props: {
isRoot: true,
},
});
if (!timelineItem) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item Id"),
);
}
// check digits.
if (req.body["Digits"] === "1") {
// then ack incident
await this.service.updateOneById({
id: itemId,
data: {
acknowledgedAt: OneUptimeDate.getCurrentDate(),
isAcknowledged: true,
status: UserNotificationStatus.Acknowledged,
statusMessage: "Notification Acknowledged",
},
props: {
isRoot: true,
},
});
}
return NotificationMiddleware.sendResponse(req, res, token as any);
} catch (error) {
return next(error);
}
const token: JSONObject = (req as any).callTokenData;
const itemId: ObjectID = new ObjectID(req.params["itemId"]);
const timelineItem: UserOnCallLogTimeline | null =
await this.service.findOneById({
id: itemId,
select: {
_id: true,
projectId: true,
triggeredByIncidentId: true,
triggeredByAlertId: true,
},
props: {
isRoot: true,
},
});
if (!timelineItem) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item Id"),
);
}
// check digits.
if (req.body["Digits"] === "1") {
// then ack incident
await this.service.updateOneById({
id: itemId,
data: {
acknowledgedAt: OneUptimeDate.getCurrentDate(),
isAcknowledged: true,
status: UserNotificationStatus.Acknowledged,
statusMessage: "Notification Acknowledged",
},
props: {
isRoot: true,
},
});
}
return NotificationMiddleware.sendResponse(req, res, token as any);
},
);
@@ -102,73 +107,77 @@ export default class UserNotificationLogTimelineAPI extends BaseAPI<
`${new this.entityType()
.getCrudApiPath()
?.toString()}/acknowledge-page/:itemId`,
async (req: ExpressRequest, res: ExpressResponse) => {
req = req as OneUptimeRequest;
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
req = req as OneUptimeRequest;
if (!req.params["itemId"]) {
return Response.sendErrorResponse(
if (!req.params["itemId"]) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Item ID is required"),
);
}
const itemId: ObjectID = new ObjectID(req.params["itemId"]);
const timelineItem: UserOnCallLogTimeline | null =
await this.service.findOneById({
id: itemId,
select: {
_id: true,
projectId: true,
triggeredByIncidentId: true,
triggeredByIncident: {
title: true,
description: true,
},
triggeredByAlertId: true,
triggeredByAlert: {
title: true,
description: true,
},
},
props: {
isRoot: true,
},
});
if (!timelineItem) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item Id"),
);
}
const notificationType: string = timelineItem.triggeredByIncidentId
? "Incident"
: "Alert";
const host: Hostname = await DatabaseConfig.getHost();
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
return Response.render(
req,
res,
new BadDataException("Item ID is required"),
);
}
const itemId: ObjectID = new ObjectID(req.params["itemId"]);
const timelineItem: UserOnCallLogTimeline | null =
await this.service.findOneById({
id: itemId,
select: {
_id: true,
projectId: true,
triggeredByIncidentId: true,
triggeredByIncident: {
title: true,
description: true,
},
triggeredByAlertId: true,
triggeredByAlert: {
title: true,
description: true,
},
"/usr/src/Common/Server/Views/AcknowledgeUserOnCallNotification.ejs",
{
title: `Acknowledge ${notificationType} - ${timelineItem.triggeredByIncident?.title || timelineItem.triggeredByAlert?.title}`,
message: `Do you want to acknowledge this ${notificationType}?`,
acknowledgeText: `Acknowledge ${notificationType}`,
acknowledgeUrl: new URL(
httpProtocol,
host,
new Route(AppApiRoute.toString())
.addRoute(new UserOnCallLogTimeline().crudApiPath!)
.addRoute("/acknowledge/" + itemId.toString()),
).toString(),
},
props: {
isRoot: true,
},
});
if (!timelineItem) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item Id"),
);
} catch (error) {
return next(error);
}
const notificationType: string = timelineItem.triggeredByIncidentId
? "Incident"
: "Alert";
const host: Hostname = await DatabaseConfig.getHost();
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
return Response.render(
req,
res,
"/usr/src/Common/Server/Views/AcknowledgeUserOnCallNotification.ejs",
{
title: `Acknowledge ${notificationType} - ${timelineItem.triggeredByIncident?.title || timelineItem.triggeredByAlert?.title}`,
message: `Do you want to acknowledge this ${notificationType}?`,
acknowledgeText: `Acknowledge ${notificationType}`,
acknowledgeUrl: new URL(
httpProtocol,
host,
new Route(AppApiRoute.toString())
.addRoute(new UserOnCallLogTimeline().crudApiPath!)
.addRoute("/acknowledge/" + itemId.toString()),
).toString(),
},
);
},
);
@@ -177,124 +186,128 @@ export default class UserNotificationLogTimelineAPI extends BaseAPI<
`${new this.entityType()
.getCrudApiPath()
?.toString()}/acknowledge/:itemId`,
async (req: ExpressRequest, res: ExpressResponse) => {
req = req as OneUptimeRequest;
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
req = req as OneUptimeRequest;
if (!req.params["itemId"]) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Item ID is required"),
);
}
if (!req.params["itemId"]) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Item ID is required"),
);
}
const itemId: ObjectID = new ObjectID(req.params["itemId"]);
const itemId: ObjectID = new ObjectID(req.params["itemId"]);
const timelineItem: UserOnCallLogTimeline | null =
await this.service.findOneById({
const timelineItem: UserOnCallLogTimeline | null =
await this.service.findOneById({
id: itemId,
select: {
_id: true,
projectId: true,
triggeredByIncidentId: true,
triggeredByAlertId: true,
triggeredByAlert: {
title: true,
},
triggeredByIncident: {
title: true,
},
acknowledgedAt: true,
isAcknowledged: true,
},
props: {
isRoot: true,
},
});
if (!timelineItem) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item Id"),
);
}
const host: Hostname = await DatabaseConfig.getHost();
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
if (timelineItem.isAcknowledged) {
// already acknowledged. Then show already acknowledged page with view details button.
const viewDetailsUrl: URL = new URL(
httpProtocol,
host,
DashboardRoute.addRoute(
`/${timelineItem.projectId?.toString()}/${timelineItem.triggeredByIncidentId ? "incidents" : "alerts"}/${timelineItem.triggeredByIncidentId ? timelineItem.triggeredByIncidentId!.toString() : timelineItem.triggeredByAlertId!.toString()}`,
),
);
return Response.render(
req,
res,
"/usr/src/Common/Server/Views/ViewMessage.ejs",
{
title: `Notification Already Acknowledged - ${timelineItem.triggeredByIncident?.title || timelineItem.triggeredByAlert?.title}`,
message: `This notification has already been acknowledged.`,
viewDetailsText: `View ${timelineItem.triggeredByIncidentId ? "Incident" : "Alert"}`,
viewDetailsUrl: viewDetailsUrl.toString(),
},
);
}
await this.service.updateOneById({
id: itemId,
select: {
_id: true,
projectId: true,
triggeredByIncidentId: true,
triggeredByAlertId: true,
triggeredByAlert: {
title: true,
},
triggeredByIncident: {
title: true,
},
acknowledgedAt: true,
data: {
acknowledgedAt: OneUptimeDate.getCurrentDate(),
isAcknowledged: true,
status: UserNotificationStatus.Acknowledged,
statusMessage: "Notification Acknowledged",
},
props: {
isRoot: true,
},
});
if (!timelineItem) {
// redirect to dashboard to incidents page.
if (timelineItem.triggeredByIncidentId) {
return Response.redirect(
req,
res,
new URL(
httpProtocol,
host,
DashboardRoute.addRoute(
`/${timelineItem.projectId?.toString()}/incidents/${timelineItem.triggeredByIncidentId!.toString()}`,
),
),
);
}
if (timelineItem.triggeredByAlertId) {
return Response.redirect(
req,
res,
new URL(
httpProtocol,
host,
DashboardRoute.addRoute(
`/${timelineItem.projectId?.toString()}/alerts/${timelineItem.triggeredByAlertId!.toString()}`,
),
),
);
}
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item Id"),
);
} catch (error) {
return next(error);
}
const host: Hostname = await DatabaseConfig.getHost();
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
if (timelineItem.isAcknowledged) {
// already acknowledged. Then show already acknowledged page with view details button.
const viewDetailsUrl: URL = new URL(
httpProtocol,
host,
DashboardRoute.addRoute(
`/${timelineItem.projectId?.toString()}/${timelineItem.triggeredByIncidentId ? "incidents" : "alerts"}/${timelineItem.triggeredByIncidentId ? timelineItem.triggeredByIncidentId!.toString() : timelineItem.triggeredByAlertId!.toString()}`,
),
);
return Response.render(
req,
res,
"/usr/src/Common/Server/Views/ViewMessage.ejs",
{
title: `Notification Already Acknowledged - ${timelineItem.triggeredByIncident?.title || timelineItem.triggeredByAlert?.title}`,
message: `This notification has already been acknowledged.`,
viewDetailsText: `View ${timelineItem.triggeredByIncidentId ? "Incident" : "Alert"}`,
viewDetailsUrl: viewDetailsUrl.toString(),
},
);
}
await this.service.updateOneById({
id: itemId,
data: {
acknowledgedAt: OneUptimeDate.getCurrentDate(),
isAcknowledged: true,
status: UserNotificationStatus.Acknowledged,
statusMessage: "Notification Acknowledged",
},
props: {
isRoot: true,
},
});
// redirect to dashboard to incidents page.
if (timelineItem.triggeredByIncidentId) {
return Response.redirect(
req,
res,
new URL(
httpProtocol,
host,
DashboardRoute.addRoute(
`/${timelineItem.projectId?.toString()}/incidents/${timelineItem.triggeredByIncidentId!.toString()}`,
),
),
);
}
if (timelineItem.triggeredByAlertId) {
return Response.redirect(
req,
res,
new URL(
httpProtocol,
host,
DashboardRoute.addRoute(
`/${timelineItem.projectId?.toString()}/alerts/${timelineItem.triggeredByAlertId!.toString()}`,
),
),
);
}
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item Id"),
);
},
);
}

View File

@@ -108,104 +108,104 @@ export default class UserPushAPI extends BaseAPI<
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,
deviceName: true,
deviceToken: true,
deviceType: true,
isVerified: true,
projectId: 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"),
);
}
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
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,
});
req = req as OneUptimeRequest;
await PushNotificationService.sendPushNotification(
{
devices: [
{
token: device.deviceToken!,
...(device.deviceName && {
name: device.deviceName,
}),
},
],
message: testMessage,
deviceType: device.deviceType!,
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,
},
{
isSensitive: false,
projectId: device.projectId!,
userId: device.userId!,
select: {
userId: true,
deviceName: true,
deviceToken: true,
deviceType: true,
isVerified: true,
projectId: 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(
{
devices: [
{
token: device.deviceToken!,
...(device.deviceName && {
name: device.deviceName,
}),
},
],
message: testMessage,
deviceType: device.deviceType!,
},
{
isSensitive: false,
projectId: device.projectId!,
userId: device.userId!,
},
);
} catch (error: any) {
throw new BadDataException(
`Failed to send test notification: ${error.message}`,
);
}
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}`,
),
);
} catch (error) {
return next(error);
}
},
);
@@ -213,100 +213,108 @@ export default class UserPushAPI extends BaseAPI<
this.router.post(
`/user-push/:deviceId/verify`,
UserMiddleware.getUserMiddleware,
async (req: ExpressRequest, res: ExpressResponse) => {
req = req as OneUptimeRequest;
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
req = req as OneUptimeRequest;
if (!req.params["deviceId"]) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Device ID is required"),
);
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);
} catch (error) {
return next(error);
}
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;
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
req = req as OneUptimeRequest;
if (!req.params["deviceId"]) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Device ID is required"),
);
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);
} catch (error) {
return next(error);
}
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);
},
);
}

View File

@@ -5,6 +5,7 @@ import UserSMSService, {
import {
ExpressRequest,
ExpressResponse,
NextFunction,
OneUptimeRequest,
} from "../Utils/Express";
import Response from "../Utils/Response";
@@ -19,97 +20,105 @@ export default class UserSMSAPI extends BaseAPI<UserSMS, UserSMSServiceType> {
this.router.post(
`/user-sms/verify`,
UserMiddleware.getUserMiddleware,
async (req: ExpressRequest, res: ExpressResponse) => {
req = req as OneUptimeRequest;
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
req = req as OneUptimeRequest;
if (!req.body.itemId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item ID"),
);
if (!req.body.itemId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item ID"),
);
}
if (!req.body.code) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid code"),
);
}
// Check if the code matches and verify the phone number.
const item: UserSMS | null = await this.service.findOneById({
id: req.body["itemId"],
props: {
isRoot: true,
},
select: {
userId: true,
verificationCode: true,
},
});
if (!item) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Item not found"),
);
}
//check user id
if (
item.userId?.toString() !==
(req as OneUptimeRequest)?.userAuthorization?.userId?.toString()
) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid user ID"),
);
}
if (item.verificationCode !== req.body["code"]) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid code"),
);
}
await this.service.updateOneById({
id: item.id!,
props: {
isRoot: true,
},
data: {
isVerified: true,
},
});
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
return next(err);
}
if (!req.body.code) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid code"),
);
}
// Check if the code matches and verify the phone number.
const item: UserSMS | null = await this.service.findOneById({
id: req.body["itemId"],
props: {
isRoot: true,
},
select: {
userId: true,
verificationCode: true,
},
});
if (!item) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Item not found"),
);
}
//check user id
if (
item.userId?.toString() !==
(req as OneUptimeRequest)?.userAuthorization?.userId?.toString()
) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid user ID"),
);
}
if (item.verificationCode !== req.body["code"]) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid code"),
);
}
await this.service.updateOneById({
id: item.id!,
props: {
isRoot: true,
},
data: {
isVerified: true,
},
});
return Response.sendEmptySuccessResponse(req, res);
},
);
this.router.post(
`/user-sms/resend-verification-code`,
UserMiddleware.getUserMiddleware,
async (req: ExpressRequest, res: ExpressResponse) => {
req = req as OneUptimeRequest;
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
req = req as OneUptimeRequest;
if (!req.body.itemId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item ID"),
);
if (!req.body.itemId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item ID"),
);
}
await this.service.resendVerificationCode(req.body.itemId);
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
return next(err);
}
await this.service.resendVerificationCode(req.body.itemId);
return Response.sendEmptySuccessResponse(req, res);
},
);
}

View File

@@ -0,0 +1,136 @@
import UserMiddleware from "../Middleware/UserAuthorization";
import UserWhatsAppService, {
Service as UserWhatsAppServiceType,
} from "../Services/UserWhatsAppService";
import {
ExpressRequest,
ExpressResponse,
NextFunction,
OneUptimeRequest,
} from "../Utils/Express";
import Response from "../Utils/Response";
import BaseAPI from "./BaseAPI";
import BadDataException from "../../Types/Exception/BadDataException";
import UserWhatsApp from "../../Models/DatabaseModels/UserWhatsApp";
export default class UserWhatsAppAPI extends BaseAPI<
UserWhatsApp,
UserWhatsAppServiceType
> {
public constructor() {
super(UserWhatsApp, UserWhatsAppService);
this.router.post(
`${new this.entityType().getCrudApiPath()?.toString()}/verify`,
UserMiddleware.getUserMiddleware,
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
req = req as OneUptimeRequest;
if (!req.body.itemId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item ID"),
);
}
if (!req.body.code) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid code"),
);
}
const item: UserWhatsApp | null = await this.service.findOneById({
id: req.body["itemId"],
props: {
isRoot: true,
},
select: {
userId: true,
verificationCode: true,
isVerified: true,
},
});
if (!item) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Item not found"),
);
}
if (item.isVerified) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("WhatsApp number already verified"),
);
}
if (
item.userId?.toString() !==
(req as OneUptimeRequest)?.userAuthorization?.userId?.toString()
) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid user ID"),
);
}
if (item.verificationCode !== req.body["code"]) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid code"),
);
}
await this.service.updateOneById({
id: item.id!,
props: {
isRoot: true,
},
data: {
isVerified: true,
},
});
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
return next(err);
}
},
);
this.router.post(
`${new this.entityType()
.getCrudApiPath()
?.toString()}/resend-verification-code`,
UserMiddleware.getUserMiddleware,
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
req = req as OneUptimeRequest;
if (!req.body.itemId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid item ID"),
);
}
await this.service.resendVerificationCode(req.body.itemId);
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
return next(err);
}
},
);
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 889 B

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,331 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1759943124812 implements MigrationInterface {
public name = "MigrationName1759943124812";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "WhatsAppLog" ("_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, "toNumber" character varying(30) NOT NULL, "fromNumber" character varying(30), "messageText" text, "statusMessage" character varying(500), "status" character varying(100) NOT NULL, "whatsAppCostInUSDCents" integer NOT NULL DEFAULT '0', "incidentId" uuid, "userId" uuid, "alertId" uuid, "scheduledMaintenanceId" uuid, "statusPageId" uuid, "statusPageAnnouncementId" uuid, "teamId" uuid, "onCallDutyPolicyId" uuid, "onCallDutyPolicyEscalationRuleId" uuid, "onCallDutyPolicyScheduleId" uuid, "deletedByUserId" uuid, CONSTRAINT "PK_9800b27ad5072db21ff1e453300" PRIMARY KEY ("_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_20e246495b31ec9720529ec13a" ON "WhatsAppLog" ("projectId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_ee24b8a69670171de6c19fdcaf" ON "WhatsAppLog" ("toNumber") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_f9cf8acb2f63698431f4f18f48" ON "WhatsAppLog" ("fromNumber") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_98e71cf97956e7938195be8451" ON "WhatsAppLog" ("whatsAppCostInUSDCents") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_2f4c03d17243b8b3ddae2677ae" ON "WhatsAppLog" ("incidentId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_020a8349a02b6cfc79129a8deb" ON "WhatsAppLog" ("userId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_431a6f11acfd2795b17c652fbb" ON "WhatsAppLog" ("alertId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_048963c43c534478290408bdd7" ON "WhatsAppLog" ("scheduledMaintenanceId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_16379405298dc2b312ff456fc8" ON "WhatsAppLog" ("statusPageId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_4136974e8f1832f03df27057a7" ON "WhatsAppLog" ("statusPageAnnouncementId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_a638be5c4d5e8551f7be91dd8b" ON "WhatsAppLog" ("teamId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_dc907429d63e2d860bb290124e" ON "WhatsAppLog" ("onCallDutyPolicyId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_486f56105b72ee019cd9634272" ON "WhatsAppLog" ("onCallDutyPolicyEscalationRuleId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_f78a2014c70e4846df79f9e681" ON "WhatsAppLog" ("onCallDutyPolicyScheduleId") `,
);
await queryRunner.query(
`CREATE TABLE "UserWhatsApp" ("_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, "phone" character varying(30) NOT NULL, "userId" uuid, "createdByUserId" uuid, "deletedByUserId" uuid, "isVerified" boolean NOT NULL DEFAULT false, "verificationCode" character varying(100) NOT NULL, CONSTRAINT "PK_19ab8aa5949cb38d08930e959ad" PRIMARY KEY ("_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_cacaefed4f479bf300d4065c80" ON "UserWhatsApp" ("projectId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_b99e3db0cecd0e5f15b1f6738a" ON "UserWhatsApp" ("userId") `,
);
await queryRunner.query(
`ALTER TABLE "Project" ADD "enableWhatsAppNotifications" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "GlobalConfig" ADD "metaWhatsAppAccessToken" text`,
);
await queryRunner.query(
`ALTER TABLE "GlobalConfig" ADD CONSTRAINT "UQ_83bd5d0c54a21bfe12316fa6520" UNIQUE ("metaWhatsAppAccessToken")`,
);
await queryRunner.query(
`ALTER TABLE "GlobalConfig" ADD "metaWhatsAppPhoneNumberId" character varying(100)`,
);
await queryRunner.query(
`ALTER TABLE "GlobalConfig" ADD CONSTRAINT "UQ_ef032cda9dd2fac68daeedd7bd2" UNIQUE ("metaWhatsAppPhoneNumberId")`,
);
await queryRunner.query(
`ALTER TABLE "GlobalConfig" ADD "metaWhatsAppBusinessAccountId" character varying(100)`,
);
await queryRunner.query(
`ALTER TABLE "GlobalConfig" ADD CONSTRAINT "UQ_607e6e88215689951d9b3645f00" UNIQUE ("metaWhatsAppBusinessAccountId")`,
);
await queryRunner.query(
`ALTER TABLE "GlobalConfig" ADD "metaWhatsAppAppId" character varying(100)`,
);
await queryRunner.query(
`ALTER TABLE "GlobalConfig" ADD CONSTRAINT "UQ_e67fd0998ca781ec7db0e573e91" UNIQUE ("metaWhatsAppAppId")`,
);
await queryRunner.query(
`ALTER TABLE "GlobalConfig" ADD "metaWhatsAppAppSecret" text`,
);
await queryRunner.query(
`ALTER TABLE "GlobalConfig" ADD CONSTRAINT "UQ_d4669bf754f937bae16c4a1837c" UNIQUE ("metaWhatsAppAppSecret")`,
);
await queryRunner.query(
`ALTER TABLE "UserNotificationRule" ADD "userWhatsAppId" uuid`,
);
await queryRunner.query(
`ALTER TABLE "UserNotificationSetting" ADD "alertByWhatsApp" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "UserOnCallLogTimeline" ADD "userWhatsAppId" 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_73297560a1a70e4fe47eac2986" ON "UserNotificationRule" ("userWhatsAppId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_0a67c82e4e093ae5c89d2d76bd" ON "UserOnCallLogTimeline" ("userWhatsAppId") `,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" ADD CONSTRAINT "FK_20e246495b31ec9720529ec13a6" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" ADD CONSTRAINT "FK_2f4c03d17243b8b3ddae2677ae1" FOREIGN KEY ("incidentId") REFERENCES "Incident"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" ADD CONSTRAINT "FK_020a8349a02b6cfc79129a8deba" FOREIGN KEY ("userId") REFERENCES "User"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" ADD CONSTRAINT "FK_431a6f11acfd2795b17c652fbb5" FOREIGN KEY ("alertId") REFERENCES "Alert"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" ADD CONSTRAINT "FK_048963c43c534478290408bdd78" FOREIGN KEY ("scheduledMaintenanceId") REFERENCES "ScheduledMaintenance"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" ADD CONSTRAINT "FK_16379405298dc2b312ff456fc88" FOREIGN KEY ("statusPageId") REFERENCES "StatusPage"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" ADD CONSTRAINT "FK_4136974e8f1832f03df27057a7e" FOREIGN KEY ("statusPageAnnouncementId") REFERENCES "StatusPageAnnouncement"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" ADD CONSTRAINT "FK_a638be5c4d5e8551f7be91dd8be" FOREIGN KEY ("teamId") REFERENCES "Team"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" ADD CONSTRAINT "FK_dc907429d63e2d860bb290124e3" FOREIGN KEY ("onCallDutyPolicyId") REFERENCES "OnCallDutyPolicy"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" ADD CONSTRAINT "FK_486f56105b72ee019cd96342723" FOREIGN KEY ("onCallDutyPolicyEscalationRuleId") REFERENCES "OnCallDutyPolicyEscalationRule"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" ADD CONSTRAINT "FK_f78a2014c70e4846df79f9e681a" FOREIGN KEY ("onCallDutyPolicyScheduleId") REFERENCES "OnCallDutyPolicySchedule"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" ADD CONSTRAINT "FK_2f75dca0a039aa9384de646f759" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "UserWhatsApp" ADD CONSTRAINT "FK_cacaefed4f479bf300d4065c802" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "UserWhatsApp" ADD CONSTRAINT "FK_b99e3db0cecd0e5f15b1f6738aa" FOREIGN KEY ("userId") REFERENCES "User"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "UserWhatsApp" ADD CONSTRAINT "FK_57d2f22db228562775e3274975a" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "UserWhatsApp" ADD CONSTRAINT "FK_e90592dde8357dd1afbf19073d8" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "UserNotificationRule" ADD CONSTRAINT "FK_73297560a1a70e4fe47eac29861" FOREIGN KEY ("userWhatsAppId") REFERENCES "UserWhatsApp"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "UserOnCallLogTimeline" ADD CONSTRAINT "FK_0a67c82e4e093ae5c89d2d76bdf" FOREIGN KEY ("userWhatsAppId") REFERENCES "UserWhatsApp"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "UserOnCallLogTimeline" DROP CONSTRAINT "FK_0a67c82e4e093ae5c89d2d76bdf"`,
);
await queryRunner.query(
`ALTER TABLE "UserNotificationRule" DROP CONSTRAINT "FK_73297560a1a70e4fe47eac29861"`,
);
await queryRunner.query(
`ALTER TABLE "UserWhatsApp" DROP CONSTRAINT "FK_e90592dde8357dd1afbf19073d8"`,
);
await queryRunner.query(
`ALTER TABLE "UserWhatsApp" DROP CONSTRAINT "FK_57d2f22db228562775e3274975a"`,
);
await queryRunner.query(
`ALTER TABLE "UserWhatsApp" DROP CONSTRAINT "FK_b99e3db0cecd0e5f15b1f6738aa"`,
);
await queryRunner.query(
`ALTER TABLE "UserWhatsApp" DROP CONSTRAINT "FK_cacaefed4f479bf300d4065c802"`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" DROP CONSTRAINT "FK_2f75dca0a039aa9384de646f759"`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" DROP CONSTRAINT "FK_f78a2014c70e4846df79f9e681a"`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" DROP CONSTRAINT "FK_486f56105b72ee019cd96342723"`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" DROP CONSTRAINT "FK_dc907429d63e2d860bb290124e3"`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" DROP CONSTRAINT "FK_a638be5c4d5e8551f7be91dd8be"`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" DROP CONSTRAINT "FK_4136974e8f1832f03df27057a7e"`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" DROP CONSTRAINT "FK_16379405298dc2b312ff456fc88"`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" DROP CONSTRAINT "FK_048963c43c534478290408bdd78"`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" DROP CONSTRAINT "FK_431a6f11acfd2795b17c652fbb5"`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" DROP CONSTRAINT "FK_020a8349a02b6cfc79129a8deba"`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" DROP CONSTRAINT "FK_2f4c03d17243b8b3ddae2677ae1"`,
);
await queryRunner.query(
`ALTER TABLE "WhatsAppLog" DROP CONSTRAINT "FK_20e246495b31ec9720529ec13a6"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_0a67c82e4e093ae5c89d2d76bd"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_73297560a1a70e4fe47eac2986"`,
);
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 "userWhatsAppId"`,
);
await queryRunner.query(
`ALTER TABLE "UserNotificationSetting" DROP COLUMN "alertByWhatsApp"`,
);
await queryRunner.query(
`ALTER TABLE "UserNotificationRule" DROP COLUMN "userWhatsAppId"`,
);
await queryRunner.query(
`ALTER TABLE "GlobalConfig" DROP CONSTRAINT "UQ_d4669bf754f937bae16c4a1837c"`,
);
await queryRunner.query(
`ALTER TABLE "GlobalConfig" DROP COLUMN "metaWhatsAppAppSecret"`,
);
await queryRunner.query(
`ALTER TABLE "GlobalConfig" DROP CONSTRAINT "UQ_e67fd0998ca781ec7db0e573e91"`,
);
await queryRunner.query(
`ALTER TABLE "GlobalConfig" DROP COLUMN "metaWhatsAppAppId"`,
);
await queryRunner.query(
`ALTER TABLE "GlobalConfig" DROP CONSTRAINT "UQ_607e6e88215689951d9b3645f00"`,
);
await queryRunner.query(
`ALTER TABLE "GlobalConfig" DROP COLUMN "metaWhatsAppBusinessAccountId"`,
);
await queryRunner.query(
`ALTER TABLE "GlobalConfig" DROP CONSTRAINT "UQ_ef032cda9dd2fac68daeedd7bd2"`,
);
await queryRunner.query(
`ALTER TABLE "GlobalConfig" DROP COLUMN "metaWhatsAppPhoneNumberId"`,
);
await queryRunner.query(
`ALTER TABLE "GlobalConfig" DROP CONSTRAINT "UQ_83bd5d0c54a21bfe12316fa6520"`,
);
await queryRunner.query(
`ALTER TABLE "GlobalConfig" DROP COLUMN "metaWhatsAppAccessToken"`,
);
await queryRunner.query(
`ALTER TABLE "Project" DROP COLUMN "enableWhatsAppNotifications"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_b99e3db0cecd0e5f15b1f6738a"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_cacaefed4f479bf300d4065c80"`,
);
await queryRunner.query(`DROP TABLE "UserWhatsApp"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_f78a2014c70e4846df79f9e681"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_486f56105b72ee019cd9634272"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_dc907429d63e2d860bb290124e"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_a638be5c4d5e8551f7be91dd8b"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_4136974e8f1832f03df27057a7"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_16379405298dc2b312ff456fc8"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_048963c43c534478290408bdd7"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_431a6f11acfd2795b17c652fbb"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_020a8349a02b6cfc79129a8deb"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_2f4c03d17243b8b3ddae2677ae"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_98e71cf97956e7938195be8451"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_f9cf8acb2f63698431f4f18f48"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_ee24b8a69670171de6c19fdcaf"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_20e246495b31ec9720529ec13a"`,
);
await queryRunner.query(`DROP TABLE "WhatsAppLog"`);
}
}

View File

@@ -174,6 +174,7 @@ import { MigrationName1758798730753 } from "./1758798730753-MigrationName";
import { MigrationName1759175457008 } from "./1759175457008-MigrationName";
import { MigrationName1759232954703 } from "./1759232954703-MigrationName";
import { RenameUserTwoFactorAuthToUserTotpAuth1759234532998 } from "./1759234532998-MigrationName";
import { MigrationName1759943124812 } from "./1759943124812-MigrationName";
export default [
InitialMigration,
@@ -352,4 +353,5 @@ export default [
MigrationName1759175457008,
MigrationName1759232954703,
RenameUserTwoFactorAuthToUserTotpAuth1759234532998,
MigrationName1759943124812,
];

View File

@@ -91,6 +91,7 @@ import ServiceCopilotCodeRepositoryService from "./ServiceCopilotCodeRepositoryS
import ShortLinkService from "./ShortLinkService";
// SMS Log Service
import SmsLogService from "./SmsLogService";
import WhatsAppLogService from "./WhatsAppLogService";
import SmsService from "./SmsService";
import SpanService from "./SpanService";
import StatusPageAnnouncementService from "./StatusPageAnnouncementService";
@@ -126,6 +127,7 @@ import UserService from "./UserService";
import UserTotpAuthService from "./UserTotpAuthService";
import UserWebAuthnService from "./UserWebAuthnService";
import UserSmsService from "./UserSmsService";
import UserWhatsAppService from "./UserWhatsAppService";
import WorkflowLogService from "./WorkflowLogService";
// Workflows.
import WorkflowService from "./WorkflowService";
@@ -250,6 +252,7 @@ const services: Array<BaseService> = [
ShortLinkService,
SmsLogService,
WhatsAppLogService,
SmsService,
StatusPageAnnouncementService,
@@ -281,6 +284,7 @@ const services: Array<BaseService> = [
UserOnCallLogService,
UserOnCallLogTimelineService,
UserSmsService,
UserWhatsAppService,
UserTotpAuthService,
UserWebAuthnService,

View File

@@ -69,6 +69,8 @@ import logger from "../Utils/Logger";
import PushNotificationUtil from "../Utils/PushNotificationUtil";
import ExceptionMessages from "../../Types/Exception/ExceptionMessages";
import Project from "../../Models/DatabaseModels/Project";
import { createWhatsAppMessageFromTemplate } from "../Utils/WhatsAppTemplateUtil";
import { WhatsAppMessagePayload } from "../../Types/WhatsApp/WhatsAppMessage";
export class Service extends DatabaseService<Model> {
public constructor() {
@@ -1191,6 +1193,19 @@ ${createdItem.description?.trim() || "No description provided."}
],
};
const eventType: NotificationSettingEventType =
NotificationSettingEventType.SEND_MONITOR_NOTIFICATION_WHEN_NO_PROBES_ARE_MONITORING_THE_MONITOR;
const whatsAppMessage: WhatsAppMessagePayload =
createWhatsAppMessageFromTemplate({
eventType,
templateVariables: {
monitor_name: monitor.name!,
probe_status: enabledStatus,
monitor_link: vars["monitorViewLink"] || "",
},
});
await UserNotificationSettingService.sendUserNotification({
userId: owner.id!,
projectId: monitor.projectId!,
@@ -1205,8 +1220,8 @@ ${createdItem.description?.trim() || "No description provided."}
monitorId: monitor.id!.toString(),
monitorName: monitor.name!,
}),
eventType:
NotificationSettingEventType.SEND_MONITOR_NOTIFICATION_WHEN_NO_PROBES_ARE_MONITORING_THE_MONITOR,
whatsAppMessage,
eventType,
});
}
}
@@ -1298,6 +1313,19 @@ ${createdItem.description?.trim() || "No description provided."}
],
};
const eventType: NotificationSettingEventType =
NotificationSettingEventType.SEND_MONITOR_NOTIFICATION_WHEN_PORBE_STATUS_CHANGES;
const whatsAppMessage: WhatsAppMessagePayload =
createWhatsAppMessageFromTemplate({
eventType,
templateVariables: {
monitor_name: monitor.name!,
probe_status: status,
monitor_link: vars["monitorViewLink"] || "",
},
});
await UserNotificationSettingService.sendUserNotification({
userId: owner.id!,
projectId: monitor.projectId!,
@@ -1309,8 +1337,8 @@ ${createdItem.description?.trim() || "No description provided."}
monitorName: monitor.name!,
monitorId: monitor.id!.toString(),
}),
eventType:
NotificationSettingEventType.SEND_MONITOR_NOTIFICATION_WHEN_PORBE_STATUS_CHANGES,
whatsAppMessage,
eventType,
});
}
}

View File

@@ -20,6 +20,8 @@ import { OnCallDutyPolicyFeedEventType } from "../../Models/DatabaseModels/OnCal
import { Gray500, Red500 } from "../../Types/BrandColors";
import PushNotificationMessage from "../../Types/PushNotification/PushNotificationMessage";
import PushNotificationUtil from "../Utils/PushNotificationUtil";
import { createWhatsAppMessageFromTemplate } from "../Utils/WhatsAppTemplateUtil";
import { WhatsAppMessagePayload } from "../../Types/WhatsApp/WhatsAppMessage";
export class Service extends DatabaseService<Model> {
public constructor() {
@@ -135,6 +137,21 @@ export class Service extends DatabaseService<Model> {
policyName: createdModel.onCallDutyPolicy?.name || "No name provided",
});
const eventType: NotificationSettingEventType =
NotificationSettingEventType.SEND_WHEN_USER_IS_ADDED_TO_ON_CALL_POLICY;
const whatsAppMessage: WhatsAppMessagePayload =
createWhatsAppMessageFromTemplate({
eventType,
templateVariables: {
on_call_policy_name:
createdModel.onCallDutyPolicy?.name || "No name provided",
schedule_name: scheduleName,
on_call_context: `schedule ${scheduleName}`,
policy_link: vars["onCallPolicyViewLink"] || "",
},
});
await UserNotificationSettingService.sendUserNotification({
userId: sendEmailToUserId,
projectId: createdModel!.projectId!,
@@ -142,8 +159,8 @@ export class Service extends DatabaseService<Model> {
smsMessage: sms,
callRequestMessage: callMessage,
pushNotificationMessage: pushMessage,
eventType:
NotificationSettingEventType.SEND_WHEN_USER_IS_ADDED_TO_ON_CALL_POLICY,
whatsAppMessage,
eventType,
});
// add workspace message.
@@ -317,6 +334,21 @@ export class Service extends DatabaseService<Model> {
policyName: deletedItem.onCallDutyPolicy?.name || "No name provided",
});
const eventType: NotificationSettingEventType =
NotificationSettingEventType.SEND_WHEN_USER_IS_REMOVED_FROM_ON_CALL_POLICY;
const whatsAppMessage: WhatsAppMessagePayload =
createWhatsAppMessageFromTemplate({
eventType,
templateVariables: {
on_call_policy_name:
deletedItem.onCallDutyPolicy?.name || "No name provided",
schedule_name: scheduleName,
on_call_context: `schedule ${scheduleName}`,
policy_link: vars["onCallPolicyViewLink"] || "",
},
});
await UserNotificationSettingService.sendUserNotification({
userId: sendEmailToUserId,
projectId: deletedItem!.projectId!,
@@ -324,8 +356,8 @@ export class Service extends DatabaseService<Model> {
smsMessage: sms,
callRequestMessage: callMessage,
pushNotificationMessage: pushMessage,
eventType:
NotificationSettingEventType.SEND_WHEN_USER_IS_REMOVED_FROM_ON_CALL_POLICY,
whatsAppMessage,
eventType,
});
}

View File

@@ -20,6 +20,8 @@ import { OnCallDutyPolicyFeedEventType } from "../../Models/DatabaseModels/OnCal
import { Gray500, Red500 } from "../../Types/BrandColors";
import PushNotificationMessage from "../../Types/PushNotification/PushNotificationMessage";
import PushNotificationUtil from "../Utils/PushNotificationUtil";
import { createWhatsAppMessageFromTemplate } from "../Utils/WhatsAppTemplateUtil";
import { WhatsAppMessagePayload } from "../../Types/WhatsApp/WhatsAppMessage";
import Team from "../../Models/DatabaseModels/Team";
import OnCallDutyPolicyTimeLogService from "./OnCallDutyPolicyTimeLogService";
import OneUptimeDate from "../../Types/Date";
@@ -134,6 +136,20 @@ export class Service extends DatabaseService<Model> {
policyName: createdModel.onCallDutyPolicy?.name || "No name provided",
});
const eventType: NotificationSettingEventType =
NotificationSettingEventType.SEND_WHEN_USER_IS_ADDED_TO_ON_CALL_POLICY;
const whatsAppMessage: WhatsAppMessagePayload =
createWhatsAppMessageFromTemplate({
eventType,
templateVariables: {
on_call_policy_name:
createdModel.onCallDutyPolicy?.name || "No name provided",
on_call_context: `team ${temaName}`,
policy_link: vars["onCallPolicyViewLink"] || "",
},
});
await UserNotificationSettingService.sendUserNotification({
userId: sendEmailToUserId,
projectId: createdModel!.projectId!,
@@ -141,8 +157,8 @@ export class Service extends DatabaseService<Model> {
smsMessage: sms,
callRequestMessage: callMessage,
pushNotificationMessage: pushMessage,
eventType:
NotificationSettingEventType.SEND_WHEN_USER_IS_ADDED_TO_ON_CALL_POLICY,
whatsAppMessage,
eventType,
});
// add start log
@@ -322,6 +338,20 @@ export class Service extends DatabaseService<Model> {
deletedItem.onCallDutyPolicy?.name || "No name provided",
});
const eventType: NotificationSettingEventType =
NotificationSettingEventType.SEND_WHEN_USER_IS_REMOVED_FROM_ON_CALL_POLICY;
const whatsAppMessage: WhatsAppMessagePayload =
createWhatsAppMessageFromTemplate({
eventType,
templateVariables: {
on_call_policy_name:
deletedItem.onCallDutyPolicy?.name || "No name provided",
on_call_context: `team ${teamName}`,
policy_link: vars["onCallPolicyViewLink"] || "",
},
});
UserNotificationSettingService.sendUserNotification({
userId: sendEmailToUserId,
projectId: deletedItem!.projectId!,
@@ -329,8 +359,8 @@ export class Service extends DatabaseService<Model> {
smsMessage: sms,
callRequestMessage: callMessage,
pushNotificationMessage: pushMessage,
eventType:
NotificationSettingEventType.SEND_WHEN_USER_IS_REMOVED_FROM_ON_CALL_POLICY,
whatsAppMessage,
eventType,
});
// end time log

View File

@@ -22,7 +22,9 @@ import PushNotificationMessage from "../../Types/PushNotification/PushNotificati
import PushNotificationUtil from "../Utils/PushNotificationUtil";
import OnCallDutyPolicyTimeLogService from "./OnCallDutyPolicyTimeLogService";
import OneUptimeDate from "../../Types/Date";
import { createWhatsAppMessageFromTemplate } from "../Utils/WhatsAppTemplateUtil";
import logger from "../Utils/Logger";
import { WhatsAppMessagePayload } from "../../Types/WhatsApp/WhatsAppMessage";
export class Service extends DatabaseService<Model> {
public constructor() {
@@ -117,6 +119,20 @@ export class Service extends DatabaseService<Model> {
policyName: createdModel.onCallDutyPolicy?.name || "",
});
const eventType: NotificationSettingEventType =
NotificationSettingEventType.SEND_WHEN_USER_IS_ADDED_TO_ON_CALL_POLICY;
const whatsAppMessage: WhatsAppMessagePayload =
createWhatsAppMessageFromTemplate({
eventType,
templateVariables: {
on_call_policy_name:
createdModel.onCallDutyPolicy?.name || "No name provided",
on_call_context: `escalation rule ${createdModel.onCallDutyPolicyEscalationRule?.name || "No name provided"}`,
policy_link: vars["onCallPolicyViewLink"] || "",
},
});
await UserNotificationSettingService.sendUserNotification({
userId: sendEmailToUserId,
projectId: createdModel!.projectId!,
@@ -124,8 +140,8 @@ export class Service extends DatabaseService<Model> {
smsMessage: sms,
callRequestMessage: callMessage,
pushNotificationMessage: pushMessage,
eventType:
NotificationSettingEventType.SEND_WHEN_USER_IS_ADDED_TO_ON_CALL_POLICY,
whatsAppMessage,
eventType,
onCallPolicyId: createdModel.onCallDutyPolicy!.id!,
onCallPolicyEscalationRuleId:
createdModel.onCallDutyPolicyEscalationRule!.id!,
@@ -322,6 +338,20 @@ export class Service extends DatabaseService<Model> {
policyName: deletedItem.onCallDutyPolicy?.name || "",
});
const eventType: NotificationSettingEventType =
NotificationSettingEventType.SEND_WHEN_USER_IS_REMOVED_FROM_ON_CALL_POLICY;
const whatsAppMessage: WhatsAppMessagePayload =
createWhatsAppMessageFromTemplate({
eventType,
templateVariables: {
on_call_policy_name:
deletedItem.onCallDutyPolicy?.name || "No name provided",
on_call_context: `escalation rule ${deletedItem.onCallDutyPolicyEscalationRule?.name || "No name provided"}`,
policy_link: vars["onCallPolicyViewLink"] || "",
},
});
UserNotificationSettingService.sendUserNotification({
userId: sendEmailToUserId,
projectId: deletedItem!.projectId!,
@@ -329,8 +359,8 @@ export class Service extends DatabaseService<Model> {
smsMessage: sms,
callRequestMessage: callMessage,
pushNotificationMessage: pushMessage,
eventType:
NotificationSettingEventType.SEND_WHEN_USER_IS_REMOVED_FROM_ON_CALL_POLICY,
whatsAppMessage,
eventType,
onCallPolicyId: deletedItem.onCallDutyPolicy!.id!,
onCallPolicyEscalationRuleId:
deletedItem.onCallDutyPolicyEscalationRule!.id!,

View File

@@ -36,6 +36,8 @@ import DeleteBy from "../Types/Database/DeleteBy";
import { OnDelete } from "../Types/Database/Hooks";
import PushNotificationMessage from "../../Types/PushNotification/PushNotificationMessage";
import PushNotificationUtil from "../Utils/PushNotificationUtil";
import { createWhatsAppMessageFromTemplate } from "../Utils/WhatsAppTemplateUtil";
import { WhatsAppMessagePayload } from "../../Types/WhatsApp/WhatsAppMessage";
export class Service extends DatabaseService<OnCallDutyPolicySchedule> {
private layerUtil = new LayerUtil();
@@ -265,6 +267,19 @@ export class Service extends DatabaseService<OnCallDutyPolicySchedule> {
requireInteraction: false,
});
const eventType: NotificationSettingEventType =
NotificationSettingEventType.SEND_WHEN_USER_IS_NO_LONGER_ACTIVE_ON_ON_CALL_ROSTER;
const whatsAppMessage: WhatsAppMessagePayload =
createWhatsAppMessageFromTemplate({
eventType,
templateVariables: {
on_call_policy_name: onCallPolicy.name!,
schedule_name: onCallSchedule.name!,
schedule_link: vars["onCallPolicyViewLink"] || "",
},
});
await UserNotificationSettingService.sendUserNotification({
userId: sendEmailToUserId,
projectId: projectId,
@@ -272,8 +287,8 @@ export class Service extends DatabaseService<OnCallDutyPolicySchedule> {
smsMessage: sms,
callRequestMessage: callMessage,
pushNotificationMessage: pushMessage,
eventType:
NotificationSettingEventType.SEND_WHEN_USER_IS_NO_LONGER_ACTIVE_ON_ON_CALL_ROSTER,
whatsAppMessage,
eventType,
onCallPolicyId: escalationRule.onCallDutyPolicy!.id!,
onCallPolicyEscalationRuleId:
escalationRule.onCallDutyPolicyEscalationRule!.id!,
@@ -385,6 +400,19 @@ export class Service extends DatabaseService<OnCallDutyPolicySchedule> {
requireInteraction: true,
});
const eventType: NotificationSettingEventType =
NotificationSettingEventType.SEND_WHEN_USER_IS_ON_CALL_ROSTER;
const whatsAppMessage: WhatsAppMessagePayload =
createWhatsAppMessageFromTemplate({
eventType,
templateVariables: {
on_call_policy_name: onCallPolicy.name!,
schedule_name: onCallSchedule.name!,
schedule_link: vars["onCallPolicyViewLink"] || "",
},
});
await UserNotificationSettingService.sendUserNotification({
userId: sendEmailToUserId,
projectId: projectId,
@@ -392,8 +420,8 @@ export class Service extends DatabaseService<OnCallDutyPolicySchedule> {
smsMessage: sms,
callRequestMessage: callMessage,
pushNotificationMessage: pushMessage,
eventType:
NotificationSettingEventType.SEND_WHEN_USER_IS_ON_CALL_ROSTER,
whatsAppMessage,
eventType,
onCallPolicyId: escalationRule.onCallDutyPolicy!.id!,
onCallPolicyEscalationRuleId:
escalationRule.onCallDutyPolicyEscalationRule!.id!,
@@ -525,6 +553,19 @@ export class Service extends DatabaseService<OnCallDutyPolicySchedule> {
requireInteraction: false,
});
const eventType: NotificationSettingEventType =
NotificationSettingEventType.SEND_WHEN_USER_IS_NEXT_ON_CALL_ROSTER;
const whatsAppMessage: WhatsAppMessagePayload =
createWhatsAppMessageFromTemplate({
eventType,
templateVariables: {
on_call_policy_name: onCallPolicy.name!,
schedule_name: onCallSchedule.name!,
schedule_link: vars["onCallPolicyViewLink"] || "",
},
});
await UserNotificationSettingService.sendUserNotification({
userId: sendEmailToUserId,
projectId: projectId,
@@ -532,8 +573,8 @@ export class Service extends DatabaseService<OnCallDutyPolicySchedule> {
smsMessage: sms,
callRequestMessage: callMessage,
pushNotificationMessage: pushMessage,
eventType:
NotificationSettingEventType.SEND_WHEN_USER_IS_NEXT_ON_CALL_ROSTER,
whatsAppMessage,
eventType,
onCallPolicyId: escalationRule.onCallDutyPolicy!.id!,
onCallPolicyEscalationRuleId:
escalationRule.onCallDutyPolicyEscalationRule!.id!,

View File

@@ -33,6 +33,8 @@ import PushNotificationUtil from "../Utils/PushNotificationUtil";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import { IsBillingEnabled } from "../EnvironmentConfig";
import GlobalCache from "../Infrastructure/GlobalCache";
import { createWhatsAppMessageFromTemplate } from "../Utils/WhatsAppTemplateUtil";
import { WhatsAppMessagePayload } from "../../Types/WhatsApp/WhatsAppMessage";
export class Service extends DatabaseService<Model> {
public constructor() {
@@ -387,6 +389,19 @@ export class Service extends DatabaseService<Model> {
pushMessageParams,
);
const eventType: NotificationSettingEventType =
NotificationSettingEventType.SEND_PROBE_STATUS_CHANGED_OWNER_NOTIFICATION;
const whatsAppMessage: WhatsAppMessagePayload =
createWhatsAppMessageFromTemplate({
eventType,
templateVariables: {
probe_name: probe.name!,
probe_status: connectionStatus,
probe_link: vars["viewProbesLink"] || "",
},
});
await UserNotificationSettingService.sendUserNotification({
userId: user.id!,
projectId: probe.projectId!,
@@ -394,8 +409,8 @@ export class Service extends DatabaseService<Model> {
smsMessage: sms,
callRequestMessage: callMessage,
pushNotificationMessage: pushMessage,
eventType:
NotificationSettingEventType.SEND_PROBE_STATUS_CHANGED_OWNER_NOTIFICATION,
whatsAppMessage,
eventType,
});
} catch (e) {
logger.error("Error in sending incident created resource notification");

View File

@@ -1,5 +1,6 @@
import CreateBy from "../Types/Database/CreateBy";
import DeleteBy from "../Types/Database/DeleteBy";
import { OnDelete, OnUpdate } from "../Types/Database/Hooks";
import { OnCreate, OnDelete, OnUpdate } from "../Types/Database/Hooks";
import UpdateBy from "../Types/Database/UpdateBy";
import DatabaseService from "./DatabaseService";
import LIMIT_MAX from "../../Types/Database/LimitMax";
@@ -7,8 +8,10 @@ import BadDataException from "../../Types/Exception/BadDataException";
import Model from "../../Models/DatabaseModels/Team";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import ObjectID from "../../Types/ObjectID";
import PositiveNumber from "../../Types/PositiveNumber";
import TeamMember from "../../Models/DatabaseModels/TeamMember";
import TeamMemberService from "./TeamMemberService";
import ProjectSCIMService from "./ProjectSCIMService";
export class Service extends DatabaseService<Model> {
public constructor() {
@@ -48,6 +51,70 @@ export class Service extends DatabaseService<Model> {
return teams;
}
private async assertScimAllowsTeamMutation(data: {
projectIds: Array<ObjectID>;
action: "create" | "delete";
}): Promise<void> {
if (!data.projectIds || data.projectIds.length === 0) {
return;
}
const uniqueProjectIds: Map<string, ObjectID> = new Map();
for (const projectId of data.projectIds) {
if (projectId) {
uniqueProjectIds.set(projectId.toString(), new ObjectID(projectId));
}
}
for (const projectId of uniqueProjectIds.values()) {
const scimCount: PositiveNumber = await ProjectSCIMService.countBy({
query: {
projectId: projectId,
},
skip: new PositiveNumber(0),
limit: new PositiveNumber(1),
props: {
isRoot: true,
tenantId: projectId,
},
});
if (scimCount.toNumber() > 0) {
throw new BadDataException(
`Cannot ${data.action} teams when SCIM is enabled for this project.`,
);
}
}
}
@CaptureSpan()
protected override async onBeforeCreate(
createBy: CreateBy<Model>,
): Promise<OnCreate<Model>> {
let projectId: ObjectID | undefined = createBy.data.projectId;
if (!projectId && createBy.props.tenantId) {
projectId = new ObjectID(createBy.props.tenantId);
}
if (!projectId) {
throw new BadDataException("Project ID cannot be null");
}
projectId = new ObjectID(projectId);
createBy.data.projectId = projectId;
if (!createBy.props.isRoot) {
await this.assertScimAllowsTeamMutation({
projectIds: [projectId],
action: "create",
});
}
return { createBy, carryForward: null };
}
@CaptureSpan()
protected override async onBeforeUpdate(
updateBy: UpdateBy<Model>,
@@ -90,11 +157,27 @@ export class Service extends DatabaseService<Model> {
select: {
name: true,
isTeamDeleteable: true,
projectId: true,
},
props: deleteBy.props,
});
const projectIds: Array<ObjectID> = teams
.map((team: Model) => {
return team.projectId;
})
.filter((projectId: ObjectID | undefined): projectId is ObjectID => {
return Boolean(projectId);
});
if (deleteBy.props.isRoot !== true) {
await this.assertScimAllowsTeamMutation({
projectIds: projectIds,
action: "delete",
});
}
for (const team of teams) {
if (!team.isTeamDeleteable) {
throw new BadDataException(

View File

@@ -9,6 +9,7 @@ import IncidentSeverityService from "./IncidentSeverityService";
import MailService from "./MailService";
import ShortLinkService from "./ShortLinkService";
import SmsService from "./SmsService";
import WhatsAppService from "./WhatsAppService";
import UserEmailService from "./UserEmailService";
import UserOnCallLogService from "./UserOnCallLogService";
import UserOnCallLogTimelineService from "./UserOnCallLogTimelineService";
@@ -29,6 +30,13 @@ import NotificationRuleType from "../../Types/NotificationRule/NotificationRuleT
import ObjectID from "../../Types/ObjectID";
import Phone from "../../Types/Phone";
import SMS from "../../Types/SMS/SMS";
import WhatsAppMessage from "../../Types/WhatsApp/WhatsAppMessage";
import {
renderWhatsAppTemplate,
WhatsAppTemplateIds,
WhatsAppTemplateLanguage,
WhatsAppTemplateId,
} from "../../Types/WhatsApp/WhatsAppTemplates";
import UserNotificationEventType from "../../Types/UserNotification/UserNotificationEventType";
import UserNotificationExecutionStatus from "../../Types/UserNotification/UserNotificationExecutionStatus";
import UserNotificationStatus from "../../Types/UserNotification/UserNotificationStatus";
@@ -135,6 +143,10 @@ export class Service extends DatabaseService<Model> {
phone: true,
isVerified: true,
},
userWhatsApp: {
phone: true,
isVerified: true,
},
userEmail: {
email: true,
isVerified: true,
@@ -224,6 +236,7 @@ export class Service extends DatabaseService<Model> {
name: true,
},
rootCause: true,
incidentNumber: true,
},
});
}
@@ -252,6 +265,7 @@ export class Service extends DatabaseService<Model> {
alertSeverity: {
name: true,
},
alertNumber: true,
},
});
}
@@ -516,6 +530,125 @@ export class Service extends DatabaseService<Model> {
});
}
if (
notificationRuleItem.userWhatsApp?.phone &&
notificationRuleItem.userWhatsApp?.isVerified
) {
if (
options.userNotificationEventType ===
UserNotificationEventType.AlertCreated &&
alert
) {
logTimelineItem.status = UserNotificationStatus.Sending;
logTimelineItem.statusMessage = `Sending WhatsApp message to ${notificationRuleItem.userWhatsApp?.phone.toString()}.`;
logTimelineItem.userWhatsAppId = notificationRuleItem.userWhatsApp.id!;
const updatedLog: UserOnCallLogTimeline =
await UserOnCallLogTimelineService.create({
data: logTimelineItem,
props: {
isRoot: true,
},
});
const whatsAppMessage: WhatsAppMessage =
await this.generateWhatsAppTemplateForAlertCreated(
notificationRuleItem.userWhatsApp.phone,
alert,
updatedLog.id!,
);
WhatsAppService.sendWhatsAppMessage(whatsAppMessage, {
projectId: alert.projectId,
alertId: alert.id!,
userOnCallLogTimelineId: updatedLog.id!,
userId: notificationRuleItem.userId!,
onCallPolicyId: options.onCallPolicyId,
onCallPolicyEscalationRuleId: options.onCallPolicyEscalationRuleId,
teamId: options.userBelongsToTeamId,
onCallDutyPolicyExecutionLogTimelineId:
options.onCallDutyPolicyExecutionLogTimelineId,
onCallScheduleId: options.onCallScheduleId,
}).catch(async (err: Error) => {
await UserOnCallLogTimelineService.updateOneById({
id: updatedLog.id!,
data: {
status: UserNotificationStatus.Error,
statusMessage: err.message || "Error sending WhatsApp message.",
},
props: {
isRoot: true,
},
});
});
}
if (
options.userNotificationEventType ===
UserNotificationEventType.IncidentCreated &&
incident
) {
logTimelineItem.status = UserNotificationStatus.Sending;
logTimelineItem.statusMessage = `Sending WhatsApp message to ${notificationRuleItem.userWhatsApp?.phone.toString()}.`;
logTimelineItem.userWhatsAppId = notificationRuleItem.userWhatsApp.id!;
const updatedLog: UserOnCallLogTimeline =
await UserOnCallLogTimelineService.create({
data: logTimelineItem,
props: {
isRoot: true,
},
});
const whatsAppMessage: WhatsAppMessage =
await this.generateWhatsAppTemplateForIncidentCreated(
notificationRuleItem.userWhatsApp.phone,
incident,
updatedLog.id!,
);
WhatsAppService.sendWhatsAppMessage(whatsAppMessage, {
projectId: incident.projectId,
incidentId: incident.id!,
userOnCallLogTimelineId: updatedLog.id!,
userId: notificationRuleItem.userId!,
onCallPolicyId: options.onCallPolicyId,
onCallPolicyEscalationRuleId: options.onCallPolicyEscalationRuleId,
teamId: options.userBelongsToTeamId,
onCallDutyPolicyExecutionLogTimelineId:
options.onCallDutyPolicyExecutionLogTimelineId,
onCallScheduleId: options.onCallScheduleId,
}).catch(async (err: Error) => {
await UserOnCallLogTimelineService.updateOneById({
id: updatedLog.id!,
data: {
status: UserNotificationStatus.Error,
statusMessage: err.message || "Error sending WhatsApp message.",
},
props: {
isRoot: true,
},
});
});
}
}
if (
notificationRuleItem.userWhatsApp?.phone &&
!notificationRuleItem.userWhatsApp?.isVerified
) {
logTimelineItem.status = UserNotificationStatus.Error;
logTimelineItem.statusMessage = `WhatsApp message not sent because phone ${notificationRuleItem.userWhatsApp?.phone.toString()} is not verified.`;
logTimelineItem.userWhatsAppId = notificationRuleItem.userWhatsApp.id!;
await UserOnCallLogTimelineService.create({
data: logTimelineItem,
props: {
isRoot: true,
},
});
}
// send call.
if (
notificationRuleItem.userCall?.phone &&
@@ -925,16 +1058,19 @@ export class Service extends DatabaseService<Model> {
host,
new Route(AppApiRoute.toString())
.addRoute(new UserOnCallLogTimeline().crudApiPath!)
.addRoute("/acknowledge/" + userOnCallLogTimelineId.toString()),
.addRoute("/acknowledge-page/" + userOnCallLogTimelineId.toString()),
),
);
const url: URL = await ShortLinkService.getShortenedUrl(shortUrl);
const alertIdentifier: string =
alert.alertNumber !== undefined
? `#${alert.alertNumber} (${alert.title || "Alert"})`
: alert.title || "Alert";
const sms: SMS = {
to,
message: `This is a message from OneUptime. A new alert has been created. ${
alert.title
}. To acknowledge this alert, please click on the following link ${url.toString()}`,
message: `This is a message from OneUptime. A new alert has been created: ${alertIdentifier}. To acknowledge this alert, please click on the following link ${url.toString()}`,
};
return sms;
@@ -955,21 +1091,138 @@ export class Service extends DatabaseService<Model> {
host,
new Route(AppApiRoute.toString())
.addRoute(new UserOnCallLogTimeline().crudApiPath!)
.addRoute("/acknowledge/" + userOnCallLogTimelineId.toString()),
.addRoute("/acknowledge-page/" + userOnCallLogTimelineId.toString()),
),
);
const url: URL = await ShortLinkService.getShortenedUrl(shortUrl);
const incidentIdentifier: string =
incident.incidentNumber !== undefined
? `#${incident.incidentNumber} (${incident.title || "Incident"})`
: incident.title || "Incident";
const sms: SMS = {
to,
message: `This is a message from OneUptime. A new incident has been created. ${
incident.title
}. To acknowledge this incident, please click on the following link ${url.toString()}`,
message: `This is a message from OneUptime. A new incident has been created: ${incidentIdentifier}. To acknowledge this incident, please click on the following link ${url.toString()}`,
};
return sms;
}
@CaptureSpan()
public async generateWhatsAppTemplateForAlertCreated(
to: Phone,
alert: Alert,
userOnCallLogTimelineId: ObjectID,
): Promise<WhatsAppMessage> {
const host: Hostname = await DatabaseConfig.getHost();
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
const acknowledgeShortLink: ShortLink =
await ShortLinkService.saveShortLinkFor(
new URL(
httpProtocol,
host,
new Route(AppApiRoute.toString())
.addRoute(new UserOnCallLogTimeline().crudApiPath!)
.addRoute(
"/acknowledge-page/" + userOnCallLogTimelineId.toString(),
),
),
);
const acknowledgeUrl: URL =
await ShortLinkService.getShortenedUrl(acknowledgeShortLink);
const alertLinkOnDashboard: string =
alert.projectId && alert.id
? (
await AlertService.getAlertLinkInDashboard(
alert.projectId,
alert.id,
)
).toString()
: acknowledgeUrl.toString();
const templateKey: WhatsAppTemplateId = WhatsAppTemplateIds.AlertCreated;
const templateVariables: Record<string, string> = {
project_name: alert.project?.name || "OneUptime",
alert_title: alert.title || "",
acknowledge_url: acknowledgeUrl.toString(),
alert_number:
alert.alertNumber !== undefined ? alert.alertNumber.toString() : "",
alert_link: alertLinkOnDashboard,
};
const body: string = renderWhatsAppTemplate(templateKey, templateVariables);
return {
to,
body,
templateKey,
templateVariables,
templateLanguageCode: WhatsAppTemplateLanguage[templateKey],
};
}
@CaptureSpan()
public async generateWhatsAppTemplateForIncidentCreated(
to: Phone,
incident: Incident,
userOnCallLogTimelineId: ObjectID,
): Promise<WhatsAppMessage> {
const host: Hostname = await DatabaseConfig.getHost();
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
const acknowledgeShortLink: ShortLink =
await ShortLinkService.saveShortLinkFor(
new URL(
httpProtocol,
host,
new Route(AppApiRoute.toString())
.addRoute(new UserOnCallLogTimeline().crudApiPath!)
.addRoute(
"/acknowledge-page/" + userOnCallLogTimelineId.toString(),
),
),
);
const acknowledgeUrl: URL =
await ShortLinkService.getShortenedUrl(acknowledgeShortLink);
const incidentLinkOnDashboard: string =
incident.projectId && incident.id
? (
await IncidentService.getIncidentLinkInDashboard(
incident.projectId,
incident.id,
)
).toString()
: acknowledgeUrl.toString();
const templateKey: WhatsAppTemplateId = WhatsAppTemplateIds.IncidentCreated;
const templateVariables: Record<string, string> = {
project_name: incident.project?.name || "OneUptime",
incident_title: incident.title || "",
acknowledge_url: acknowledgeUrl.toString(),
incident_number:
incident.incidentNumber !== undefined
? incident.incidentNumber.toString()
: "",
incident_link: incidentLinkOnDashboard,
};
const body: string = renderWhatsAppTemplate(templateKey, templateVariables);
return {
to,
body,
templateKey,
templateVariables,
templateLanguageCode: WhatsAppTemplateLanguage[templateKey],
};
}
@CaptureSpan()
public async generateEmailTemplateForAlertCreated(
to: Email,
@@ -1210,12 +1463,14 @@ export class Service extends DatabaseService<Model> {
!createBy.data.userEmail &&
!createBy.data.userSms &&
!createBy.data.userSmsId &&
!createBy.data.userWhatsApp &&
!createBy.data.userWhatsAppId &&
!createBy.data.userEmailId &&
!createBy.data.userPushId &&
!createBy.data.userPush
) {
throw new BadDataException(
"Call, SMS, Email, or Push notification is required",
"Call, SMS, WhatsApp, Email, or Push notification is required",
);
}

View File

@@ -10,6 +10,8 @@ import UserCallService from "./UserCallService";
import UserEmailService from "./UserEmailService";
import UserSmsService from "./UserSmsService";
import PushNotificationService from "./PushNotificationService";
import UserWhatsAppService from "./UserWhatsAppService";
import WhatsAppService from "./WhatsAppService";
import { CallRequestMessage } from "../../Types/Call/CallRequest";
import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
import { EmailEnvelope } from "../../Types/Email/EmailMessage";
@@ -19,11 +21,16 @@ import ObjectID from "../../Types/ObjectID";
import PositiveNumber from "../../Types/PositiveNumber";
import { SMSMessage } from "../../Types/SMS/SMS";
import PushNotificationMessage from "../../Types/PushNotification/PushNotificationMessage";
import WhatsAppMessage, {
WhatsAppMessagePayload,
} from "../../Types/WhatsApp/WhatsAppMessage";
import UserCall from "../../Models/DatabaseModels/UserCall";
import UserEmail from "../../Models/DatabaseModels/UserEmail";
import UserNotificationSetting from "../../Models/DatabaseModels/UserNotificationSetting";
import UserSMS from "../../Models/DatabaseModels/UserSMS";
import UserWhatsApp from "../../Models/DatabaseModels/UserWhatsApp";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import { appendRecipientToWhatsAppMessage } from "../Utils/WhatsAppTemplateUtil";
export class Service extends DatabaseService<UserNotificationSetting> {
public constructor() {
@@ -39,6 +46,7 @@ export class Service extends DatabaseService<UserNotificationSetting> {
smsMessage: SMSMessage;
callRequestMessage: CallRequestMessage;
pushNotificationMessage: PushNotificationMessage;
whatsAppMessage: WhatsAppMessagePayload;
incidentId?: ObjectID | undefined;
alertId?: ObjectID | undefined;
scheduledMaintenanceId?: ObjectID | undefined;
@@ -67,6 +75,7 @@ export class Service extends DatabaseService<UserNotificationSetting> {
select: {
alertByEmail: true,
alertBySMS: true,
alertByWhatsApp: true,
alertByCall: true,
alertByPush: true,
},
@@ -167,6 +176,57 @@ export class Service extends DatabaseService<UserNotificationSetting> {
}
}
if (notificationSettings.alertByWhatsApp) {
const userWhatsApps: Array<UserWhatsApp> =
await UserWhatsAppService.findBy({
query: {
userId: data.userId,
projectId: data.projectId,
isVerified: true,
},
select: {
phone: true,
},
limit: LIMIT_PER_PROJECT,
skip: 0,
props: {
isRoot: true,
},
});
if (!data.whatsAppMessage) {
logger.warn(
"Skipping WhatsApp notification because WhatsApp template payload is missing.",
);
} else {
for (const userWhatsApp of userWhatsApps) {
const whatsAppMessage: WhatsAppMessage =
appendRecipientToWhatsAppMessage(
data.whatsAppMessage,
userWhatsApp.phone!,
);
WhatsAppService.sendWhatsAppMessage(whatsAppMessage, {
projectId: data.projectId,
incidentId: data.incidentId,
alertId: data.alertId,
scheduledMaintenanceId: data.scheduledMaintenanceId,
statusPageId: data.statusPageId,
statusPageAnnouncementId: data.statusPageAnnouncementId,
userId: data.userId,
teamId: data.teamId,
onCallPolicyId: data.onCallPolicyId,
onCallPolicyEscalationRuleId: data.onCallPolicyEscalationRuleId,
onCallDutyPolicyExecutionLogTimelineId:
data.onCallDutyPolicyExecutionLogTimelineId,
onCallScheduleId: data.onCallScheduleId,
}).catch((err: Error) => {
logger.error(err);
});
}
}
}
if (notificationSettings.alertByCall) {
const userCalls: Array<UserCall> = await UserCallService.findBy({
query: {

View File

@@ -0,0 +1,203 @@
import { IsBillingEnabled } from "../EnvironmentConfig";
import CreateBy from "../Types/Database/CreateBy";
import DeleteBy from "../Types/Database/DeleteBy";
import { OnCreate, OnDelete } from "../Types/Database/Hooks";
import logger from "../Utils/Logger";
import DatabaseService from "./DatabaseService";
import ProjectService from "./ProjectService";
import UserNotificationRuleService from "./UserNotificationRuleService";
import WhatsAppService from "./WhatsAppService";
import LIMIT_MAX from "../../Types/Database/LimitMax";
import BadDataException from "../../Types/Exception/BadDataException";
import ObjectID from "../../Types/ObjectID";
import Text from "../../Types/Text";
import Project from "../../Models/DatabaseModels/Project";
import Model from "../../Models/DatabaseModels/UserWhatsApp";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import WhatsAppMessage from "../../Types/WhatsApp/WhatsAppMessage";
import {
WhatsAppTemplateIds,
WhatsAppTemplateLanguage,
WhatsAppTemplateId,
} from "../../Types/WhatsApp/WhatsAppTemplates";
import HTTPErrorResponse from "../../Types/API/HTTPErrorResponse";
import HTTPResponse from "../../Types/API/HTTPResponse";
import { JSONObject } from "../../Types/JSON";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
}
@CaptureSpan()
protected override async onBeforeDelete(
deleteBy: DeleteBy<Model>,
): Promise<OnDelete<Model>> {
const itemsToDelete: Array<Model> = await this.findBy({
query: deleteBy.query,
select: {
_id: true,
projectId: true,
},
skip: 0,
limit: LIMIT_MAX,
props: {
isRoot: true,
},
});
for (const item of itemsToDelete) {
await UserNotificationRuleService.deleteBy({
query: {
userWhatsAppId: item.id!,
projectId: item.projectId!,
},
limit: LIMIT_MAX,
skip: 0,
props: {
isRoot: true,
},
});
}
return {
deleteBy,
carryForward: null,
};
}
@CaptureSpan()
protected override async onBeforeCreate(
createBy: CreateBy<Model>,
): Promise<OnCreate<Model>> {
if (!createBy.props.isRoot && createBy.data.isVerified) {
throw new BadDataException("isVerified cannot be set to true");
}
const project: Project | null = await ProjectService.findOneById({
id: createBy.data.projectId!,
props: {
isRoot: true,
},
select: {
enableWhatsAppNotifications: true,
smsOrCallCurrentBalanceInUSDCents: true,
},
});
if (!project) {
throw new BadDataException("Project not found");
}
if (!project.enableWhatsAppNotifications) {
throw new BadDataException(
"WhatsApp notifications are disabled for this project. Please enable them in Project Settings > Notification Settings.",
);
}
if (
(project.smsOrCallCurrentBalanceInUSDCents as number) <= 100 &&
IsBillingEnabled
) {
throw new BadDataException(
"Your WhatsApp balance is low. Please recharge your balance in Project Settings > Notification Settings.",
);
}
return {
createBy,
carryForward: null,
};
}
@CaptureSpan()
protected override async onCreateSuccess(
_onCreate: OnCreate<Model>,
createdItem: Model,
): Promise<Model> {
if (!createdItem.isVerified) {
this.sendVerificationCode(createdItem).catch((error: Error) => {
logger.error(error);
});
}
return createdItem;
}
@CaptureSpan()
public async resendVerificationCode(itemId: ObjectID): Promise<void> {
const item: Model | null = await this.findOneById({
id: itemId,
props: {
isRoot: true,
},
select: {
phone: true,
verificationCode: true,
isVerified: true,
projectId: true,
userId: true,
},
});
if (!item) {
throw new BadDataException(
"Item with ID " + itemId.toString() + " not found",
);
}
if (item.isVerified) {
throw new BadDataException("WhatsApp number already verified");
}
item.verificationCode = Text.generateRandomNumber(6);
await this.updateOneById({
id: item.id!,
props: {
isRoot: true,
},
data: {
verificationCode: item.verificationCode,
},
});
await this.sendVerificationCode(item);
}
public async sendVerificationCode(item: Model): Promise<void> {
if (!item.projectId || !item.userId || !item.phone) {
logger.warn("Cannot send WhatsApp verification code. Missing data.");
throw new BadDataException(
"Unable to send WhatsApp verification code. Please remove this number and add it again.",
);
}
const templateKey: WhatsAppTemplateId =
WhatsAppTemplateIds.VerificationCode;
const templateVariables: Record<string, string> = {
"1": item.verificationCode || "",
};
const whatsAppMessage: WhatsAppMessage = {
to: item.phone,
body: "",
templateKey,
templateVariables,
templateLanguageCode: WhatsAppTemplateLanguage[templateKey],
};
const response: HTTPResponse<JSONObject> =
await WhatsAppService.sendWhatsAppMessage(whatsAppMessage, {
projectId: item.projectId,
isSensitive: true,
userId: item.userId,
});
if (response instanceof HTTPErrorResponse) {
throw response;
}
}
}
export default new Service();

View File

@@ -0,0 +1,15 @@
import { IsBillingEnabled } from "../EnvironmentConfig";
import DatabaseService from "./DatabaseService";
import Model from "../../Models/DatabaseModels/WhatsAppLog";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
if (IsBillingEnabled) {
this.hardDeleteItemsOlderThanInDays("createdAt", 3);
}
}
}
export default new Service();

View File

@@ -0,0 +1,141 @@
import { AppApiHostname } from "../EnvironmentConfig";
import ClusterKeyAuthorization from "../Middleware/ClusterKeyAuthorization";
import BaseService from "./BaseService";
import EmptyResponseData from "../../Types/API/EmptyResponse";
import HTTPResponse from "../../Types/API/HTTPResponse";
import Protocol from "../../Types/API/Protocol";
import Route from "../../Types/API/Route";
import URL from "../../Types/API/URL";
import { JSONObject } from "../../Types/JSON";
import ObjectID from "../../Types/ObjectID";
import WhatsAppMessage from "../../Types/WhatsApp/WhatsAppMessage";
import API from "../../Utils/API";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
export class WhatsAppService extends BaseService {
public constructor() {
super();
}
@CaptureSpan()
public async sendWhatsAppMessage(
message: WhatsAppMessage,
options: {
projectId?: ObjectID | undefined;
isSensitive?: boolean | undefined;
userOnCallLogTimelineId?: ObjectID | undefined;
incidentId?: ObjectID | undefined;
alertId?: ObjectID | undefined;
scheduledMaintenanceId?: ObjectID | undefined;
statusPageId?: ObjectID | undefined;
statusPageAnnouncementId?: ObjectID | undefined;
userId?: ObjectID | undefined;
onCallPolicyId?: ObjectID | undefined;
onCallPolicyEscalationRuleId?: ObjectID | undefined;
onCallDutyPolicyExecutionLogTimelineId?: ObjectID | undefined;
onCallScheduleId?: ObjectID | undefined;
teamId?: ObjectID | undefined;
} = {},
): Promise<HTTPResponse<EmptyResponseData>> {
const body: JSONObject = {
to: message.to.toString(),
};
if (message.body) {
body["body"] = message.body;
}
if (message.templateKey) {
body["templateKey"] = message.templateKey;
}
if (message.templateVariables) {
const templateVariables: JSONObject = {};
for (const [key, value] of Object.entries(message.templateVariables)) {
templateVariables[key] = value;
}
body["templateVariables"] = templateVariables;
}
if (message.templateLanguageCode) {
body["templateLanguageCode"] = message.templateLanguageCode;
}
if (options.projectId) {
body["projectId"] = options.projectId.toString();
}
if (options.isSensitive !== undefined) {
body["isSensitive"] = options.isSensitive;
}
if (options.userOnCallLogTimelineId) {
body["userOnCallLogTimelineId"] =
options.userOnCallLogTimelineId.toString();
}
if (options.incidentId) {
body["incidentId"] = options.incidentId.toString();
}
if (options.alertId) {
body["alertId"] = options.alertId.toString();
}
if (options.scheduledMaintenanceId) {
body["scheduledMaintenanceId"] =
options.scheduledMaintenanceId.toString();
}
if (options.statusPageId) {
body["statusPageId"] = options.statusPageId.toString();
}
if (options.statusPageAnnouncementId) {
body["statusPageAnnouncementId"] =
options.statusPageAnnouncementId.toString();
}
if (options.userId) {
body["userId"] = options.userId.toString();
}
if (options.onCallPolicyId) {
body["onCallPolicyId"] = options.onCallPolicyId.toString();
}
if (options.onCallPolicyEscalationRuleId) {
body["onCallPolicyEscalationRuleId"] =
options.onCallPolicyEscalationRuleId.toString();
}
if (options.onCallDutyPolicyExecutionLogTimelineId) {
body["onCallDutyPolicyExecutionLogTimelineId"] =
options.onCallDutyPolicyExecutionLogTimelineId.toString();
}
if (options.onCallScheduleId) {
body["onCallScheduleId"] = options.onCallScheduleId.toString();
}
if (options.teamId) {
body["teamId"] = options.teamId.toString();
}
return await API.post<EmptyResponseData>({
url: new URL(
Protocol.HTTP,
AppApiHostname,
new Route("/api/notification/whatsapp/send"),
),
data: body,
headers: {
...ClusterKeyAuthorization.getClusterKeyHeaders(),
},
});
}
}
export default new WhatsAppService();

View File

@@ -1,7 +1,11 @@
import ClusterKeyAuthorization from "../../../../Middleware/ClusterKeyAuthorization";
import DatabaseService from "../../../../Services/DatabaseService";
import WorkflowService from "../../../../Services/WorkflowService";
import { ExpressRequest, ExpressResponse } from "../../../../Utils/Express";
import {
ExpressRequest,
ExpressResponse,
NextFunction,
} from "../../../../Utils/Express";
import logger from "../../../../Utils/Logger";
import Response from "../../../../Utils/Response";
import Select from "../../../Database/Select";
@@ -60,16 +64,24 @@ export default class OnTriggerBaseModel<
props.router.get(
`/model/:projectId/${this.modelId}/${this.type}`,
ClusterKeyAuthorization.isAuthorizedServiceMiddleware,
async (req: ExpressRequest, res: ExpressResponse) => {
await this.initTrigger(req, res, props);
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
await this.initTrigger(req, res, props);
} catch (err) {
return next(err);
}
},
);
props.router.post(
`/model/:projectId/${this.modelId}/${this.type}`,
ClusterKeyAuthorization.isAuthorizedServiceMiddleware,
async (req: ExpressRequest, res: ExpressResponse) => {
await this.initTrigger(req, res, props);
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
await this.initTrigger(req, res, props);
} catch (err) {
return next(err);
}
},
);
}

View File

@@ -59,7 +59,7 @@ export default class WebhookTrigger extends TriggerCode {
try {
await this.initTrigger(req, res, props);
} catch (e) {
next(e);
return next(e);
}
},
);
@@ -70,7 +70,7 @@ export default class WebhookTrigger extends TriggerCode {
try {
await this.initTrigger(req, res, props);
} catch (e) {
next(e);
return next(e);
}
},
);

View File

@@ -20,6 +20,7 @@ import "./Process";
import Response from "./Response";
import { api } from "@opentelemetry/sdk-node";
import StatusCode from "../../Types/API/StatusCode";
import HTTPErrorResponse from "../../Types/API/HTTPErrorResponse";
import Exception from "../../Types/Exception/Exception";
import NotFoundException from "../../Types/Exception/NotFoundException";
import ServerException from "../../Types/Exception/ServerException";
@@ -182,14 +183,15 @@ const init: InitFunction = async (
app.get(
[`/${appName}/env.js`, "/env.js"],
async (req: ExpressRequest, res: ExpressResponse) => {
// ping api server for database config.
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
// ping api server for database config.
const env: JSONObject = {
...process.env,
};
const env: JSONObject = {
...process.env,
};
const script: string = `
const script: string = `
if(!window.process){
window.process = {}
}
@@ -201,7 +203,10 @@ const init: InitFunction = async (
window.process.env = JSON.parse(envVars);
`;
Response.sendJavaScriptResponse(req, res, script);
Response.sendJavaScriptResponse(req, res, script);
} catch (err) {
return next(err);
}
},
);
@@ -216,32 +221,40 @@ const init: InitFunction = async (
app.get(
["/*", `/${appName}/*`],
async (_req: ExpressRequest, res: ExpressResponse) => {
logger.debug("Rendering index page");
async (
_req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
) => {
try {
logger.debug("Rendering index page");
let variables: JSONObject = {};
let variables: JSONObject = {};
if (data.getVariablesToRenderIndexPage) {
logger.debug("Getting variables to render index page");
try {
const variablesToRenderIndexPage: JSONObject =
await data.getVariablesToRenderIndexPage(_req, res);
variables = {
...variables,
...variablesToRenderIndexPage,
};
} catch (error) {
logger.error(error);
if (data.getVariablesToRenderIndexPage) {
logger.debug("Getting variables to render index page");
try {
const variablesToRenderIndexPage: JSONObject =
await data.getVariablesToRenderIndexPage(_req, res);
variables = {
...variables,
...variablesToRenderIndexPage,
};
} catch (error) {
logger.error(error);
}
}
logger.debug("Rendering index page with variables: ");
logger.debug(variables);
return res.render("/usr/src/app/views/index.ejs", {
enableGoogleTagManager: IsBillingEnabled || false,
...variables,
});
} catch (err) {
return next(err);
}
logger.debug("Rendering index page with variables: ");
logger.debug(variables);
return res.render("/usr/src/app/views/index.ejs", {
enableGoogleTagManager: IsBillingEnabled || false,
...variables,
});
},
);
}
@@ -285,7 +298,7 @@ const addDefaultRoutes: PromiseVoidFunction = async (): Promise<void> => {
// Attach Error Handler.
app.use(
(
err: Error | Exception,
err: Error | Exception | HTTPErrorResponse,
_req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
@@ -323,6 +336,19 @@ const addDefaultRoutes: PromiseVoidFunction = async (): Promise<void> => {
res.send({ error: "Server Error" });
}
});
} else if (err instanceof HTTPErrorResponse) {
const errorStatusCode: number = StatusCode.isValidStatusCode(
err.statusCode,
)
? err.statusCode
: 500;
const payload: unknown = err.jsonData ?? {
error: err.message || "Server Error",
};
res.status(errorStatusCode);
res.send(payload);
} else if (err instanceof Exception) {
res.status((err as Exception).code);
res.send({ error: (err as Exception).message });

View File

@@ -0,0 +1,247 @@
import NotificationSettingEventType from "../../Types/NotificationSetting/NotificationSettingEventType";
import WhatsAppTemplateMessages, {
WhatsAppTemplateIds,
WhatsAppTemplateId,
WhatsAppTemplateLanguage,
} from "../../Types/WhatsApp/WhatsAppTemplates";
import WhatsAppMessage, {
WhatsAppMessagePayload,
} from "../../Types/WhatsApp/WhatsAppMessage";
const DEFAULT_ACTION_LINK: string = "https://oneuptime.com/dashboard";
const templateDashboardLinkVariableMap: Partial<
Record<WhatsAppTemplateId, string>
> = {
[WhatsAppTemplateIds.AlertCreated]: "alert_link",
[WhatsAppTemplateIds.AlertCreatedOwnerNotification]: "alert_link",
[WhatsAppTemplateIds.AlertNotePostedOwnerNotification]: "alert_link",
[WhatsAppTemplateIds.AlertStateChangedOwnerNotification]: "alert_link",
[WhatsAppTemplateIds.AlertOwnerAddedNotification]: "alert_link",
[WhatsAppTemplateIds.IncidentCreated]: "incident_link",
[WhatsAppTemplateIds.IncidentCreatedOwnerNotification]: "incident_link",
[WhatsAppTemplateIds.IncidentNotePostedOwnerNotification]: "incident_link",
[WhatsAppTemplateIds.IncidentStateChangedOwnerNotification]: "incident_link",
[WhatsAppTemplateIds.IncidentOwnerAddedNotification]: "incident_link",
[WhatsAppTemplateIds.MonitorOwnerAddedNotification]: "monitor_link",
[WhatsAppTemplateIds.MonitorCreatedOwnerNotification]: "monitor_link",
[WhatsAppTemplateIds.MonitorStatusChangedOwnerNotification]: "monitor_link",
[WhatsAppTemplateIds.MonitorProbeStatusChangedNotification]: "monitor_link",
[WhatsAppTemplateIds.MonitorNoProbesMonitoringNotification]: "monitor_link",
[WhatsAppTemplateIds.ScheduledMaintenanceCreatedOwnerNotification]:
"maintenance_link",
[WhatsAppTemplateIds.ScheduledMaintenanceNotePostedOwnerNotification]:
"maintenance_link",
[WhatsAppTemplateIds.ScheduledMaintenanceOwnerAddedNotification]:
"maintenance_link",
[WhatsAppTemplateIds.ScheduledMaintenanceStateChangedOwnerNotification]:
"maintenance_link",
[WhatsAppTemplateIds.StatusPageAnnouncementCreatedOwnerNotification]:
"status_page_link",
[WhatsAppTemplateIds.StatusPageCreatedOwnerNotification]: "status_page_link",
[WhatsAppTemplateIds.StatusPageOwnerAddedNotification]: "status_page_link",
[WhatsAppTemplateIds.ProbeStatusChangedOwnerNotification]: "probe_link",
[WhatsAppTemplateIds.ProbeOwnerAddedNotification]: "probe_link",
[WhatsAppTemplateIds.OnCallUserIsOnRosterNotification]: "schedule_link",
[WhatsAppTemplateIds.OnCallUserIsNextNotification]: "schedule_link",
[WhatsAppTemplateIds.OnCallUserNoLongerActiveNotification]: "schedule_link",
[WhatsAppTemplateIds.OnCallUserAddedToPolicyNotification]: "policy_link",
[WhatsAppTemplateIds.OnCallUserRemovedFromPolicyNotification]: "policy_link",
[WhatsAppTemplateIds.VerificationCode]: "dashboard_link",
};
const templateIdByEventType: Record<
NotificationSettingEventType,
WhatsAppTemplateId
> = {
[NotificationSettingEventType.SEND_INCIDENT_CREATED_OWNER_NOTIFICATION]:
WhatsAppTemplateIds.IncidentCreatedOwnerNotification,
[NotificationSettingEventType.SEND_INCIDENT_NOTE_POSTED_OWNER_NOTIFICATION]:
WhatsAppTemplateIds.IncidentNotePostedOwnerNotification,
[NotificationSettingEventType.SEND_INCIDENT_STATE_CHANGED_OWNER_NOTIFICATION]:
WhatsAppTemplateIds.IncidentStateChangedOwnerNotification,
[NotificationSettingEventType.SEND_INCIDENT_OWNER_ADDED_NOTIFICATION]:
WhatsAppTemplateIds.IncidentOwnerAddedNotification,
[NotificationSettingEventType.SEND_ALERT_CREATED_OWNER_NOTIFICATION]:
WhatsAppTemplateIds.AlertCreatedOwnerNotification,
[NotificationSettingEventType.SEND_ALERT_NOTE_POSTED_OWNER_NOTIFICATION]:
WhatsAppTemplateIds.AlertNotePostedOwnerNotification,
[NotificationSettingEventType.SEND_ALERT_STATE_CHANGED_OWNER_NOTIFICATION]:
WhatsAppTemplateIds.AlertStateChangedOwnerNotification,
[NotificationSettingEventType.SEND_ALERT_OWNER_ADDED_NOTIFICATION]:
WhatsAppTemplateIds.AlertOwnerAddedNotification,
[NotificationSettingEventType.SEND_MONITOR_OWNER_ADDED_NOTIFICATION]:
WhatsAppTemplateIds.MonitorOwnerAddedNotification,
[NotificationSettingEventType.SEND_MONITOR_CREATED_OWNER_NOTIFICATION]:
WhatsAppTemplateIds.MonitorCreatedOwnerNotification,
[NotificationSettingEventType.SEND_MONITOR_STATUS_CHANGED_OWNER_NOTIFICATION]:
WhatsAppTemplateIds.MonitorStatusChangedOwnerNotification,
[NotificationSettingEventType.SEND_MONITOR_NOTIFICATION_WHEN_PORBE_STATUS_CHANGES]:
WhatsAppTemplateIds.MonitorProbeStatusChangedNotification,
[NotificationSettingEventType.SEND_MONITOR_NOTIFICATION_WHEN_NO_PROBES_ARE_MONITORING_THE_MONITOR]:
WhatsAppTemplateIds.MonitorNoProbesMonitoringNotification,
[NotificationSettingEventType.SEND_SCHEDULED_MAINTENANCE_CREATED_OWNER_NOTIFICATION]:
WhatsAppTemplateIds.ScheduledMaintenanceCreatedOwnerNotification,
[NotificationSettingEventType.SEND_SCHEDULED_MAINTENANCE_NOTE_POSTED_OWNER_NOTIFICATION]:
WhatsAppTemplateIds.ScheduledMaintenanceNotePostedOwnerNotification,
[NotificationSettingEventType.SEND_SCHEDULED_MAINTENANCE_OWNER_ADDED_NOTIFICATION]:
WhatsAppTemplateIds.ScheduledMaintenanceOwnerAddedNotification,
[NotificationSettingEventType.SEND_SCHEDULED_MAINTENANCE_STATE_CHANGED_OWNER_NOTIFICATION]:
WhatsAppTemplateIds.ScheduledMaintenanceStateChangedOwnerNotification,
[NotificationSettingEventType.SEND_STATUS_PAGE_ANNOUNCEMENT_CREATED_OWNER_NOTIFICATION]:
WhatsAppTemplateIds.StatusPageAnnouncementCreatedOwnerNotification,
[NotificationSettingEventType.SEND_STATUS_PAGE_CREATED_OWNER_NOTIFICATION]:
WhatsAppTemplateIds.StatusPageCreatedOwnerNotification,
[NotificationSettingEventType.SEND_STATUS_PAGE_OWNER_ADDED_NOTIFICATION]:
WhatsAppTemplateIds.StatusPageOwnerAddedNotification,
[NotificationSettingEventType.SEND_PROBE_STATUS_CHANGED_OWNER_NOTIFICATION]:
WhatsAppTemplateIds.ProbeStatusChangedOwnerNotification,
[NotificationSettingEventType.SEND_PROBE_OWNER_ADDED_NOTIFICATION]:
WhatsAppTemplateIds.ProbeOwnerAddedNotification,
[NotificationSettingEventType.SEND_WHEN_USER_IS_ON_CALL_ROSTER]:
WhatsAppTemplateIds.OnCallUserIsOnRosterNotification,
[NotificationSettingEventType.SEND_WHEN_USER_IS_NEXT_ON_CALL_ROSTER]:
WhatsAppTemplateIds.OnCallUserIsNextNotification,
[NotificationSettingEventType.SEND_WHEN_USER_IS_ADDED_TO_ON_CALL_POLICY]:
WhatsAppTemplateIds.OnCallUserAddedToPolicyNotification,
[NotificationSettingEventType.SEND_WHEN_USER_IS_REMOVED_FROM_ON_CALL_POLICY]:
WhatsAppTemplateIds.OnCallUserRemovedFromPolicyNotification,
[NotificationSettingEventType.SEND_WHEN_USER_IS_NO_LONGER_ACTIVE_ON_ON_CALL_ROSTER]:
WhatsAppTemplateIds.OnCallUserNoLongerActiveNotification,
};
export function getWhatsAppTemplateIdForEventType(
eventType: NotificationSettingEventType,
): WhatsAppTemplateId {
const templateId: WhatsAppTemplateId | undefined =
templateIdByEventType[eventType];
if (!templateId) {
throw new Error(
`WhatsApp template is not defined for event type ${eventType}.`,
);
}
return templateId;
}
export function getWhatsAppTemplateStringForEventType(
eventType: NotificationSettingEventType,
): string {
const templateId: WhatsAppTemplateId =
getWhatsAppTemplateIdForEventType(eventType);
const templateContent: string | undefined =
WhatsAppTemplateMessages[templateId];
if (!templateContent) {
throw new Error(
`WhatsApp template content is not defined for event type ${eventType}.`,
);
}
return templateContent;
}
function renderTemplateContent(
templateContent: string,
variables: Record<string, string>,
context: string,
): string {
return templateContent.replace(
/\{\{(.*?)\}\}/g,
(_match: string, key: string) => {
const value: string | undefined = variables[key];
if (value === undefined) {
throw new Error(
`Missing variable "${key}" for WhatsApp template ${context}.`,
);
}
return value;
},
);
}
export function createWhatsAppMessageFromTemplate({
templateString,
actionLink,
eventType,
templateKey,
templateVariables,
}: {
templateString?: string;
actionLink?: string | undefined;
eventType?: NotificationSettingEventType;
templateKey?: WhatsAppTemplateId;
templateVariables?: Record<string, string>;
}): WhatsAppMessagePayload {
const resolvedTemplateKey: WhatsAppTemplateId | undefined =
templateKey ??
(eventType ? getWhatsAppTemplateIdForEventType(eventType) : undefined);
if (!resolvedTemplateKey) {
throw new Error(
"WhatsApp template key or event type must be provided to create WhatsApp message.",
);
}
const resolvedActionLink: string = (actionLink ?? DEFAULT_ACTION_LINK).trim();
const templateVariablesWithDefaults: Record<string, string> = {
...(templateVariables ?? {}),
};
const dashboardLinkVariableName: string | undefined =
templateDashboardLinkVariableMap[resolvedTemplateKey];
if (dashboardLinkVariableName) {
const providedLink: string | undefined =
templateVariablesWithDefaults[dashboardLinkVariableName] ??
templateVariables?.[dashboardLinkVariableName];
const finalLink: string = (providedLink || resolvedActionLink).trim();
templateVariablesWithDefaults[dashboardLinkVariableName] = finalLink;
}
const resolvedTemplateContent: string | undefined =
templateString ?? WhatsAppTemplateMessages[resolvedTemplateKey];
if (!resolvedTemplateContent) {
throw new Error(
`WhatsApp template content is not defined for template ${resolvedTemplateKey}.`,
);
}
const body: string = renderTemplateContent(
resolvedTemplateContent,
templateVariablesWithDefaults,
resolvedTemplateKey,
);
return {
body,
templateKey: resolvedTemplateKey,
templateVariables: templateVariablesWithDefaults,
templateLanguageCode: WhatsAppTemplateLanguage[resolvedTemplateKey],
};
}
export function appendRecipientToWhatsAppMessage(
payload: WhatsAppMessagePayload,
to: WhatsAppMessage["to"],
): WhatsAppMessage {
return {
...payload,
to,
};
}
export default {
createWhatsAppMessageFromTemplate,
appendRecipientToWhatsAppMessage,
getWhatsAppTemplateIdForEventType,
getWhatsAppTemplateStringForEventType,
};

View File

@@ -2378,29 +2378,23 @@ All monitoring checks are passing normally.`;
logger.debug(`Channel data: ${JSON.stringify(channelData)}`);
// Check if the bot was added
const recipientId: string | undefined =
data.turnContext.activity.recipient?.id;
const botWasAdded: boolean = membersAdded.some((member: JSONObject) => {
return member["id"] === MicrosoftTeamsAppClientId;
return member["id"] === recipientId;
});
if (botWasAdded) {
logger.debug("OneUptime bot was added to a Teams conversation");
const welcomeText: string =
"🎉 Welcome to OneUptime!\n\nI'm your monitoring and alerting assistant. I'll help you stay on top of your system's health and notify you about any incidents.\n\nType 'help' to see what I can do for you.";
try {
// Send welcome message directly using TurnContext
await data.turnContext.sendActivity(welcomeText);
logger.debug("Welcome message sent successfully using TurnContext");
} catch (error) {
logger.error("Error sending welcome message via TurnContext: " + error);
}
await this.sendWelcomeAdaptiveCard(data.turnContext);
}
}
@CaptureSpan()
public static async handleInstallationUpdateActivity(data: {
activity: JSONObject;
turnContext: TurnContext;
}): Promise<void> {
// Handle bot installation/uninstallation
const action: string = (data.activity["action"] as string) || "";
@@ -2412,6 +2406,7 @@ All monitoring checks are passing normally.`;
if (action === "add") {
logger.debug("OneUptime bot was installed");
await this.sendWelcomeAdaptiveCard(data.turnContext);
} else if (action === "remove") {
logger.debug("OneUptime bot was uninstalled");
}
@@ -2482,6 +2477,20 @@ All monitoring checks are passing normally.`;
await next();
},
);
this.onInstallationUpdateAdd(
async (context: TurnContext, next: () => Promise<void>) => {
logger.debug(
"Handling installation update add activity: " +
JSON.stringify(context.activity),
);
await MicrosoftTeamsUtil.handleInstallationUpdateActivity({
activity: context.activity as unknown as JSONObject,
turnContext: context,
});
await next();
},
);
}
protected override async onInvokeActivity(
@@ -2528,6 +2537,132 @@ All monitoring checks are passing normally.`;
}
}
private static buildWelcomeAdaptiveCard(): JSONObject {
return {
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
type: "AdaptiveCard",
version: "1.4",
body: [
{
type: "TextBlock",
text: "Welcome to OneUptime for Microsoft Teams",
weight: "Bolder",
size: "Large",
wrap: true,
},
{
type: "TextBlock",
text: "OneUptime keeps your team ahead of incidents by streaming alerts, maintenance updates, and on-call context directly into Microsoft Teams.",
wrap: true,
spacing: "Small",
},
{
type: "TextBlock",
text: "Getting started",
weight: "Bolder",
size: "Medium",
spacing: "Large",
wrap: true,
},
{
type: "TextBlock",
text: "1. Connect this Teams workspace to your OneUptime project from **Settings → Integrations → Microsoft Teams**.\n2. Choose which incidents, alerts, and maintenance events should sync into Teams.\n3. Try the commands below or automate workflows from the OneUptime dashboard.",
wrap: true,
},
{
type: "TextBlock",
text: "Bot commands",
weight: "Bolder",
size: "Medium",
spacing: "Large",
wrap: true,
},
{
type: "FactSet",
facts: [
{
title: "help",
value: "Show quick help and useful links",
},
{
title: "/incident",
value: "Create a new incident without leaving Teams",
},
{
title: "/maintenance",
value: "Schedule or review maintenance windows",
},
{
title: "show active incidents",
value: "List all incidents that are currently open",
},
{
title: "show scheduled maintenance",
value: "Display upcoming maintenance events",
},
{
title: "show active alerts",
value: "Summarize active alerts for your project",
},
],
},
{
type: "TextBlock",
text: "To use this app, each user must have an active OneUptime account. Please contact our support team for more details.",
wrap: true,
spacing: "Large",
},
{
type: "TextBlock",
text: "Need more help?",
weight: "Bolder",
size: "Medium",
spacing: "Large",
wrap: true,
},
{
type: "TextBlock",
text: "Review our setup guide or reach out if you need assistance configuring notifications.",
wrap: true,
},
],
actions: [
{
type: "Action.OpenUrl",
title: "View Setup Guide",
url: "https://oneuptime.com/docs/microsoft-teams",
},
{
type: "Action.OpenUrl",
title: "Contact Support",
url: "mailto:support@oneuptime.com?subject=OneUptime%20Microsoft%20Teams%20Bot",
},
{
type: "Action.OpenUrl",
title: "Open OneUptime Dashboard",
url: "https://oneuptime.com/dashboard",
},
],
} as JSONObject;
}
private static async sendWelcomeAdaptiveCard(
turnContext: TurnContext,
): Promise<void> {
try {
const welcomeCard: JSONObject = this.buildWelcomeAdaptiveCard();
const message: Partial<Activity> = MessageFactory.attachment({
contentType: "application/vnd.microsoft.card.adaptive",
content: welcomeCard,
});
await turnContext.sendActivity(message);
logger.debug("Welcome adaptive card sent successfully");
} catch (error) {
logger.error("Error sending welcome adaptive card: " + error);
}
}
// Method to refresh teams list for a user
@CaptureSpan()
public static async refreshTeams(data: {

View File

@@ -1,6 +1,8 @@
/** @jest-environment jsdom */
// Ensure deterministic timezone for Date#getHours() etc.
// This must be set before importing the component under test.
/*
* Ensure deterministic timezone for Date#getHours() etc.
* This must be set before importing the component under test.
*/
// eslint-disable-next-line no-undef
process.env.TZ = "UTC";
import React from "react";
@@ -8,65 +10,138 @@ import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import TimePicker from "../../../../UI/Components/TimePicker/TimePicker";
import DateUtilities from "../../../../Types/Date";
type DateModule = typeof import("../../../../Types/Date");
type DateLib = DateModule["default"];
type MockedDateLib = jest.Mocked<DateLib>;
type UserEventInstance = ReturnType<typeof userEvent.setup>;
type ChangeHandler = jest.Mock<void, [string | undefined]>;
type VoidHandler = jest.Mock<void, []>;
type DialogElement = HTMLElement;
type ButtonElement = HTMLButtonElement;
type InputElement = HTMLInputElement;
type HourMinuteMock = {
getHours: () => number;
getMinutes: () => number;
};
// Mock OneUptimeDate utilities used by the component
jest.mock("../../../../Types/Date", () => {
const real = jest.requireActual("../../../../Types/Date");
const real: DateModule = jest.requireActual("../../../../Types/Date");
// Helper to create a minimal date-like object with getHours/getMinutes
const makeHM = (h: number, m: number) => ({ getHours: () => h, getMinutes: () => m });
const makeHM: (h: number, m: number) => HourMinuteMock = (
h: number,
m: number,
): HourMinuteMock => {
return {
getHours: () => {
return h;
},
getMinutes: () => {
return m;
},
};
};
return {
__esModule: true,
default: {
...real.default,
getUserPrefers12HourFormat: jest.fn(() => false), // default to 24h; tests can override per test
getCurrentDate: jest.fn(() => makeHM(13, 45) as any),
getUserPrefers12HourFormat: jest.fn(() => {
return false;
}), // default to 24h; tests can override per test
getCurrentDate: jest.fn(() => {
return makeHM(13, 45) as unknown as Date;
}),
fromString: jest.fn((v: string | Date) => {
if (!v) { return undefined as any; }
if (!v) {
return undefined as unknown as Date;
}
if (typeof v === "string") {
const m = v.match(/T(\d{2}):(\d{2})/);
const hh = m ? parseInt(m[1] as string, 10) : 0;
const mm = m ? parseInt(m[2] as string, 10) : 0;
return makeHM(hh, mm) as any;
const match: RegExpMatchArray | null = v.match(/T(\d{2}):(\d{2})/);
const hh: number = match ? parseInt(match[1] as string, 10) : 0;
const mm: number = match ? parseInt(match[2] as string, 10) : 0;
return makeHM(hh, mm) as unknown as Date;
}
// If a Date instance is provided, prefer UTC to avoid env timezone
const d = v as Date;
const hh = (d as any).getUTCHours ? (d as any).getUTCHours() : d.getHours();
const mm = (d as any).getUTCMinutes ? (d as any).getUTCMinutes() : d.getMinutes();
return makeHM(hh, mm) as any;
const d: Date = v;
const hasUtcHours: (() => number) | undefined = (
d as { getUTCHours?: () => number }
).getUTCHours;
const hasUtcMinutes: (() => number) | undefined = (
d as { getUTCMinutes?: () => number }
).getUTCMinutes;
const hh: number = hasUtcHours ? hasUtcHours.call(d) : d.getHours();
const mm: number = hasUtcMinutes
? hasUtcMinutes.call(d)
: d.getMinutes();
return makeHM(hh, mm) as unknown as Date;
}),
toString: jest.fn((d: Date) => d.toISOString()),
getDateWithCustomTime: jest.fn(({ hours, minutes }: { hours: number; minutes: number; seconds?: number }) => {
const base = new Date("2024-05-15T00:00:00.000Z");
base.setUTCHours(hours, minutes, 0, 0);
return base;
toString: jest.fn((d: Date) => {
return d.toISOString();
}),
getDateWithCustomTime: jest.fn(
({
hours,
minutes,
}: {
hours: number;
minutes: number;
seconds?: number;
}) => {
const base: Date = new Date("2024-05-15T00:00:00.000Z");
base.setUTCHours(hours, minutes, 0, 0);
return base;
},
),
getCurrentTimezoneString: jest.fn(() => {
return "UTC";
}),
getCurrentTimezone: jest.fn(() => {
return "Etc/UTC";
}),
getCurrentTimezoneString: jest.fn(() => "UTC"),
getCurrentTimezone: jest.fn(() => "Etc/UTC"),
},
};
});
// Mock Icon to avoid SVG complexity
jest.mock("../../../../UI/Components/Icon/Icon", () => ({
__esModule: true,
default: ({ className }: { className?: string }) => <i data-testid="icon" className={className} />,
}));
jest.mock("../../../../UI/Components/Icon/Icon", () => {
return {
__esModule: true,
default: ({ className }: { className?: string }) => {
return <i data-testid="icon" className={className} />;
},
};
});
// Mock Modal to render children immediately and expose submit/close
jest.mock("../../../../UI/Components/Modal/Modal", () => ({
__esModule: true,
default: ({ title, description, onClose, onSubmit, children, submitButtonText }: any) => (
<div role="dialog" aria-label={title}>
<div>{description}</div>
<div>{children}</div>
<button onClick={onSubmit}>{submitButtonText ?? "Apply"}</button>
<button onClick={onClose}>Close</button>
</div>
),
ModalWidth: { Medium: "Medium" },
}));
jest.mock("../../../../UI/Components/Modal/Modal", () => {
return {
__esModule: true,
default: ({
title,
description,
onClose,
onSubmit,
children,
submitButtonText,
}: any) => {
return (
<div role="dialog" aria-label={title}>
<div>{description}</div>
<div>{children}</div>
<button onClick={onSubmit}>{submitButtonText ?? "Apply"}</button>
<button onClick={onClose}>Close</button>
</div>
);
},
ModalWidth: { Medium: "Medium" },
};
});
const getDateLib = () => require("../../../../Types/Date").default;
const getDateLib: () => MockedDateLib = () => {
return DateUtilities as MockedDateLib;
};
describe("TimePicker", () => {
beforeEach(() => {
@@ -75,7 +150,7 @@ describe("TimePicker", () => {
});
it("renders in 24h by default and shows current time", () => {
const onChange = jest.fn();
const onChange: ChangeHandler = jest.fn();
render(<TimePicker value="2024-05-15T08:05:00.000Z" onChange={onChange} />);
// Should display HH:mm based on value prop
@@ -83,162 +158,246 @@ describe("TimePicker", () => {
expect(screen.getByLabelText("Minutes")).toHaveValue("05");
// AM/PM buttons are not shown in 24h
expect(screen.queryByRole("button", { name: "AM" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "PM" })).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "AM" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "PM" }),
).not.toBeInTheDocument();
});
it("opens modal on click when enabled", async () => {
const user = userEvent.setup();
const user: UserEventInstance = userEvent.setup();
render(<TimePicker value="2024-05-15T10:20:00.000Z" />);
// Click the field container by clicking on hours input
await user.click(screen.getByLabelText("Hours"));
// Modal should appear
expect(screen.getByRole("dialog", { name: "Select time" })).toBeInTheDocument();
expect(
screen.getByRole("dialog", { name: "Select time" }),
).toBeInTheDocument();
expect(screen.getByText(/your UTC/i)).toBeInTheDocument();
});
it("does not open modal when readOnly or disabled", async () => {
const user = userEvent.setup();
const { rerender } = render(<TimePicker value="2024-05-15T10:20:00.000Z" readOnly />);
const user: UserEventInstance = userEvent.setup();
const { rerender } = render(
<TimePicker value="2024-05-15T10:20:00.000Z" readOnly />,
);
await user.click(screen.getByLabelText("Hours"));
expect(screen.queryByRole("dialog", { name: "Select time" })).not.toBeInTheDocument();
expect(
screen.queryByRole("dialog", { name: "Select time" }),
).not.toBeInTheDocument();
rerender(<TimePicker value="2024-05-15T10:20:00.000Z" disabled />);
await user.click(screen.getByLabelText("Minutes"));
expect(screen.queryByRole("dialog", { name: "Select time" })).not.toBeInTheDocument();
expect(
screen.queryByRole("dialog", { name: "Select time" }),
).not.toBeInTheDocument();
});
it("applies changes from modal and emits ISO via onChange (24h)", async () => {
const user = userEvent.setup();
const onChange = jest.fn();
const user: UserEventInstance = userEvent.setup();
const onChange: ChangeHandler = jest.fn();
render(<TimePicker value="2024-05-15T08:05:00.000Z" onChange={onChange} />);
// Open modal
await user.click(screen.getByLabelText("Hours"));
const dialog = screen.getByRole("dialog", { name: "Select time" });
const dialog: DialogElement = screen.getByRole("dialog", {
name: "Select time",
});
// Increase hours and minutes using the chevrons
const incHour = within(dialog).getByLabelText("Increase hours");
const incMin = within(dialog).getByLabelText("Increase minutes");
const incHour: ButtonElement = within(dialog).getByLabelText(
"Increase hours",
) as HTMLButtonElement;
const incMin: ButtonElement = within(dialog).getByLabelText(
"Increase minutes",
) as HTMLButtonElement;
await user.click(incHour); // 08 -> 09
await user.click(incMin); // 05 -> 06
// Apply
await user.click(within(dialog).getByRole("button", { name: "Apply" }));
await user.click(
within(dialog).getByRole("button", {
name: "Apply",
}),
);
// onChange should be called with ISO string
expect(onChange).toHaveBeenCalledTimes(1);
const emitted = onChange.mock.calls[0][0] as string;
const emittedCall: [string | undefined] | undefined =
onChange.mock.calls[0];
expect(emittedCall).toBeDefined();
const emitted: string = (emittedCall as [string])[0];
expect(typeof emitted).toBe("string");
const lib = getDateLib();
const lib: MockedDateLib = getDateLib();
// getDateWithCustomTime uses UTC hours in our mock; 9:06 maps to 09:06:00Z on the chosen date
expect(lib.getDateWithCustomTime).toHaveBeenCalledWith({ hours: 9, minutes: 6, seconds: 0 });
expect(lib.getDateWithCustomTime).toHaveBeenCalledWith({
hours: 9,
minutes: 6,
seconds: 0,
});
});
it("supports decrement wrapping for hours and minutes (24h)", async () => {
const user = userEvent.setup();
const onChange = jest.fn();
const user: UserEventInstance = userEvent.setup();
const onChange: ChangeHandler = jest.fn();
render(<TimePicker value="2024-05-15T00:00:00.000Z" onChange={onChange} />);
await user.click(screen.getByLabelText("Hours"));
const dialog = screen.getByRole("dialog", { name: "Select time" });
const dialog: DialogElement = screen.getByRole("dialog", {
name: "Select time",
});
const decHour = within(dialog).getByLabelText("Decrease hours");
const decMin = within(dialog).getByLabelText("Decrease minutes");
const decHour: ButtonElement = within(dialog).getByLabelText(
"Decrease hours",
) as HTMLButtonElement;
const decMin: ButtonElement = within(dialog).getByLabelText(
"Decrease minutes",
) as HTMLButtonElement;
// Minutes 00 -> 59 and hours 00 -> 23 when decreasing
await user.click(decMin);
await user.click(decHour);
await user.click(within(dialog).getByRole("button", { name: "Apply" }));
await user.click(
within(dialog).getByRole("button", {
name: "Apply",
}),
);
const lib = getDateLib();
const lib: MockedDateLib = getDateLib();
// dec minute first -> 00 -> 59, hours 0->23, then dec hour -> 22
expect(lib.getDateWithCustomTime).toHaveBeenCalledWith({ hours: 22, minutes: 59, seconds: 0 });
expect(lib.getDateWithCustomTime).toHaveBeenCalledWith({
hours: 22,
minutes: 59,
seconds: 0,
});
});
it("renders and operates in 12h mode with AM/PM toggles", async () => {
const user = userEvent.setup();
const lib = getDateLib();
(lib.getUserPrefers12HourFormat as jest.Mock).mockReturnValue(true);
const user: UserEventInstance = userEvent.setup();
const lib: MockedDateLib = getDateLib();
lib.getUserPrefers12HourFormat.mockReturnValue(true);
const onChange = jest.fn();
const onChange: ChangeHandler = jest.fn();
render(<TimePicker value="2024-05-15T13:45:00.000Z" onChange={onChange} />);
// Displays 01:45 PM
expect(screen.getByLabelText("Hours")).toHaveValue("01");
expect(screen.getByLabelText("Minutes")).toHaveValue("45");
// Inline AM/PM buttons have aria-label overriding the name
const apButtons = screen.getAllByRole("button", { name: "Open time selector for AM/PM" });
expect(apButtons).toHaveLength(2);
const apButtons: HTMLElement[] = screen.getAllByRole("button", {
name: "Open time selector for AM/PM",
});
expect(apButtons).toHaveLength(2);
await user.click(screen.getByLabelText("Hours"));
const dialog = screen.getByRole("dialog", { name: "Select time" });
const dialog: DialogElement = screen.getByRole("dialog", {
name: "Select time",
});
// Modal description should reflect 12h mode
expect(within(dialog).getByText(/choose hours, minutes, and AM\/PM/i)).toBeInTheDocument();
expect(
within(dialog).getByText(/choose hours, minutes, and AM\/PM/i),
).toBeInTheDocument();
// Toggle to AM and change hour input to 12 to map to 00
await user.click(within(dialog).getByRole("button", { name: /^AM$/ }));
const hourInput = within(dialog).getByLabelText("Hours");
await user.click(
within(dialog).getByRole("button", { name: /^AM$/ }) as HTMLButtonElement,
);
const hourInput: InputElement = within(dialog).getByLabelText(
"Hours",
) as InputElement;
// Change to 12
await user.clear(hourInput);
await user.type(hourInput, "12");
await user.click(within(dialog).getByRole("button", { name: "Apply" }));
await user.click(
within(dialog).getByRole("button", {
name: "Apply",
}),
);
// Should map to hours 0 in 24h
expect(lib.getDateWithCustomTime).toHaveBeenCalledWith({ hours: 0, minutes: 45, seconds: 0 });
expect(lib.getDateWithCustomTime).toHaveBeenCalledWith({
hours: 0,
minutes: 45,
seconds: 0,
});
});
it("AM/PM button mapping inside modal", async () => {
const user = userEvent.setup();
const lib = getDateLib();
(lib.getUserPrefers12HourFormat as jest.Mock).mockReturnValue(true);
const user: UserEventInstance = userEvent.setup();
const lib: MockedDateLib = getDateLib();
lib.getUserPrefers12HourFormat.mockReturnValue(true);
render(<TimePicker value="2024-05-15T01:10:00.000Z" />);
await user.click(screen.getByLabelText("Hours"));
const dialog = screen.getByRole("dialog", { name: "Select time" });
const dialog: DialogElement = screen.getByRole("dialog", {
name: "Select time",
});
// Click PM, should add 12 hours (1 -> 13)
await user.click(within(dialog).getByRole("button", { name: /^PM$/ }));
await user.click(
within(dialog).getByRole("button", { name: /^PM$/ }) as HTMLButtonElement,
);
// Increase minutes to 11 to ensure state changed
await user.click(within(dialog).getByLabelText("Increase minutes"));
await user.click(
within(dialog).getByLabelText("Increase minutes") as HTMLButtonElement,
);
await user.click(within(dialog).getByRole("button", { name: "Apply" }));
await user.click(
within(dialog).getByRole("button", {
name: "Apply",
}),
);
expect(lib.getDateWithCustomTime).toHaveBeenCalledWith({ hours: 13, minutes: 11, seconds: 0 });
expect(lib.getDateWithCustomTime).toHaveBeenCalledWith({
hours: 13,
minutes: 11,
seconds: 0,
});
});
it("quick minutes buttons set minutes", async () => {
const user = userEvent.setup();
const onChange = jest.fn();
const user: UserEventInstance = userEvent.setup();
const onChange: ChangeHandler = jest.fn();
render(<TimePicker value="2024-05-15T08:05:00.000Z" onChange={onChange} />);
await user.click(screen.getByLabelText("Hours"));
const dialog = screen.getByRole("dialog", { name: "Select time" });
const dialog: DialogElement = screen.getByRole("dialog", {
name: "Select time",
});
await user.click(within(dialog).getByRole("button", { name: "05" }));
await user.click(within(dialog).getByRole("button", { name: "Apply" }));
await user.click(
within(dialog).getByRole("button", { name: "05" }) as HTMLButtonElement,
);
await user.click(
within(dialog).getByRole("button", {
name: "Apply",
}) as HTMLButtonElement,
);
const lib = getDateLib();
expect(lib.getDateWithCustomTime).toHaveBeenCalledWith({ hours: 8, minutes: 5, seconds: 0 });
const lib: MockedDateLib = getDateLib();
expect(lib.getDateWithCustomTime).toHaveBeenCalledWith({
hours: 8,
minutes: 5,
seconds: 0,
});
});
it("respects placeholder in 24h and 12h modes", () => {
const lib = getDateLib();
(lib.getUserPrefers12HourFormat as jest.Mock).mockReturnValue(false);
const lib: MockedDateLib = getDateLib();
lib.getUserPrefers12HourFormat.mockReturnValue(false);
const { unmount } = render(<TimePicker placeholder="HH" />);
expect(screen.getByLabelText("Hours")).toHaveAttribute("placeholder", "HH");
(lib.getUserPrefers12HourFormat as jest.Mock).mockReturnValue(true);
lib.getUserPrefers12HourFormat.mockReturnValue(true);
unmount();
render(<TimePicker />);
expect(screen.getByLabelText("Hours")).toHaveAttribute("placeholder", "hh");
@@ -249,17 +408,21 @@ describe("TimePicker", () => {
expect(screen.getByTestId("error-message")).toHaveTextContent("Required");
// Error icon rendered
expect(screen.getAllByTestId("icon").some(i => i.className?.includes("text-red-500"))).toBeTruthy();
expect(
screen.getAllByTestId("icon").some((iconEl: HTMLElement) => {
return iconEl.className?.includes("text-red-500");
}),
).toBeTruthy();
});
it("calls onFocus and onBlur from the hours input", async () => {
const user = userEvent.setup();
const onFocus = jest.fn();
const onBlur = jest.fn();
const user: UserEventInstance = userEvent.setup();
const onFocus: VoidHandler = jest.fn();
const onBlur: VoidHandler = jest.fn();
render(<TimePicker onFocus={onFocus} onBlur={onBlur} />);
const hours = screen.getByLabelText("Hours");
const hours: InputElement = screen.getByLabelText("Hours") as InputElement;
await user.click(hours);
expect(onFocus).toHaveBeenCalled();
@@ -269,10 +432,12 @@ describe("TimePicker", () => {
it("updates when value prop changes", () => {
// Force 24h mode for this test to avoid bleed from prior tests
const lib = getDateLib();
(lib.getUserPrefers12HourFormat as jest.Mock).mockReturnValue(false);
const lib: MockedDateLib = getDateLib();
lib.getUserPrefers12HourFormat.mockReturnValue(false);
const { rerender } = render(<TimePicker value="2024-05-15T02:03:00.000Z" />);
const { rerender } = render(
<TimePicker value="2024-05-15T02:03:00.000Z" />,
);
expect(screen.getByLabelText("Hours")).toHaveValue("02");
expect(screen.getByLabelText("Minutes")).toHaveValue("03");
@@ -282,52 +447,84 @@ describe("TimePicker", () => {
});
it("clamps and maps hour text edits inside modal for 12h", async () => {
const user = userEvent.setup();
const lib = getDateLib();
(lib.getUserPrefers12HourFormat as jest.Mock).mockReturnValue(true);
const user: UserEventInstance = userEvent.setup();
const lib: MockedDateLib = getDateLib();
lib.getUserPrefers12HourFormat.mockReturnValue(true);
render(<TimePicker value="2024-05-15T12:00:00.000Z" />);
await user.click(screen.getByLabelText("Hours"));
const dialog = screen.getByRole("dialog", { name: "Select time" });
const dialog: DialogElement = screen.getByRole("dialog", {
name: "Select time",
});
const hourInput = within(dialog).getByLabelText("Hours");
const hourInput: InputElement = within(dialog).getByLabelText(
"Hours",
) as InputElement;
await user.clear(hourInput);
await user.type(hourInput, "99"); // should clamp to 12 in 12h mode
await user.click(within(dialog).getByRole("button", { name: "Apply" }));
await user.click(
within(dialog).getByRole("button", {
name: "Apply",
}),
);
// 12 PM stays 12 (i.e., 12 in 24h), with minutes from initial value 00
expect(lib.getDateWithCustomTime).toHaveBeenCalledWith({ hours: 12, minutes: 0, seconds: 0 });
expect(lib.getDateWithCustomTime).toHaveBeenCalledWith({
hours: 12,
minutes: 0,
seconds: 0,
});
});
it("minute text edits clamp to 0-59", async () => {
const user = userEvent.setup();
const user: UserEventInstance = userEvent.setup();
render(<TimePicker value="2024-05-15T10:10:00.000Z" />);
await user.click(screen.getByLabelText("Hours"));
const dialog = screen.getByRole("dialog", { name: "Select time" });
const dialog: DialogElement = screen.getByRole("dialog", {
name: "Select time",
});
const minInput = within(dialog).getByLabelText("Minutes");
const minInput: InputElement = within(dialog).getByLabelText(
"Minutes",
) as InputElement;
await user.clear(minInput);
await user.type(minInput, "99");
await user.click(within(dialog).getByRole("button", { name: "Apply" }));
await user.click(
within(dialog).getByRole("button", {
name: "Apply",
}),
);
const lib = getDateLib();
expect(lib.getDateWithCustomTime).toHaveBeenCalledWith({ hours: 10, minutes: 59, seconds: 0 });
const lib: MockedDateLib = getDateLib();
expect(lib.getDateWithCustomTime).toHaveBeenCalledWith({
hours: 10,
minutes: 59,
seconds: 0,
});
});
it("modal Close does not emit change or update main display", async () => {
const user = userEvent.setup();
const onChange = jest.fn();
const user: UserEventInstance = userEvent.setup();
const onChange: ChangeHandler = jest.fn();
render(<TimePicker value="2024-05-15T08:05:00.000Z" onChange={onChange} />);
// Open modal, change something, then close
await user.click(screen.getByLabelText("Hours"));
const dialog = screen.getByRole("dialog", { name: "Select time" });
await user.click(within(dialog).getByLabelText("Increase hours"));
await user.click(within(dialog).getByRole("button", { name: "Close" }));
const dialog: DialogElement = screen.getByRole("dialog", {
name: "Select time",
});
await user.click(
within(dialog).getByLabelText("Increase hours") as HTMLButtonElement,
);
await user.click(
within(dialog).getByRole("button", {
name: "Close",
}) as HTMLButtonElement,
);
// No onChange called
expect(onChange).not.toHaveBeenCalled();

View File

@@ -137,6 +137,7 @@ enum IconProp {
EyeSlash = "EyeSlash",
SquareStack3D = "SquareStack3D",
ExclaimationCircle = "ExclaimationCircle",
WhatsApp = "WhatsApp",
}
export default IconProp;

View File

@@ -0,0 +1,12 @@
import Phone from "../Phone";
export interface WhatsAppMessagePayload {
body: string;
templateKey?: string | undefined;
templateVariables?: Record<string, string> | undefined;
templateLanguageCode?: string | undefined;
}
export default interface WhatsAppMessage extends WhatsAppMessagePayload {
to: Phone;
}

View File

@@ -0,0 +1,197 @@
type TemplateIdsMap = {
readonly AlertCreated: "oneuptime_alert_created";
readonly IncidentCreated: "oneuptime_incident_created";
readonly VerificationCode: "oneuptime_verification_code";
readonly TestNotification: "oneuptime_test_notification";
readonly IncidentCreatedOwnerNotification: "oneuptime_incident_created_owner_notification";
readonly IncidentNotePostedOwnerNotification: "oneuptime_incident_note_posted_owner_notification";
readonly IncidentStateChangedOwnerNotification: "oneuptime_incident_state_change_owner_notification";
readonly IncidentOwnerAddedNotification: "oneuptime_incident_owner_added_notification";
readonly AlertCreatedOwnerNotification: "oneuptime_alert_created_owner_notification";
readonly AlertNotePostedOwnerNotification: "oneuptime_alert_note_posted_owner_notification";
readonly AlertStateChangedOwnerNotification: "oneuptime_alert_state_changed_owner_notification";
readonly AlertOwnerAddedNotification: "oneuptime_alert_owner_added_notification";
readonly MonitorOwnerAddedNotification: "oneuptime_monitor_owner_added_notification";
readonly MonitorCreatedOwnerNotification: "oneuptime_monitor_created_owner_notification";
readonly MonitorStatusChangedOwnerNotification: "oneuptime_monitor_status_changed_owner_notification";
readonly MonitorProbeStatusChangedNotification: "oneuptime_monitor_probe_status_changed_notification";
readonly MonitorNoProbesMonitoringNotification: "oneuptime_monitor_no_probes_monitoring_notification";
readonly ScheduledMaintenanceCreatedOwnerNotification: "oneuptime_scheduled_maintenance_created_owner_notification";
readonly ScheduledMaintenanceNotePostedOwnerNotification: "oneuptime_scheduled_maintenance_note_posted_owner_notification";
readonly ScheduledMaintenanceOwnerAddedNotification: "oneuptime_scheduled_maintenance_owner_added_notification";
readonly ScheduledMaintenanceStateChangedOwnerNotification: "oneuptime_scheduled_maintenance_state_changed_owner_notification";
readonly StatusPageAnnouncementCreatedOwnerNotification: "oneuptime_status_page_announcement_created_owner_notification";
readonly StatusPageCreatedOwnerNotification: "oneuptime_status_page_created_owner_notification";
readonly StatusPageOwnerAddedNotification: "oneuptime_status_page_owner_added_notification";
readonly ProbeStatusChangedOwnerNotification: "oneuptime_probe_status_changed_owner_notification";
readonly ProbeOwnerAddedNotification: "oneuptime_probe_owner_added_notification";
readonly OnCallUserIsOnRosterNotification: "oneuptime_oncall_user_is_on_roster_notification";
readonly OnCallUserIsNextNotification: "oneuptime_oncall_user_is_next_notification";
readonly OnCallUserAddedToPolicyNotification: "oneuptime_oncall_user_added_to_policy_notification";
readonly OnCallUserRemovedFromPolicyNotification: "oneuptime_oncall_user_removed_from_policy_notification";
readonly OnCallUserNoLongerActiveNotification: "oneuptime_oncall_user_no_longer_active_notification";
};
const templateIds: TemplateIdsMap = {
AlertCreated: "oneuptime_alert_created",
IncidentCreated: "oneuptime_incident_created",
VerificationCode: "oneuptime_verification_code",
TestNotification: "oneuptime_test_notification",
IncidentCreatedOwnerNotification:
"oneuptime_incident_created_owner_notification",
IncidentNotePostedOwnerNotification:
"oneuptime_incident_note_posted_owner_notification",
IncidentStateChangedOwnerNotification:
"oneuptime_incident_state_change_owner_notification",
IncidentOwnerAddedNotification: "oneuptime_incident_owner_added_notification",
AlertCreatedOwnerNotification: "oneuptime_alert_created_owner_notification",
AlertNotePostedOwnerNotification:
"oneuptime_alert_note_posted_owner_notification",
AlertStateChangedOwnerNotification:
"oneuptime_alert_state_changed_owner_notification",
AlertOwnerAddedNotification: "oneuptime_alert_owner_added_notification",
MonitorOwnerAddedNotification: "oneuptime_monitor_owner_added_notification",
MonitorCreatedOwnerNotification:
"oneuptime_monitor_created_owner_notification",
MonitorStatusChangedOwnerNotification:
"oneuptime_monitor_status_changed_owner_notification",
MonitorProbeStatusChangedNotification:
"oneuptime_monitor_probe_status_changed_notification",
MonitorNoProbesMonitoringNotification:
"oneuptime_monitor_no_probes_monitoring_notification",
ScheduledMaintenanceCreatedOwnerNotification:
"oneuptime_scheduled_maintenance_created_owner_notification",
ScheduledMaintenanceNotePostedOwnerNotification:
"oneuptime_scheduled_maintenance_note_posted_owner_notification",
ScheduledMaintenanceOwnerAddedNotification:
"oneuptime_scheduled_maintenance_owner_added_notification",
ScheduledMaintenanceStateChangedOwnerNotification:
"oneuptime_scheduled_maintenance_state_changed_owner_notification",
StatusPageAnnouncementCreatedOwnerNotification:
"oneuptime_status_page_announcement_created_owner_notification",
StatusPageCreatedOwnerNotification:
"oneuptime_status_page_created_owner_notification",
StatusPageOwnerAddedNotification:
"oneuptime_status_page_owner_added_notification",
ProbeStatusChangedOwnerNotification:
"oneuptime_probe_status_changed_owner_notification",
ProbeOwnerAddedNotification: "oneuptime_probe_owner_added_notification",
OnCallUserIsOnRosterNotification:
"oneuptime_oncall_user_is_on_roster_notification",
OnCallUserIsNextNotification: "oneuptime_oncall_user_is_next_notification",
OnCallUserAddedToPolicyNotification:
"oneuptime_oncall_user_added_to_policy_notification",
OnCallUserRemovedFromPolicyNotification:
"oneuptime_oncall_user_removed_from_policy_notification",
OnCallUserNoLongerActiveNotification:
"oneuptime_oncall_user_no_longer_active_notification",
} as const;
export const WhatsAppTemplateIds: TemplateIdsMap = templateIds;
export type WhatsAppTemplateIdsDefinition = typeof WhatsAppTemplateIds;
export type WhatsAppTemplateIdsMap = WhatsAppTemplateIdsDefinition;
export type WhatsAppTemplateId =
WhatsAppTemplateIdsDefinition[keyof WhatsAppTemplateIdsDefinition];
type WhatsAppTemplateMessagesDefinition = Readonly<
Record<WhatsAppTemplateId, string>
>;
export const WhatsAppTemplateMessages: WhatsAppTemplateMessagesDefinition = {
[WhatsAppTemplateIds.AlertCreated]: `A new alert #{{alert_number}} ({{alert_title}}) has been created for project {{project_name}}. To acknowledge this alert, open {{acknowledge_url}} to respond. For more information, please check out this alert {{alert_link}} on the OneUptime dashboard.`,
[WhatsAppTemplateIds.IncidentCreated]: `A new incident #{{incident_number}} ({{incident_title}}) has been created for project {{project_name}}. To acknowledge this incident, open {{acknowledge_url}} to respond. For more information, please check out this incident {{incident_link}} on the OneUptime dashboard.`,
[WhatsAppTemplateIds.VerificationCode]: `{{1}} is your verification code. For your security, do not share this code.`,
[WhatsAppTemplateIds.TestNotification]: `This is a WhatsApp test message from OneUptime to verify your integration. No action is required.`,
[WhatsAppTemplateIds.IncidentCreatedOwnerNotification]: `Incident #{{incident_number}} ({{incident_title}}) has been created for project {{project_name}}. View incident details using {{incident_link}} on the OneUptime dashboard for complete context.`,
[WhatsAppTemplateIds.IncidentNotePostedOwnerNotification]: `A new note was posted on incident #{{incident_number}} ({{incident_title}}). Review the incident using {{incident_link}} on the OneUptime dashboard for more context.`,
[WhatsAppTemplateIds.IncidentStateChangedOwnerNotification]: `Incident #{{incident_number}} ({{incident_title}}) state changed to {{incident_state}}. Track the incident status using {{incident_link}} on the OneUptime dashboard for more context.`,
[WhatsAppTemplateIds.IncidentOwnerAddedNotification]: `You have been added as an owner of incident #{{incident_number}} ({{incident_title}}). Manage the incident using {{incident_link}} on the OneUptime dashboard.`,
[WhatsAppTemplateIds.AlertCreatedOwnerNotification]: `Alert #{{alert_number}} ({{alert_title}}) has been created for project {{project_name}}. View alert details using {{alert_link}} on the OneUptime dashboard `,
[WhatsAppTemplateIds.AlertNotePostedOwnerNotification]: `A new note was posted on alert #{{alert_number}} ({{alert_title}}). Review the alert using {{alert_link}} on the OneUptime dashboard for updates.`,
[WhatsAppTemplateIds.AlertStateChangedOwnerNotification]: `Alert #{{alert_number}} ({{alert_title}}) state changed to {{alert_state}}. Track the alert status using {{alert_link}} on the OneUptime dashboard to stay informed.`,
[WhatsAppTemplateIds.AlertOwnerAddedNotification]: `You have been added as an owner of alert #{{alert_number}} ({{alert_title}}). Manage the alert using {{alert_link}} on the OneUptime dashboard to take action.`,
[WhatsAppTemplateIds.MonitorOwnerAddedNotification]: `You have been added as an owner of monitor {{monitor_name}}. Manage the monitor using {{monitor_link}} on the OneUptime dashboard to keep things running.`,
[WhatsAppTemplateIds.MonitorCreatedOwnerNotification]: `Monitor {{monitor_name}} has been created. Check monitor {{monitor_link}} on the OneUptime dashboard `,
[WhatsAppTemplateIds.MonitorStatusChangedOwnerNotification]: `Monitor {{monitor_name}} status changed to {{monitor_status}}. Check the monitor status using {{monitor_link}} on the OneUptime dashboard to stay informed.`,
[WhatsAppTemplateIds.MonitorProbeStatusChangedNotification]: `Probes for monitor {{monitor_name}} are {{probe_status}}. Review probe details using {{monitor_link}} on the OneUptime dashboard for more insight.`,
[WhatsAppTemplateIds.MonitorNoProbesMonitoringNotification]: `No probes are monitoring monitor {{monitor_name}}. Please check the monitor using {{monitor_link}} on the OneUptime dashboard to restore coverage.`,
[WhatsAppTemplateIds.ScheduledMaintenanceCreatedOwnerNotification]: `Scheduled maintenance #{{event_number}} ({{event_title}}) has been created. View event details using {{maintenance_link}} on the OneUptime dashboard to prepare.`,
[WhatsAppTemplateIds.ScheduledMaintenanceNotePostedOwnerNotification]: `A new note was posted on scheduled maintenance #{{event_number}} ({{event_title}}). Review the event using {{maintenance_link}} on the OneUptime dashboard for the latest updates.`,
[WhatsAppTemplateIds.ScheduledMaintenanceOwnerAddedNotification]: `You have been added as an owner of scheduled maintenance #{{event_number}} ({{event_title}}). Please check the event using {{maintenance_link}} on the OneUptime dashboard.`,
[WhatsAppTemplateIds.ScheduledMaintenanceStateChangedOwnerNotification]: `Scheduled maintenance #{{event_number}} ({{event_title}}) state changed to {{event_state}}. Track event status using {{maintenance_link}} on the OneUptime dashboard to stay aligned.`,
[WhatsAppTemplateIds.StatusPageAnnouncementCreatedOwnerNotification]: `Announcement {{announcement_title}} was published on status page {{status_page_name}}. View the announcement using {{status_page_link}} on the OneUptime dashboard `,
[WhatsAppTemplateIds.StatusPageCreatedOwnerNotification]: `Status page {{status_page_name}} has been created. View status page details using {{status_page_link}} on the OneUptime dashboard for full context.`,
[WhatsAppTemplateIds.StatusPageOwnerAddedNotification]: `You have been added as an owner of status page {{status_page_name}}. Manage the status page using {{status_page_link}} on the OneUptime dashboard to stay engaged.`,
[WhatsAppTemplateIds.ProbeStatusChangedOwnerNotification]: `Probe {{probe_name}} status is {{probe_status}}. Review the probe using {{probe_link}} on the OneUptime dashboard for specifics.`,
[WhatsAppTemplateIds.ProbeOwnerAddedNotification]: `You have been added as an owner of probe {{probe_name}}. Manage the probe using {{probe_link}} on the OneUptime dashboard to take action.`,
[WhatsAppTemplateIds.OnCallUserIsOnRosterNotification]: `You are now on-call for policy {{on_call_policy_name}} on schedule {{schedule_name}}. View the on-call schedule using {{schedule_link}} on the OneUptime dashboard to plan ahead.`,
[WhatsAppTemplateIds.OnCallUserIsNextNotification]: `You are next on-call for policy {{on_call_policy_name}} on schedule {{schedule_name}}. Prepare for your shift using {{schedule_link}} on the OneUptime dashboard for the latest details.`,
[WhatsAppTemplateIds.OnCallUserAddedToPolicyNotification]: `You have been added to on-call policy {{on_call_policy_name}} for {{on_call_context}}. Review the on-call policy using {{policy_link}} on the OneUptime dashboard for full guidelines.`,
[WhatsAppTemplateIds.OnCallUserRemovedFromPolicyNotification]: `You have been removed from on-call policy {{on_call_policy_name}} for {{on_call_context}}. View on-call policies using {{policy_link}} on the OneUptime dashboard for updates.`,
[WhatsAppTemplateIds.OnCallUserNoLongerActiveNotification]: `You are no longer on-call for policy {{on_call_policy_name}} on schedule {{schedule_name}}. Review your schedule using {{schedule_link}} on the OneUptime dashboard to stay informed.`,
};
export const WhatsAppTemplateLanguage: Record<WhatsAppTemplateId, string> = {
[WhatsAppTemplateIds.AlertCreated]: "en",
[WhatsAppTemplateIds.IncidentCreated]: "en",
[WhatsAppTemplateIds.VerificationCode]: "en",
[WhatsAppTemplateIds.TestNotification]: "en",
[WhatsAppTemplateIds.IncidentCreatedOwnerNotification]: "en",
[WhatsAppTemplateIds.IncidentNotePostedOwnerNotification]: "en",
[WhatsAppTemplateIds.IncidentStateChangedOwnerNotification]: "en",
[WhatsAppTemplateIds.IncidentOwnerAddedNotification]: "en",
[WhatsAppTemplateIds.AlertCreatedOwnerNotification]: "en",
[WhatsAppTemplateIds.AlertNotePostedOwnerNotification]: "en",
[WhatsAppTemplateIds.AlertStateChangedOwnerNotification]: "en",
[WhatsAppTemplateIds.AlertOwnerAddedNotification]: "en",
[WhatsAppTemplateIds.MonitorOwnerAddedNotification]: "en",
[WhatsAppTemplateIds.MonitorCreatedOwnerNotification]: "en",
[WhatsAppTemplateIds.MonitorStatusChangedOwnerNotification]: "en",
[WhatsAppTemplateIds.MonitorProbeStatusChangedNotification]: "en",
[WhatsAppTemplateIds.MonitorNoProbesMonitoringNotification]: "en",
[WhatsAppTemplateIds.ScheduledMaintenanceCreatedOwnerNotification]: "en",
[WhatsAppTemplateIds.ScheduledMaintenanceNotePostedOwnerNotification]: "en",
[WhatsAppTemplateIds.ScheduledMaintenanceOwnerAddedNotification]: "en",
[WhatsAppTemplateIds.ScheduledMaintenanceStateChangedOwnerNotification]: "en",
[WhatsAppTemplateIds.StatusPageAnnouncementCreatedOwnerNotification]: "en",
[WhatsAppTemplateIds.StatusPageCreatedOwnerNotification]: "en",
[WhatsAppTemplateIds.StatusPageOwnerAddedNotification]: "en",
[WhatsAppTemplateIds.ProbeStatusChangedOwnerNotification]: "en",
[WhatsAppTemplateIds.ProbeOwnerAddedNotification]: "en",
[WhatsAppTemplateIds.OnCallUserIsOnRosterNotification]: "en",
[WhatsAppTemplateIds.OnCallUserIsNextNotification]: "en",
[WhatsAppTemplateIds.OnCallUserAddedToPolicyNotification]: "en",
[WhatsAppTemplateIds.OnCallUserRemovedFromPolicyNotification]: "en",
[WhatsAppTemplateIds.OnCallUserNoLongerActiveNotification]: "en",
};
// Authentication templates that require OTP button components
export const AuthenticationTemplates: Set<WhatsAppTemplateId> = new Set([
WhatsAppTemplateIds.VerificationCode,
]);
export function renderWhatsAppTemplate(
templateId: WhatsAppTemplateId,
variables: Record<string, string>,
): string {
const template: string | undefined = WhatsAppTemplateMessages[templateId];
if (!template) {
throw new Error(`WhatsApp template ${templateId} is not defined.`);
}
return template.replace(/\{\{(.*?)\}\}/g, (_match: string, key: string) => {
if (variables[key] === undefined) {
throw new Error(
`Missing variable "${key}" for WhatsApp template ${templateId}.`,
);
}
return variables[key] as string;
});
}
export default WhatsAppTemplateMessages;

View File

@@ -0,0 +1,8 @@
enum WhatsAppStatus {
Success = "Success",
Error = "Error",
LowBalance = "Low Balance",
NotVerified = "Not Verified",
}
export default WhatsAppStatus;

View File

@@ -17,7 +17,7 @@ import Select from "../../../Types/BaseDatabase/Select";
export interface ComponentProps<TBaseModel extends BaseModel> {
modelType: { new (): TBaseModel };
modelId: ObjectID;
onDuplicateSuccess?: (item: TBaseModel) => void | undefined;
onDuplicateSuccess?: (item: TBaseModel) => Promise<void> | void;
fieldsToDuplicate: Select<TBaseModel>;
fieldsToChange: Array<ModelField<TBaseModel>>;
navigateToOnSuccess?: Route | undefined;
@@ -78,7 +78,9 @@ const DuplicateModel: <TBaseModel extends BaseModel>(
throw new Error(`Could not create ${model.singularName}`);
}
props.onDuplicateSuccess?.(newItem.data);
if (props.onDuplicateSuccess) {
await props.onDuplicateSuccess(newItem.data);
}
if (props.navigateToOnSuccess) {
Navigation.navigate(

View File

@@ -1042,6 +1042,18 @@ const Icon: FunctionComponent<ComponentProps> = ({
d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
/>,
);
} else if (icon === IconProp.WhatsApp) {
return getSvgWrapper(
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M26.576 5.363c-2.69-2.69-6.406-4.354-10.511-4.354-8.209 0-14.865 6.655-14.865 14.865 0 2.732 0.737 5.291 2.022 7.491l-0.038-0.070-2.109 7.702 7.879-2.067c2.051 1.139 4.498 1.809 7.102 1.809h0.006c8.209-0.003 14.862-6.659 14.862-14.868 0-4.103-1.662-7.817-4.349-10.507l0 0zM16.062 28.228h-0.005c-0 0-0.001 0-0.001 0-2.319 0-4.489-0.64-6.342-1.753l0.056 0.031-0.451-0.267-4.675 1.227 1.247-4.559-0.294-0.467c-1.185-1.862-1.889-4.131-1.889-6.565 0-6.822 5.531-12.353 12.353-12.353s12.353 5.531 12.353 12.353c0 6.822-5.53 12.353-12.353 12.353h-0zM22.838 18.977c-0.371-0.186-2.197-1.083-2.537-1.208-0.341-0.124-0.589-0.185-0.837 0.187-0.246 0.371-0.958 1.207-1.175 1.455-0.216 0.249-0.434 0.279-0.805 0.094-1.15-0.466-2.138-1.087-2.997-1.852l0.010 0.009c-0.799-0.74-1.484-1.587-2.037-2.521l-0.028-0.052c-0.216-0.371-0.023-0.572 0.162-0.757 0.167-0.166 0.372-0.434 0.557-0.65 0.146-0.179 0.271-0.384 0.366-0.604l0.006-0.017c0.043-0.087 0.068-0.188 0.068-0.296 0-0.131-0.037-0.253-0.101-0.357l0.002 0.003c-0.094-0.186-0.836-2.014-1.145-2.758-0.302-0.724-0.609-0.625-0.836-0.637-0.216-0.010-0.464-0.012-0.712-0.012-0.395 0.010-0.746 0.188-0.988 0.463l-0.001 0.002c-0.802 0.761-1.3 1.834-1.3 3.023 0 0.026 0 0.053 0.001 0.079l-0-0.004c0.131 1.467 0.681 2.784 1.527 3.857l-0.012-0.015c1.604 2.379 3.742 4.282 6.251 5.564l0.094 0.043c0.548 0.248 1.25 0.513 1.968 0.74l0.149 0.041c0.442 0.14 0.951 0.221 1.479 0.221 0.303 0 0.601-0.027 0.889-0.078l-0.031 0.004c1.069-0.223 1.956-0.868 2.497-1.749l0.009-0.017c0.165-0.366 0.261-0.793 0.261-1.242 0-0.185-0.016-0.366-0.047-0.542l0.003 0.019c-0.092-0.155-0.34-0.247-0.712-0.434z"
/>,
{
viewBox: "0 0 32 32",
strokeWidth: "1.2",
},
);
} else if (icon === IconProp.Hide) {
return getSvgWrapper(
<path

View File

@@ -65,11 +65,14 @@ const TimePicker: FunctionComponent<ComponentProps> = (
}, []);
// Timezone label derived from OneUptimeDate utilities (e.g., "PDT (America/Los_Angeles)" or "GMT+5:30 (Asia/Kolkata)")
const [timezoneLabel, setTimezoneLabel] = useState<string>("your local time zone");
const [timezoneLabel, setTimezoneLabel] = useState<string>(
"your local time zone",
);
useEffect((): void => {
const abbr: string = OneUptimeDate.getCurrentTimezoneString();
const iana: string = OneUptimeDate.getCurrentTimezone() as unknown as string;
const iana: string =
OneUptimeDate.getCurrentTimezone() as unknown as string;
setTimezoneLabel(`${abbr}${iana ? ` (${iana})` : ""}`);
}, []);
@@ -290,7 +293,6 @@ const TimePicker: FunctionComponent<ComponentProps> = (
submitButtonText="Apply"
>
<div className="p-2">
<div className="flex items-center justify-center gap-6">
{/* Hours selector */}
<div className="flex flex-col items-center">

View File

@@ -11,6 +11,9 @@
"babelConfig": false
}
},
"modulePathIgnorePatterns": [
"<rootDir>/build/dist"
],
"moduleNameMapper": {
"Common/(.*)": "<rootDir>/$1"
},

View File

@@ -33,7 +33,7 @@
"@opentelemetry/sdk-trace-web": "^1.25.1",
"@opentelemetry/semantic-conventions": "^1.26.0",
"@remixicon/react": "^4.2.0",
"@simplewebauthn/server": "^13.2.1",
"@simplewebauthn/server": "^13.2.2",
"@tippyjs/react": "^4.2.6",
"@types/archiver": "^6.0.3",
"@types/crypto-js": "^4.2.2",
@@ -4007,9 +4007,9 @@
}
},
"node_modules/@simplewebauthn/server": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.2.1.tgz",
"integrity": "sha512-Inmfye5opZXe3HI0GaksqBnQiM7glcNySoG6DH1GgkO1Lh9dvuV4XSV9DK02DReUVX39HpcDob9nxHELjECoQw==",
"version": "13.2.2",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.2.2.tgz",
"integrity": "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==",
"license": "MIT",
"dependencies": {
"@hexagon/base64": "^1.1.27",

View File

@@ -68,7 +68,7 @@
"@opentelemetry/sdk-trace-web": "^1.25.1",
"@opentelemetry/semantic-conventions": "^1.26.0",
"@remixicon/react": "^4.2.0",
"@simplewebauthn/server": "^13.2.1",
"@simplewebauthn/server": "^13.2.2",
"@tippyjs/react": "^4.2.6",
"@types/archiver": "^6.0.3",
"@types/crypto-js": "^4.2.2",

View File

@@ -16,6 +16,7 @@ ARG APP_VERSION
ENV GIT_SHA=${GIT_SHA}
ENV APP_VERSION=${APP_VERSION}
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
# IF APP_VERSION is not set, set it to 1.0.0

View File

@@ -17,6 +17,7 @@ ARG APP_VERSION
ENV GIT_SHA=${GIT_SHA}
ENV APP_VERSION=${APP_VERSION}
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
# IF APP_VERSION is not set, set it to 1.0.0

View File

@@ -590,7 +590,7 @@ The zip file contains the app manifest and required icons for Teams installation
text={`
##### ⏳ App Approval Status
**Please Note:** We're currently waiting for the OneUptime app to be approved by Microsoft. Once it's approved, you will be able to add it to your teams directly from the Microsoft Teams Store.
**Please Note:** We're currently waiting for the OneUptime app to be approved by Microsoft. Once it's approved, you will be able to add it to your teams directly from the Microsoft Teams Store. In the meatime, you can follow the Manual Sideloading installation steps below to get started.
##### Installation Steps (Once Approved):

View File

@@ -5,6 +5,7 @@ import SmsLogsTable from "./SmsLogsTable";
import CallLogsTable from "./CallLogsTable";
import PushLogsTable from "./PushLogsTable";
import WorkspaceLogsTable from "./WorkspaceLogsTable";
import WhatsAppLogsTable from "./WhatsAppLogsTable";
import Query from "Common/Types/BaseDatabase/Query";
import BaseModel from "Common/Types/Workflow/Components/BaseModel";
@@ -36,6 +37,10 @@ const NotificationLogsTabs: FunctionComponent<NotificationLogsTabsProps> = (
name: "SMS",
children: <SmsLogsTable {...commonProps} />,
},
{
name: "WhatsApp",
children: <WhatsAppLogsTable {...commonProps} />,
},
{
name: "Call",
children: <CallLogsTable {...commonProps} />,

View File

@@ -0,0 +1,176 @@
import React, { FunctionComponent, ReactElement, useState } from "react";
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
import WhatsAppLog from "Common/Models/DatabaseModels/WhatsAppLog";
import FieldType from "Common/UI/Components/Types/FieldType";
import Columns from "Common/UI/Components/ModelTable/Columns";
import Pill from "Common/UI/Components/Pill/Pill";
import { Green, Red } from "Common/Types/BrandColors";
import WhatsAppStatus from "Common/Types/WhatsAppStatus";
import ProjectUtil from "Common/UI/Utils/Project";
import Filter from "Common/UI/Components/ModelFilter/Filter";
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
import IconProp from "Common/Types/Icon/IconProp";
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
import Query from "Common/Types/BaseDatabase/Query";
import BaseModel from "Common/Types/Workflow/Components/BaseModel";
import UserElement from "../User/User";
import User from "Common/Models/DatabaseModels/User";
export interface WhatsAppLogsTableProps {
query?: Query<BaseModel>;
singularName?: string;
}
const WhatsAppLogsTable: FunctionComponent<WhatsAppLogsTableProps> = (
props: WhatsAppLogsTableProps,
): ReactElement => {
const [showModal, setShowModal] = useState<boolean>(false);
const [modalText, setModalText] = useState<string>("");
const [modalTitle, setModalTitle] = useState<string>("");
const defaultColumns: Columns<WhatsAppLog> = [
{
field: { toNumber: true },
title: "To",
type: FieldType.Phone,
noValueMessage: "-",
},
{
field: {
user: {
name: true,
email: true,
profilePictureId: true,
},
},
title: "User",
type: FieldType.Text,
hideOnMobile: true,
noValueMessage: "-",
getElement: (item: WhatsAppLog): ReactElement => {
if (!item["user"]) {
return <p>-</p>;
}
return <UserElement user={item["user"] as User} />;
},
},
{ field: { createdAt: true }, title: "Sent at", type: FieldType.DateTime },
{
field: { status: true },
title: "Status",
type: FieldType.Text,
getElement: (item: WhatsAppLog): ReactElement => {
if (item["status"]) {
return (
<Pill
isMinimal={false}
color={item["status"] === WhatsAppStatus.Success ? Green : Red}
text={item["status"] as string}
/>
);
}
return <></>;
},
},
];
const defaultFilters: Array<Filter<WhatsAppLog>> = [
{ field: { createdAt: true }, title: "Sent at", type: FieldType.Date },
{ field: { status: true }, title: "Status", type: FieldType.Dropdown },
];
return (
<>
<ModelTable<WhatsAppLog>
modelType={WhatsAppLog}
id={
props.singularName
? `${props.singularName.replace(/\s+/g, "-").toLowerCase()}-whatsapp-logs-table`
: "whatsapp-logs-table"
}
name="WhatsApp Logs"
isDeleteable={false}
isEditable={false}
isCreateable={false}
showViewIdButton={true}
isViewable={false}
userPreferencesKey={
props.singularName
? `${props.singularName.replace(/\s+/g, "-").toLowerCase()}-whatsapp-logs-table`
: "whatsapp-logs-table"
}
query={{
projectId: ProjectUtil.getCurrentProjectId()!,
...(props.query || {}),
}}
selectMoreFields={{
messageText: true,
statusMessage: true,
user: {
name: true,
},
}}
cardProps={{
title: "WhatsApp Logs",
description: props.singularName
? `WhatsApp messages sent for this ${props.singularName}.`
: "WhatsApp messages sent for this project.",
}}
noItemsMessage={
props.singularName
? `No WhatsApp logs for this ${props.singularName}.`
: "No WhatsApp logs."
}
showRefreshButton={true}
columns={defaultColumns}
filters={defaultFilters}
actionButtons={[
{
title: "View Message",
buttonStyleType: ButtonStyleType.NORMAL,
icon: IconProp.List,
onClick: async (
item: WhatsAppLog,
onCompleteAction: VoidFunction,
) => {
setModalText(item["messageText"] as string);
setModalTitle("WhatsApp Message");
setShowModal(true);
onCompleteAction();
},
},
{
title: "View Status Message",
buttonStyleType: ButtonStyleType.NORMAL,
icon: IconProp.Error,
onClick: async (
item: WhatsAppLog,
onCompleteAction: VoidFunction,
) => {
setModalText(item["statusMessage"] as string);
setModalTitle("Status Message");
setShowModal(true);
onCompleteAction();
},
},
]}
/>
{showModal && (
<ConfirmModal
title={modalTitle}
description={modalText || "-"}
onSubmit={() => {
setShowModal(false);
}}
submitButtonText="Close"
submitButtonType={ButtonStyleType.NORMAL}
/>
)}
</>
);
};
export default WhatsAppLogsTable;

View File

@@ -43,6 +43,15 @@ const NotificationMethodView: FunctionComponent<ComponentProps> = (
]?.toString()}
</p>
)}
{item.getColumnValue("userWhatsApp") &&
(item.getColumnValue("userWhatsApp") as JSONObject)["phone"] && (
<p>
WhatsApp:{" "}
{(item.getColumnValue("userWhatsApp") as JSONObject)[
"phone"
]?.toString()}
</p>
)}
{item.getColumnValue("userPush") &&
(item.getColumnValue("userPush") as JSONObject)["deviceName"] && (
<p>

View File

@@ -0,0 +1,298 @@
import ProjectUtil from "Common/UI/Utils/Project";
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
import HTTPResponse from "Common/Types/API/HTTPResponse";
import URL from "Common/Types/API/URL";
import { ErrorFunction, VoidFunction } from "Common/Types/FunctionTypes";
import IconProp from "Common/Types/Icon/IconProp";
import { JSONObject } from "Common/Types/JSON";
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
import BasicFormModal from "Common/UI/Components/FormModal/BasicFormModal";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
import FieldType from "Common/UI/Components/Types/FieldType";
import { APP_API_URL } from "Common/UI/Config";
import API from "Common/UI/Utils/API/API";
import User from "Common/UI/Utils/User";
import UserWhatsApp from "Common/Models/DatabaseModels/UserWhatsApp";
import React, { ReactElement, useEffect, useState } from "react";
import OneUptimeDate from "Common/Types/Date";
const WhatsApp: () => JSX.Element = (): ReactElement => {
const [showVerificationCodeModal, setShowVerificationCodeModal] =
useState<boolean>(false);
const [showResendCodeModal, setShowResendCodeModal] =
useState<boolean>(false);
const [verificationError, setVerificationError] = useState<string>("");
const [resendError, setResendError] = useState<string>("");
const [currentItem, setCurrentItem] = useState<UserWhatsApp | null>(null);
const [refreshToggle, setRefreshToggle] = useState<string>(
OneUptimeDate.getCurrentDate().toString(),
);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [showVerificationCodeResentModal, setShowVerificationCodeResentModal] =
useState<boolean>(false);
useEffect(() => {
setVerificationError("");
}, [showVerificationCodeModal]);
useEffect(() => {
if (!showResendCodeModal) {
setResendError("");
}
}, [showResendCodeModal]);
return (
<>
<ModelTable<UserWhatsApp>
modelType={UserWhatsApp}
userPreferencesKey={"user-whatsapp-table"}
query={{
projectId: ProjectUtil.getCurrentProjectId()!,
userId: User.getUserId().toString(),
}}
refreshToggle={refreshToggle}
onBeforeCreate={(model: UserWhatsApp): Promise<UserWhatsApp> => {
model.projectId = ProjectUtil.getCurrentProjectId()!;
model.userId = User.getUserId();
return Promise.resolve(model);
}}
createVerb={"Add"}
actionButtons={[
{
title: "Verify",
buttonStyleType: ButtonStyleType.SUCCESS_OUTLINE,
icon: IconProp.Check,
isVisible: (item: UserWhatsApp): boolean => {
if (item["isVerified"]) {
return false;
}
return true;
},
onClick: async (
item: UserWhatsApp,
onCompleteAction: VoidFunction,
onError: ErrorFunction,
) => {
try {
setCurrentItem(item);
setShowVerificationCodeModal(true);
setVerificationError("");
onCompleteAction();
} catch (err) {
onCompleteAction();
onError(err as Error);
}
},
},
{
title: "Resend Code",
buttonStyleType: ButtonStyleType.NORMAL,
icon: IconProp.SMS,
isVisible: (item: UserWhatsApp): boolean => {
if (item["isVerified"]) {
return false;
}
return true;
},
onClick: async (
item: UserWhatsApp,
onCompleteAction: VoidFunction,
onError: ErrorFunction,
) => {
try {
setCurrentItem(item);
setResendError("");
setShowResendCodeModal(true);
onCompleteAction();
} catch (err) {
onCompleteAction();
onError(err as Error);
}
},
},
]}
id="user-whatsapp"
name="User Settings > Notification Methods > WhatsApp"
isDeleteable={true}
isEditable={false}
isCreateable={true}
cardProps={{
title: "WhatsApp Numbers for Notifications",
description:
"Manage WhatsApp numbers that will receive notifications for this project.",
}}
noItemsMessage={
"No WhatsApp numbers found. Please add one to receive notifications."
}
formFields={[
{
field: {
phone: true,
},
title: "WhatsApp Number",
fieldType: FormFieldSchemaType.Phone,
required: true,
placeholder: "+11234567890",
validation: {
minLength: 2,
},
disableSpellCheck: true,
},
]}
showRefreshButton={true}
filters={[]}
columns={[
{
field: {
phone: true,
},
title: "WhatsApp Number",
type: FieldType.Phone,
},
{
field: {
isVerified: true,
},
title: "Verified",
type: FieldType.Boolean,
},
]}
/>
{showVerificationCodeModal && currentItem ? (
<BasicFormModal
title={"Verify WhatsApp Number"}
onClose={() => {
setShowVerificationCodeModal(false);
}}
error={verificationError}
isLoading={isLoading}
submitButtonText={"Verify"}
onSubmit={async (item: JSONObject) => {
setIsLoading(true);
setVerificationError("");
try {
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
await API.post({
url: URL.fromString(APP_API_URL.toString()).addRoute(
"/user-whatsapp/verify",
),
data: {
code: item["code"],
projectId: ProjectUtil.getCurrentProjectId()!,
itemId: currentItem["_id"],
},
});
if (response.isFailure()) {
setVerificationError(API.getFriendlyMessage(response));
setIsLoading(false);
} else {
setIsLoading(false);
setShowVerificationCodeModal(false);
setRefreshToggle(OneUptimeDate.getCurrentDate().toString());
}
} catch (e) {
setVerificationError(API.getFriendlyMessage(e));
setIsLoading(false);
}
}}
formProps={{
name: "Verify WhatsApp Number",
fields: [
{
title: "Verification Code",
description:
"We have sent a WhatsApp message with your verification code.",
field: {
code: true,
},
placeholder: "123456",
required: true,
validation: {
minLength: 6,
maxLength: 6,
},
fieldType: FormFieldSchemaType.Number,
},
],
}}
/>
) : (
<></>
)}
{showResendCodeModal && currentItem ? (
<ConfirmModal
title={`Resend Code`}
error={resendError}
description={
"Are you sure you want to resend the WhatsApp verification code?"
}
submitButtonText={"Resend Code"}
onClose={() => {
setShowResendCodeModal(false);
setResendError("");
}}
isLoading={isLoading}
onSubmit={async () => {
setIsLoading(true);
setResendError("");
try {
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
await API.post({
url: URL.fromString(APP_API_URL.toString()).addRoute(
"/user-whatsapp/resend-verification-code",
),
data: {
projectId: ProjectUtil.getCurrentProjectId()!,
itemId: currentItem["_id"],
},
});
if (response.isFailure()) {
setResendError(API.getFriendlyMessage(response));
setIsLoading(false);
} else {
setIsLoading(false);
setShowResendCodeModal(false);
setShowVerificationCodeResentModal(true);
}
} catch (err) {
setResendError(API.getFriendlyMessage(err));
setIsLoading(false);
}
}}
/>
) : (
<></>
)}
{showVerificationCodeResentModal ? (
<ConfirmModal
title={`Code sent successfully`}
error={resendError}
description={`We have sent a verification code via WhatsApp.`}
submitButtonText={"Close"}
onSubmit={async () => {
setShowVerificationCodeResentModal(false);
setResendError("");
}}
/>
) : (
<></>
)}
</>
);
};
export default WhatsApp;

View File

@@ -257,7 +257,6 @@ const OnCallDutyScheduleView: FunctionComponent<
onCallDutyPolicyScheduleId={modelId}
projectId={ProjectUtil.getCurrentProjectId() as ObjectID}
/>
</Fragment>
);
};

View File

@@ -1,16 +1,178 @@
import DuplicateModel from "Common/UI/Components/DuplicateModel/DuplicateModel";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import OnCallDutySchedule from "Common/Models/DatabaseModels/OnCallDutyPolicySchedule";
import PageComponentProps from "../../PageComponentProps";
import OnCallDutyPolicyScheduleLayer from "Common/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer";
import OnCallDutyPolicyScheduleLayerUser from "Common/Models/DatabaseModels/OnCallDutyPolicyScheduleLayerUser";
import Route from "Common/Types/API/Route";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
import ObjectID from "Common/Types/ObjectID";
import API from "Common/UI/Utils/API/API";
import ModelAPI, { type ListResult } from "Common/UI/Utils/ModelAPI/ModelAPI";
import Navigation from "Common/UI/Utils/Navigation";
import ProjectUtil from "Common/UI/Utils/Project";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import PageMap from "../../../Utils/PageMap";
import PageComponentProps from "../../PageComponentProps";
import React, { Fragment, FunctionComponent, ReactElement } from "react";
const OnCallDutyScheduleSettings: FunctionComponent<PageComponentProps> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID();
const OnCallDutyScheduleSettings: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const duplicateScheduleLayers: (
newSchedule: OnCallDutySchedule,
) => Promise<void> = async (
newSchedule: OnCallDutySchedule,
): Promise<void> => {
const projectId: ObjectID | null =
newSchedule.projectId || ProjectUtil.getCurrentProjectId();
if (!newSchedule.id) {
throw new Error(
"Failed to duplicate schedule layers: new schedule ID is missing.",
);
}
if (!projectId) {
throw new Error(
"Failed to duplicate schedule layers: project ID is missing.",
);
}
try {
const existingLayers: ListResult<OnCallDutyPolicyScheduleLayer> =
await ModelAPI.getList<OnCallDutyPolicyScheduleLayer>({
modelType: OnCallDutyPolicyScheduleLayer,
query: {
onCallDutyPolicyScheduleId: modelId,
projectId: projectId,
},
limit: LIMIT_PER_PROJECT,
skip: 0,
select: {
_id: true,
order: true,
name: true,
description: true,
startsAt: true,
restrictionTimes: true,
rotation: true,
handOffTime: true,
},
sort: {
order: SortOrder.Ascending,
},
});
if (existingLayers.data.length === 0) {
return;
}
for (const layer of existingLayers.data) {
if (!layer.id) {
continue;
}
const newLayer: OnCallDutyPolicyScheduleLayer =
new OnCallDutyPolicyScheduleLayer();
newLayer.projectId = projectId;
newLayer.onCallDutyPolicyScheduleId = newSchedule.id;
if (!layer.name) {
throw new Error(
"Failed to duplicate schedule layers: layer name is missing.",
);
}
newLayer.name = layer.name;
if (layer.description !== undefined) {
newLayer.description = layer.description;
}
if (typeof layer.order === "number") {
newLayer.order = layer.order;
}
if (!layer.startsAt) {
throw new Error(
"Failed to duplicate schedule layers: layer start time is missing.",
);
}
newLayer.startsAt = new Date(layer.startsAt);
if (!layer.handOffTime) {
throw new Error(
"Failed to duplicate schedule layers: layer hand off time is missing.",
);
}
newLayer.handOffTime = new Date(layer.handOffTime);
if (layer.rotation) {
newLayer.rotation = layer.rotation;
}
if (layer.restrictionTimes) {
newLayer.restrictionTimes = layer.restrictionTimes;
}
const createdLayer: OnCallDutyPolicyScheduleLayer = (
await ModelAPI.create<OnCallDutyPolicyScheduleLayer>({
model: newLayer,
modelType: OnCallDutyPolicyScheduleLayer,
})
).data as OnCallDutyPolicyScheduleLayer;
if (!createdLayer.id) {
continue;
}
const existingLayerUsers: ListResult<OnCallDutyPolicyScheduleLayerUser> =
await ModelAPI.getList<OnCallDutyPolicyScheduleLayerUser>({
modelType: OnCallDutyPolicyScheduleLayerUser,
query: {
onCallDutyPolicyScheduleId: modelId,
onCallDutyPolicyScheduleLayerId: layer.id,
projectId: projectId,
},
limit: LIMIT_PER_PROJECT,
skip: 0,
select: {
_id: true,
order: true,
userId: true,
},
sort: {
order: SortOrder.Ascending,
},
});
for (const existingLayerUser of existingLayerUsers.data) {
if (!existingLayerUser.userId) {
continue;
}
const newLayerUser: OnCallDutyPolicyScheduleLayerUser =
new OnCallDutyPolicyScheduleLayerUser();
newLayerUser.projectId = projectId;
newLayerUser.onCallDutyPolicyScheduleId = newSchedule.id;
newLayerUser.onCallDutyPolicyScheduleLayerId = createdLayer.id;
newLayerUser.userId = existingLayerUser.userId;
if (typeof existingLayerUser.order === "number") {
newLayerUser.order = existingLayerUser.order;
}
await ModelAPI.create<OnCallDutyPolicyScheduleLayerUser>({
model: newLayerUser,
modelType: OnCallDutyPolicyScheduleLayerUser,
});
}
}
} catch (err) {
throw new Error(
`Failed to duplicate schedule layers: ${API.getFriendlyMessage(err)}`,
);
}
};
return (
<Fragment>
@@ -21,7 +183,9 @@ const OnCallDutyScheduleSettings: FunctionComponent<PageComponentProps> = (): Re
fieldsToDuplicate={{
description: true,
labels: true,
projectId: true,
}}
onDuplicateSuccess={duplicateScheduleLayers}
navigateToOnSuccess={RouteUtil.populateRouteParams(
RouteMap[PageMap.ON_CALL_DUTY_SCHEDULES] as Route,
)}

View File

@@ -80,7 +80,8 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
name="Enable Notifications"
cardProps={{
title: "Enable Notifications",
description: "Enable Call and SMS notifications for this project.",
description:
"Enable Call, SMS, and WhatsApp notifications for this project.",
}}
isEditable={true}
editButtonText="Edit Notification Settings"
@@ -95,6 +96,16 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
fieldType: FormFieldSchemaType.Toggle,
required: false,
},
{
field: {
enableWhatsAppNotifications: true,
},
title: "Enable WhatsApp Notifications",
description:
"Enable WhatsApp notifications for this project. This will be used for alerting users via WhatsApp.",
fieldType: FormFieldSchemaType.Toggle,
required: false,
},
{
field: {
enableSmsNotifications: true,
@@ -120,6 +131,16 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
description:
"Enable Call notifications for this project. This will be used for alerting users by phone call.",
},
{
field: {
enableWhatsAppNotifications: true,
},
fieldType: FieldType.Boolean,
title: "Enable WhatsApp Notifications",
placeholder: "Not Enabled",
description:
"Enable WhatsApp notifications for this project. This will be used for alerting users via WhatsApp.",
},
{
field: {
enableSmsNotifications: true,

View File

@@ -23,6 +23,7 @@ import UserEmail from "Common/Models/DatabaseModels/UserEmail";
import UserNotificationRule from "Common/Models/DatabaseModels/UserNotificationRule";
import UserPush from "Common/Models/DatabaseModels/UserPush";
import UserSMS from "Common/Models/DatabaseModels/UserSMS";
import UserWhatsApp from "Common/Models/DatabaseModels/UserWhatsApp";
import React, {
Fragment,
FunctionComponent,
@@ -39,6 +40,7 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
);
const [userEmails, setUserEmails] = useState<Array<UserEmail>>([]);
const [userSMSs, setUserSMSs] = useState<Array<UserSMS>>([]);
const [userWhatsApps, setUserWhatsApps] = useState<Array<UserWhatsApp>>([]);
const [userCalls, setUserCalls] = useState<Array<UserCall>>([]);
const [userPush, setUserPush] = useState<Array<UserPush>>([]);
const [
@@ -131,6 +133,19 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
if (userPushDevice) {
model.userPushId = userPushDevice.id!;
}
const userWhatsApp: UserWhatsApp | undefined = userWhatsApps.find(
(userWhatsApp: UserWhatsApp) => {
return (
userWhatsApp.id!.toString() ===
miscDataProps["notificationMethod"]?.toString()
);
},
);
if (userWhatsApp) {
model.userWhatsAppId = userWhatsApp.id!;
}
}
return Promise.resolve(model);
@@ -189,6 +204,9 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
deviceName: true,
deviceType: true,
},
userWhatsApp: {
phone: true,
},
}}
filters={[]}
columns={[
@@ -197,9 +215,18 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
userCall: {
phone: true,
},
userEmail: {
email: true,
},
userSms: {
phone: true,
},
userPush: {
deviceName: true,
},
userWhatsApp: {
phone: true,
},
},
title: "Notification Method",
type: FieldType.Text,
@@ -324,6 +351,25 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
setUserPush(userPushDevices.data);
const userWhatsAppList: ListResult<UserWhatsApp> = await ModelAPI.getList(
{
modelType: UserWhatsApp,
query: {
projectId: ProjectUtil.getCurrentProjectId()!,
userId: User.getUserId(),
isVerified: true,
},
limit: LIMIT_PER_PROJECT,
skip: 0,
select: {
phone: true,
},
sort: {},
},
);
setUserWhatsApps(userWhatsAppList.data);
setAlertSeverities(alertSeverities.data);
const dropdownOptions: Array<DropdownOption> = [
@@ -331,10 +377,12 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
...userEmails.data,
...userSMSes.data,
...userPushDevices.data,
...userWhatsAppList.data,
].map((model: BaseModel) => {
const isUserCall: boolean = model instanceof UserCall;
const isUserSms: boolean = model instanceof UserSMS;
const isUserPush: boolean = model instanceof UserPush;
const isUserWhatsApp: boolean = model instanceof UserWhatsApp;
let option: DropdownOption;
@@ -358,6 +406,8 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
option.label = "Call: " + option.label;
} else if (isUserSms) {
option.label = "SMS: " + option.label;
} else if (isUserWhatsApp) {
option.label = "WhatsApp: " + option.label;
} else {
option.label = "Email: " + option.label;
}

View File

@@ -23,6 +23,7 @@ import UserEmail from "Common/Models/DatabaseModels/UserEmail";
import UserNotificationRule from "Common/Models/DatabaseModels/UserNotificationRule";
import UserPush from "Common/Models/DatabaseModels/UserPush";
import UserSMS from "Common/Models/DatabaseModels/UserSMS";
import UserWhatsApp from "Common/Models/DatabaseModels/UserWhatsApp";
import React, {
Fragment,
FunctionComponent,
@@ -39,6 +40,7 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
>([]);
const [userEmails, setUserEmails] = useState<Array<UserEmail>>([]);
const [userSMSs, setUserSMSs] = useState<Array<UserSMS>>([]);
const [userWhatsApps, setUserWhatsApps] = useState<Array<UserWhatsApp>>([]);
const [userCalls, setUserCalls] = useState<Array<UserCall>>([]);
const [userPush, setUserPush] = useState<Array<UserPush>>([]);
const [
@@ -131,6 +133,19 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
if (userPushDevice) {
model.userPushId = userPushDevice.id!;
}
const userWhatsApp: UserWhatsApp | undefined = userWhatsApps.find(
(userWhatsApp: UserWhatsApp) => {
return (
userWhatsApp.id!.toString() ===
miscDataProps["notificationMethod"]?.toString()
);
},
);
if (userWhatsApp) {
model.userWhatsAppId = userWhatsApp.id!;
}
}
return Promise.resolve(model);
@@ -189,6 +204,9 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
deviceName: true,
deviceType: true,
},
userWhatsApp: {
phone: true,
},
}}
filters={[]}
columns={[
@@ -207,6 +225,9 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
deviceName: true,
deviceType: true,
},
userWhatsApp: {
phone: true,
},
},
title: "Notification Method",
type: FieldType.Text,
@@ -330,6 +351,25 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
setUserPush(userPushDevices.data);
const userWhatsAppList: ListResult<UserWhatsApp> = await ModelAPI.getList(
{
modelType: UserWhatsApp,
query: {
projectId: ProjectUtil.getCurrentProjectId()!,
userId: User.getUserId(),
isVerified: true,
},
limit: LIMIT_PER_PROJECT,
skip: 0,
select: {
phone: true,
},
sort: {},
},
);
setUserWhatsApps(userWhatsAppList.data);
setIncidentSeverities(incidentSeverities.data);
const dropdownOptions: Array<DropdownOption> = [
@@ -337,10 +377,12 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
...userEmails.data,
...userSMSes.data,
...userPushDevices.data,
...userWhatsAppList.data,
].map((model: BaseModel) => {
const isUserCall: boolean = model instanceof UserCall;
const isUserSms: boolean = model instanceof UserSMS;
const isUserPush: boolean = model instanceof UserPush;
const isUserWhatsApp: boolean = model instanceof UserWhatsApp;
let option: DropdownOption;
@@ -364,6 +406,8 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
option.label = "Call: " + option.label;
} else if (isUserSms) {
option.label = "SMS: " + option.label;
} else if (isUserWhatsApp) {
option.label = "WhatsApp: " + option.label;
} else {
option.label = "Email: " + option.label;
}

View File

@@ -1,7 +1,8 @@
import UserCall from "../../Components/NotificationMethods/Call";
import UserEmail from "../../Components/NotificationMethods/Email";
import UserSMS from "../../Components/NotificationMethods/SMS";
import UserPush from "../../Components/NotificationMethods/Push";
import UserSMS from "../../Components/NotificationMethods/SMS";
import UserWhatsApp from "../../Components/NotificationMethods/WhatsApp";
import PageComponentProps from "../PageComponentProps";
import React, { Fragment, FunctionComponent, ReactElement } from "react";
@@ -10,6 +11,7 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
<Fragment>
<UserEmail />
<UserSMS />
<UserWhatsApp />
<UserCall />
<UserPush />
</Fragment>

View File

@@ -10,6 +10,17 @@ import UserNotificationSetting from "Common/Models/DatabaseModels/UserNotificati
import React, { Fragment, FunctionComponent, ReactElement } from "react";
import Includes from "Common/Types/BaseDatabase/Includes";
import { ShowAs } from "Common/UI/Components/ModelTable/BaseModelTable";
import Icon, { SizeProp } from "Common/UI/Components/Icon/Icon";
import IconProp from "Common/Types/Icon/IconProp";
import Color from "Common/Types/Color";
import {
Blue500,
Gray500,
Green500,
Orange500,
Purple500,
Sky500,
} from "Common/Types/BrandColors";
const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
type GetModelTableFunctionProps = {
@@ -87,6 +98,15 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
fieldType: FormFieldSchemaType.Toggle,
required: false,
},
{
field: {
alertByWhatsApp: true,
},
title: "Alert By WhatsApp",
description: "Select if you want to be alerted by WhatsApp.",
fieldType: FormFieldSchemaType.Toggle,
required: false,
},
{
field: {
alertByCall: true,
@@ -109,6 +129,12 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
]}
showRefreshButton={true}
filters={[]}
selectMoreFields={{
alertBySMS: true,
alertByWhatsApp: true,
alertByCall: true,
alertByPush: true,
}}
columns={[
{
field: {
@@ -121,29 +147,86 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
field: {
alertByEmail: true,
},
title: "Email Alerts",
type: FieldType.Boolean,
},
{
field: {
alertBySMS: true,
title: "Delivery Channels",
type: FieldType.Element,
getElement: (item: UserNotificationSetting): ReactElement => {
type ChannelDescriptor = {
key: string;
label: string;
enabled: boolean;
icon: IconProp;
color: Color;
};
const channels: Array<ChannelDescriptor> = [
{
key: "email",
label: "Email",
enabled: Boolean(item.alertByEmail),
icon: IconProp.Email,
color: Blue500,
},
{
key: "sms",
label: "SMS",
enabled: Boolean(item.alertBySMS),
icon: IconProp.SMS,
color: Purple500,
},
{
key: "call",
label: "Call",
enabled: Boolean(item.alertByCall),
icon: IconProp.Call,
color: Orange500,
},
{
key: "push",
label: "Push",
enabled: Boolean(item.alertByPush),
icon: IconProp.Notification,
color: Sky500,
},
{
key: "whatsapp",
label: "WhatsApp",
enabled: Boolean(item.alertByWhatsApp),
icon: IconProp.WhatsApp,
color: Green500,
},
];
return (
<div className="flex flex-wrap gap-2 mt-2">
{channels.map((channel: ChannelDescriptor) => {
const stateClasses: string = channel.enabled
? "border-emerald-500/60 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-200"
: "border-gray-200 bg-gray-100 text-gray-500 dark:border-gray-600 dark:bg-gray-700/60 dark:text-gray-300";
return (
<span
key={channel.key}
aria-label={`${channel.label} alerts ${channel.enabled ? "enabled" : "disabled"}`}
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium transition-colors ${stateClasses}`}
>
<Icon
icon={channel.icon}
className="h-3 w-3"
size={SizeProp.Small}
color={channel.enabled ? channel.color : Gray500}
/>
<span>{channel.label}</span>
<span
className={`text-[10px] font-semibold uppercase tracking-wide ${channel.enabled ? "text-emerald-600 dark:text-emerald-200" : "text-gray-400 dark:text-gray-400"}`}
>
{channel.enabled ? "On" : "Off"}
</span>
</span>
);
})}
</div>
);
},
title: "SMS Alerts",
type: FieldType.Boolean,
},
{
field: {
alertByCall: true,
},
title: "Call Alerts",
type: FieldType.Boolean,
},
{
field: {
alertByPush: true,
},
title: "Push Alerts",
type: FieldType.Boolean,
},
]}
/>

View File

@@ -60,6 +60,9 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
userPush: {
deviceName: true,
},
userWhatsApp: {
phone: true,
},
}}
noItemsMessage={"No notifications sent out so far."}
showRefreshButton={true}
@@ -112,6 +115,18 @@ const Settings: FunctionComponent<PageComponentProps> = (): ReactElement => {
userCall: {
phone: true,
},
userEmail: {
email: true,
},
userSms: {
phone: true,
},
userPush: {
deviceName: true,
},
userWhatsApp: {
phone: true,
},
},
title: "Notification Method",
type: FieldType.Element,

View File

@@ -0,0 +1,6 @@
We're building the OneUptime mobile app in react native and expo. This mobile app is in MobileApp diretctory.
<PROMPT>
Please make sure the code you write is clean, refactored well and not duplicated. If you like to refactor code into seperate files, please use as many files as you like.
Please make sure to use typescript and type all the variables and functions well. Please do not use "any" type. Please make sure the files you areate are PascalCase and not camelCase.

View File

@@ -17,6 +17,7 @@ ARG APP_VERSION
ENV GIT_SHA=${GIT_SHA}
ENV APP_VERSION=${APP_VERSION}
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
# IF APP_VERSION is not set, set it to 1.0.0

View File

@@ -7,6 +7,7 @@ import Express, {
ExpressRequest,
ExpressResponse,
ExpressStatic,
NextFunction,
} from "Common/Server/Utils/Express";
import Response from "Common/Server/Utils/Response";
import LocalFile from "Common/Server/Utils/LocalFile";
@@ -25,7 +26,7 @@ const DocsFeatureSet: FeatureSet = {
// Handle requests to specific documentation pages
app.get(
"/docs/as-markdown/:categorypath/:pagepath",
async (req: ExpressRequest, res: ExpressResponse) => {
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
const fullPath: string =
`${req.params["categorypath"]}/${req.params["pagepath"]}`.toLowerCase();
@@ -38,15 +39,18 @@ const DocsFeatureSet: FeatureSet = {
return Response.sendMarkdownResponse(req, res, contentInMarkdown);
} catch (err) {
logger.error(err);
res.status(500);
return res.send("Internal Server Error");
return next(err);
}
},
);
app.get(
"/docs/:categorypath/:pagepath",
async (_req: ExpressRequest, res: ExpressResponse) => {
async (
_req: ExpressRequest,
res: ExpressResponse,
next: NextFunction,
) => {
try {
const fullPath: string =
`${_req.params["categorypath"]}/${_req.params["pagepath"]}`.toLowerCase();
@@ -114,12 +118,7 @@ const DocsFeatureSet: FeatureSet = {
});
} catch (err) {
logger.error(err);
res.status(500);
return res.render(`${ViewsPath}/ServerError`, {
nav: DocsNav,
enableGoogleTagManager: IsBillingEnabled,
link: "",
});
return next(err);
}
},
);

View File

@@ -16,6 +16,7 @@ ARG APP_VERSION
ENV GIT_SHA=${GIT_SHA}
ENV APP_VERSION=${APP_VERSION}
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
ENV NODE_OPTIONS="--use-openssl-ca"
## Add Intermediate Certs
@@ -59,7 +60,7 @@ ENV PRODUCTION=true
WORKDIR /usr/src/app
RUN npx playwright install --with-deps
RUN PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=0 npx playwright install --with-deps
# Install app dependencies
COPY ./FluentIngest/package*.json /usr/src/app/

21
HelmChart/Docs/GitHub.md Normal file
View File

@@ -0,0 +1,21 @@
# GitHub Ops
### Cancel all GitHub actions jobs at once.
Please install GitHub CLI and run the below command in your terminal.
```
brew install gh
```
Authenticate with your GitHub account
```
gh auth login
````
Then run the below command in your terminal
```
for id in $(gh run list --limit 5000 --jq ".[] | select (.status == \"queued\" ) | .databaseId" --json databaseId,status); do gh run cancel $id; done
```

View File

@@ -110,6 +110,8 @@ spec:
value: {{ $.Values.billing.callHighRiskValueInCentsPerMinute | quote }}
- name: SMS_DEFAULT_COST_IN_CENTS
value: {{ $.Values.billing.smsDefaultValueInCents | quote }}
- name: WHATSAPP_TEXT_DEFAULT_COST_IN_CENTS
value: {{ $.Values.billing.whatsAppTextDefaultValueInCents | quote }}
- name: CALL_DEFAULT_COST_IN_CENTS_PER_MINUTE
value: {{ $.Values.billing.callDefaultValueInCentsPerMinute | quote }}
- name: DISABLE_TELEMETRY

View File

@@ -582,6 +582,9 @@
"smsDefaultValueInCents": {
"type": ["integer", "null"]
},
"whatsAppTextDefaultValueInCents": {
"type": ["integer", "null"]
},
"callDefaultValueInCentsPerMinute": {
"type": ["integer", "null"]
},

View File

@@ -225,6 +225,7 @@ billing:
publicKey:
privateKey:
smsDefaultValueInCents:
whatsAppTextDefaultValueInCents:
callDefaultValueInCentsPerMinute:
smsHighRiskValueInCents:
callHighRiskValueInCentsPerMinute:

View File

@@ -1,12 +1,12 @@
import BlogPostUtil, { BlogPost, BlogPostHeader } from "../Utils/BlogPost";
import { BlogRootPath, ViewsPath } from "../Utils/Config";
import NotFoundUtil from "../Utils/NotFound";
import ServerErrorUtil from "../Utils/ServerError";
import Text from "Common/Types/Text";
import Express, {
ExpressApplication,
ExpressRequest,
ExpressResponse,
NextFunction,
} from "Common/Server/Utils/Express";
import logger from "Common/Server/Utils/Logger";
import Response from "Common/Server/Utils/Response";
@@ -19,7 +19,7 @@ const app: ExpressApplication = Express.getExpressApp();
app.get(
"/blog/post/:file",
async (req: ExpressRequest, res: ExpressResponse) => {
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
const fileName: string = req.params["file"] as string;
@@ -30,14 +30,14 @@ app.get(
);
} catch (e) {
logger.error(e);
return ServerErrorUtil.renderServerError(res);
return next(e);
}
},
);
app.get(
"/blog/post/:file/view",
async (req: ExpressRequest, res: ExpressResponse) => {
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
const fileName: string = req.params["file"] as string;
@@ -59,14 +59,14 @@ app.get(
});
} catch (e) {
logger.error(e);
return ServerErrorUtil.renderServerError(res);
return next(e);
}
},
);
app.get(
"/blog/post/:postName/:fileName",
async (req: ExpressRequest, res: ExpressResponse) => {
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
/*
* return static files for blog post images
* the static files are stored in the /usr/src/blog/post/:file/:imageName
@@ -83,7 +83,7 @@ app.get(
);
} catch (e) {
logger.error(e);
return ServerErrorUtil.renderServerError(res);
return next(e);
}
},
);
@@ -92,7 +92,7 @@ app.get(
app.get(
"/blog/tag/:tagName",
async (req: ExpressRequest, res: ExpressResponse) => {
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
const tagName: string = req.params["tagName"] as string;
const tagSlug: string = tagName; // original slug
@@ -149,64 +149,67 @@ app.get(
});
} catch (e) {
logger.error(e);
return ServerErrorUtil.renderServerError(res);
return next(e);
}
},
);
// main blog page
app.get("/blog", async (_req: ExpressRequest, res: ExpressResponse) => {
try {
const req: ExpressRequest = _req; // alias for clarity
const pageParam: string | undefined = req.query["page"] as
| string
| undefined;
const pageSizeParam: string | undefined = req.query["pageSize"] as
| string
| undefined;
let page: number = pageParam ? parseInt(pageParam, 10) : 1;
let pageSize: number = pageSizeParam ? parseInt(pageSizeParam, 10) : 24;
if (isNaN(page) || page < 1) {
page = 1;
}
if (isNaN(pageSize) || pageSize < 1) {
pageSize = 24;
}
if (pageSize > 100) {
pageSize = 100;
}
app.get(
"/blog",
async (_req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
const req: ExpressRequest = _req; // alias for clarity
const pageParam: string | undefined = req.query["page"] as
| string
| undefined;
const pageSizeParam: string | undefined = req.query["pageSize"] as
| string
| undefined;
let page: number = pageParam ? parseInt(pageParam, 10) : 1;
let pageSize: number = pageSizeParam ? parseInt(pageSizeParam, 10) : 24;
if (isNaN(page) || page < 1) {
page = 1;
}
if (isNaN(pageSize) || pageSize < 1) {
pageSize = 24;
}
if (pageSize > 100) {
pageSize = 100;
}
const allPosts: Array<BlogPostHeader> =
await BlogPostUtil.getBlogPostList();
const totalPosts: number = allPosts.length;
const totalPages: number = Math.ceil(totalPosts / pageSize) || 1;
if (page > totalPages) {
page = totalPages;
}
const start: number = (page - 1) * pageSize;
const paginatedPosts: Array<BlogPostHeader> = allPosts.slice(
start,
start + pageSize,
);
const allTags: Array<string> = await BlogPostUtil.getTags();
const allPosts: Array<BlogPostHeader> =
await BlogPostUtil.getBlogPostList();
const totalPosts: number = allPosts.length;
const totalPages: number = Math.ceil(totalPosts / pageSize) || 1;
if (page > totalPages) {
page = totalPages;
}
const start: number = (page - 1) * pageSize;
const paginatedPosts: Array<BlogPostHeader> = allPosts.slice(
start,
start + pageSize,
);
const allTags: Array<string> = await BlogPostUtil.getTags();
res.render(`${ViewsPath}/Blog/List`, {
support: false,
footerCards: true,
cta: true,
blackLogo: false,
requestDemoCta: false,
blogPosts: paginatedPosts,
page: page,
pageSize: pageSize,
totalPages: totalPages,
totalPosts: totalPosts,
basePath: `/blog`,
allTags: allTags,
enableGoogleTagManager: IsBillingEnabled,
});
} catch (e) {
logger.error(e);
return ServerErrorUtil.renderServerError(res);
}
});
res.render(`${ViewsPath}/Blog/List`, {
support: false,
footerCards: true,
cta: true,
blackLogo: false,
requestDemoCta: false,
blogPosts: paginatedPosts,
page: page,
pageSize: pageSize,
totalPages: totalPages,
totalPosts: totalPosts,
basePath: `/blog`,
allTags: allTags,
enableGoogleTagManager: IsBillingEnabled,
});
} catch (e) {
logger.error(e);
return next(e);
}
},
);

Some files were not shown because too many files have changed in this diff Show More