Compare commits

..

149 Commits

Author SHA1 Message Date
Allan Carr
159ee7364d IO-3187 Admin Enhancements
add BCC

Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-15 22:08:21 -07:00
Dave Richer
f2a896d568 feature/IO-3187-Admin-Enhancements - Minor cleanup 2025-04-15 14:02:29 -04:00
Dave Richer
546ebba0bd feature/IO-3187-Admin-Enhancements - Minor cleanup 2025-04-15 13:57:50 -04:00
Allan Carr
b5cb520944 IO-3187 Admin Enhancements
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-14 17:07:57 -07:00
Allan Carr
6814a3bc33 Merge branch 'master-AIO' into feature/IO-3187-Admin-Enhancements
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-14 16:57:20 -07:00
Dave Richer
e015d3574a Merged in release/2025-04-11 (pull request #2267)
Release/2025-04-11 into master-AIO - IO-2769, IO-2885, IO-3045, IO-3066, IO-3193, IO-3198, IO-3202
2025-04-12 01:27:08 +00:00
Dave Richer
60140902d4 Merged in feature/IO-3045-Product-Fruits-Tours-Modifications (pull request #2265)
feature/IO-3045-Product-Fruits-Tours-Modifications - Add in roles prop on Product fruits, tie the usage of it to a new db bodyshop var tours_enabled.
2025-04-10 16:28:21 +00:00
Dave Richer
84f41b2c11 feature/IO-3045-Product-Fruits-Tours-Modifications - Add in roles prop on Product fruits, tie the usage of it to a new db bodyshop var tours_enabled. 2025-04-10 12:27:54 -04:00
Dave Richer
282fa787a9 Merged in feature/IO-2769-Job-Totals-Testing (pull request #2263)
feature/IO-2769-Job-Totals-Testing - packages, final update
2025-04-09 16:53:04 +00:00
Dave Richer
037efff81c feature/IO-2769-Job-Totals-Testing - packages, final update 2025-04-09 12:52:23 -04:00
Patrick Fic
e26eb17d09 Merged in feature/IO-3066-ems-parts-order-data (pull request #2262)
IO-3066 Add additional CIECA fields for new partner

Approved-by: Patrick Fic
2025-04-09 16:34:42 +00:00
Patrick Fic
fbea9fde27 IO-3066 Add additional CIECA fields for new partner 2025-04-09 09:33:37 -07:00
Dave Richer
ce7cf6bdbe Merged in feature/IO-2769-Job-Totals-Testing (pull request #2260)
feature/IO-2769-Job-Totals-Testing - updated tests
2025-04-09 15:19:09 +00:00
Dave Richer
2c47e5d852 feature/IO-2769-Job-Totals-Testing - updated tests 2025-04-09 11:18:09 -04:00
Dave Richer
a6f809b20a Merged in feature/IO-2769-Job-Totals-Testing (pull request #2258)
feature/IO-2769-Job-Totals-Testing - non-related
2025-04-08 22:14:52 +00:00
Dave Richer
2bcad68351 feature/IO-2769-Job-Totals-Testing - non-related 2025-04-08 18:14:20 -04:00
Dave Richer
6b1b393804 Merged in feature/IO-2769-Job-Totals-Testing (pull request #2256)
Feature/IO-2769 Job Totals Testing
2025-04-08 22:11:26 +00:00
Dave Richer
c5181d1c5d feature/IO-2769-Job-Totals-Testing - non-related 2025-04-08 18:10:28 -04:00
Dave Richer
e33ff2a45d feature/IO-2769-Job-Totals-Testing - cleanup 2025-04-08 17:04:50 -04:00
Allan Carr
9eb77964db Merged in feature/IO-3202-HasFeatureAccess-Boolean (pull request #2250)
IO-3202 HasFeatureAccess Boolean

Approved-by: Dave Richer
2025-04-08 17:17:50 +00:00
Allan Carr
0a68d2791d IO-3202 HasFeatureAccess Boolean
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-08 09:51:52 -07:00
Dave Richer
11928d9a7e Merged in feature/IO-2769-Job-Totals-Testing (pull request #2253)
Feature/IO-2769 Job Totals Testing
2025-04-08 16:23:34 +00:00
Dave Richer
c169bb5d5d feature/IO-2769-Job-Totals-Testing - update end point 2025-04-08 12:19:33 -04:00
Dave Richer
3cc4f1c63e feature/IO-2769-Job-Totals-Testing - fix typo 2025-04-08 12:00:43 -04:00
Dave Richer
5237b1d535 Merged in feature/IO-2769-Job-Totals-Testing (pull request #2251)
feature/IO-2769-Job-Totals-Testing
2025-04-08 15:47:09 +00:00
Dave Richer
cd56c50cf9 feature/IO-2769-Job-Totals-Testing 2025-04-08 11:46:16 -04:00
Dave Richer
a18ce18d72 feature/IO-2769-Job-Totals-Testing 2025-04-08 11:42:27 -04:00
Allan Carr
3691d32aaa IO-3202 HasFeatureAccess Boolean
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-07 17:36:39 -07:00
Dave Richer
5f66488410 release/2025-04-11 - Remove unused constant from server.js 2025-04-07 13:06:47 -04:00
Dave Richer
d1be7f6e09 Merged in test-AIO (pull request #2249)
Test AIO
2025-04-07 17:04:04 +00:00
Dave Richer
44f02f28a6 Merged in release/2025-04-11 (pull request #2248)
release/2025-04-11 - Add Ivana to Usage Report
2025-04-07 17:03:00 +00:00
Dave Richer
6d33622b4e release/2025-04-11 - Add Ivana to Usage Report 2025-04-07 13:01:23 -04:00
Dave Richer
f8b8e23ef4 Merged in release/2025-04-11 (pull request #2247)
release/2025-04-11 - Fix Packages
2025-04-07 16:12:42 +00:00
Dave Richer
db09d09428 release/2025-04-11 - Fix Packages 2025-04-07 12:11:53 -04:00
Dave Richer
451820a67c Merged in release/2025-04-11 (pull request #2246)
Release/2025 04 11
2025-04-04 18:19:46 +00:00
Dave Richer
ba0ce5027e Merged in feature/IO-2769-Job-Totals-Testing (pull request #2245)
feature/IO-2769-Job-Totals-testing: Setup testing method for job totals

Approved-by: Patrick Fic
2025-04-04 18:18:51 +00:00
Dave Richer
f777d26cc1 feature/IO-2769-Job-Totals-testing: Allow for both american and canadian capture + tests 2025-04-04 14:15:30 -04:00
Dave Richer
1463037878 feature/IO-2769-Job-Totals-testing: Allow for both american and canadian capture + tests 2025-04-04 13:54:53 -04:00
Dave Richer
7ddec0bb0f feature/IO-2769-Job-Totals-testing: Allow for both american and canadian capture + tests 2025-04-04 13:49:21 -04:00
Dave Richer
51c2d3351a feature/IO-2769-Job-Totals-testing: Allow for both american and canadian capture + tests 2025-04-04 13:47:38 -04:00
Dave Richer
8323fa6696 feature/IO-2769-Job-Totals-testing: Allow for both american and canadian capture + tests 2025-04-04 13:45:45 -04:00
Dave Richer
27a3932c08 feature/IO-2769-Job-Totals-testing: Allow for both american and canadian capture + tests 2025-04-04 13:43:19 -04:00
Dave Richer
add88659a4 feature/IO-2769-Job-Totals-testing: Setup testing method for job totals 2025-04-04 13:23:33 -04:00
Dave Richer
320ad065d0 feature/IO-2769-Job-Totals-testing: Setup testing method for job totals 2025-04-04 13:19:44 -04:00
Dave Richer
a9bc51949a Merged in release/2025-04-11 (pull request #2244)
feature/IO-3198-Seperate-Socket-Provider-useSocket
2025-04-03 17:12:19 +00:00
Dave Richer
39d1397221 Merged in feature/IO-3198-Seperate-Socket-Provider-useSocket (pull request #2243)
feature/IO-3198-Seperate-Socket-Provider-useSocket
2025-04-03 17:11:55 +00:00
Dave Richer
b44b71072f feature/IO-3198-Seperate-Socket-Provider-useSocket 2025-04-03 13:10:24 -04:00
Dave Richer
f3e2a83bab Merged in release/2025-04-11 (pull request #2242)
IO-3193 Reconciliation Zero Part Price or Qty
2025-04-03 16:37:40 +00:00
Allan Carr
0ef030bb89 Merged in feature/IO-3193-Reconciliation-Zero-Part (pull request #2241)
IO-3193 Reconciliation Zero Part Price or Qty

Approved-by: Dave Richer
2025-04-03 15:26:21 +00:00
Allan Carr
3e9e6baf32 IO-3193 Reconciliation Zero Part Price or Qty
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-04-02 13:56:02 -07:00
Dave Richer
c03d45b3fc Merged in release/2025-04-11 (pull request #2240)
Release/2025 04 11
2025-04-02 18:15:36 +00:00
Dave Richer
0a9b583c4b Merged in feature/IO-2885-IntelliPay-App-Postback-Support (pull request #2236)
Feature/IO-2885 IntelliPay App Postback Support
2025-04-02 18:15:07 +00:00
Dave Richer
54ac0c84a7 Merged release/2025-04-11 into feature/IO-2885-IntelliPay-App-Postback-Support 2025-04-02 17:44:54 +00:00
Dave Richer
4d59798d8d Merged in release/2025-03-28 (pull request #2239)
release/2025-03-28 - Add Cookies Provider
2025-04-02 17:44:40 +00:00
Dave Richer
f95dab544d Merged release/2025-04-11 into feature/IO-2885-IntelliPay-App-Postback-Support 2025-04-02 16:59:45 +00:00
Dave Richer
41e43dda96 feature/IO-2885-IntelliPay-App-Postback-Support - Cleanup 2025-04-02 11:59:54 -04:00
Dave Richer
cec60db78c Merge branch 'release/2025-03-28' into feature/IO-2885-IntelliPay-App-Postback-Support 2025-04-02 11:50:42 -04:00
Dave Richer
7e741e4af9 Merged in release/2025-03-28 (pull request #2238)
release/2025-03-28 - Add Cookies Provider
2025-04-02 15:47:49 +00:00
Dave Richer
24d47ae1c5 Merged in release/2025-03-28 (pull request #2237)
Release/2025 03 28
2025-04-02 15:39:16 +00:00
Dave Richer
f556d59ad7 release/2025-03-28 - Add Cookies Provider 2025-04-02 11:38:40 -04:00
Dave Richer
09c4662436 feature/IO-2885-IntelliPay-App-Postback
- Add Tests
2025-04-02 11:34:48 -04:00
Dave Richer
9bf6ba9cf0 feature/IO-2885-IntelliPay-App-Postback
- Refactor / Add Tests
2025-04-02 11:09:03 -04:00
Dave Richer
7843ca9b1a Merged in release/2025-03-28 (pull request #2235)
[DO NOT MERGE ]Release/2025-03-28 into master-AIO - IO-2999, IO-3092, IO-3176, IO-3178, IO-3181, IO-3183, IO-3185, IO-3187
2025-04-02 12:51:24 +00:00
Dave Richer
c78b9866a3 feature/IO-2885-IntelliPay-App-Postback
- Finish ticket
2025-04-01 15:15:48 -04:00
Patrick Fic
c8701aba63 Add region capture to Crisp. 2025-04-01 10:03:17 -07:00
Dave Richer
09c1a8ae35 feature/IO-2885-IntelliPay-App-Postback-Support
- Clean intellipay.js
2025-04-01 12:57:32 -04:00
Dave Richer
0ef2814de3 feature/IO-2885-IntelliPay-App-Postback-Support
- Clean intellipay.js, add new route scaffolding
2025-04-01 12:33:41 -04:00
Dave Richer
8e105f0b36 feature/IO-2885-IntelliPay-App-Postback-Support
- Replace/Remove body-parser with Expresses built in body parsing middleware
2025-04-01 12:20:50 -04:00
Dave Richer
ba4da3e35c feature/IO-2885-IntelliPay-App-Postback-Support
- Packages
2025-04-01 12:04:14 -04:00
Dave Richer
1b8be56c15 Merged in release/2025-03-28 (pull request #2234)
Release/2025-03-28 into test-AIO - IO-3181
2025-03-28 16:18:08 +00:00
Dave Richer
f6e65f82e5 Merged in feature/IO-3181-Test-Framework-Selection (pull request #2233)
Feature/IO-3181 Test Framework Selection
2025-03-28 16:17:31 +00:00
Dave Richer
8b7bb099f3 feature/IO-3181-Test-Framework-Selection - Skeletons complete 2025-03-28 12:16:36 -04:00
Dave Richer
2b26db78eb Merged in release/2025-03-28 (pull request #2232)
IO-3187 Admin Enhancements
2025-03-27 14:04:43 +00:00
Allan Carr
663d91b648 Merged in feature/IO-3187-Admin-Enhancements (pull request #2231)
IO-3187 Admin Enhancements

Approved-by: Dave Richer
2025-03-27 14:04:09 +00:00
Allan Carr
2a7686ec75 IO-3187 Admin Enhancements
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-03-26 15:21:42 -07:00
Dave Richer
549cb56cdf feature/IO-3181-Test-Framework-Selection - Skeletons complete 2025-03-26 16:54:05 -04:00
Dave Richer
146bb6c5c0 feature/IO-3181-Test-Framework-Selection - Skeletons complete 2025-03-26 16:52:59 -04:00
Dave Richer
67b6da7c31 feature/IO-3181-Test-Framework-Selection - Skeletons complete 2025-03-26 16:51:53 -04:00
Dave Richer
c2d96922c8 Merged in release/2025-03-28 (pull request #2230)
IO-3176 IntelliPay Payment Mapping
2025-03-26 18:24:26 +00:00
Allan Carr
624894621b Merged in feature/IO-3176-IntelliPay-Payment-Mapping (pull request #2229)
IO-3176 IntelliPay Payment Mapping

Approved-by: Dave Richer
2025-03-26 18:13:51 +00:00
Allan Carr
3fba215266 Merge branch 'release/2025-03-28' into feature/IO-3176-IntelliPay-Payment-Mapping
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-03-25 20:17:50 -07:00
Allan Carr
bbf291e8f3 IO-3176 IntelliPay Payment Mapping
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-03-25 20:16:34 -07:00
Dave Richer
70b4ec7948 Merged in release/2025-03-28 (pull request #2228)
release/2025-03-28 - Modify vite config
2025-03-25 20:53:36 +00:00
Dave Richer
341fc09c22 release/2025-03-28 - Modify vite config 2025-03-25 16:51:33 -04:00
Dave Richer
a3ec364034 Merged in release/2025-03-28 (pull request #2227)
release/2025-03-28 - Modify vite config
2025-03-25 20:48:25 +00:00
Dave Richer
fb30529808 release/2025-03-28 - Modify vite config 2025-03-25 16:48:03 -04:00
Dave Richer
e1728b275b Merged in release/2025-03-28 (pull request #2226)
release/2025-03-28 - Package locks
2025-03-25 20:38:36 +00:00
Dave Richer
46999145fc release/2025-03-28 - Package locks 2025-03-25 16:38:03 -04:00
Allan Carr
9d1f810af2 Merge branch 'release/2025-03-28' into feature/IO-3176-IntelliPay-Payment-Mapping
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2025-03-25 12:22:36 -07:00
Dave Richer
10d55df461 Merged in release/2025-03-28 (pull request #2224)
Release/2025 03 28
2025-03-25 19:07:28 +00:00
Dave Richer
b9693aae95 Merge remote-tracking branch 'origin/test-AIO' into release/2025-03-28 2025-03-25 15:04:46 -04:00
Dave Richer
02f5f1985c release/2025-03-28 - Up two deps 2025-03-25 15:01:50 -04:00
Dave Richer
37edceee84 Merged in feature/IO-3092-imgproxy (pull request #2225)
Feature/IO-3092 imgproxy
2025-03-25 18:58:34 +00:00
Allan Carr
1fd63012b0 Merged in release/2025-03-28 (pull request #2222)
Release/2025 03 28
2025-03-25 00:03:29 +00:00
Dave Richer
cf084fa168 Merged in release/2025-03-28 (pull request #2218)
IO-3176 IntelliPay Payment Method Mapping
2025-03-19 18:19:45 +00:00
Dave Richer
96af289640 Merged in release/2025-03-28 (pull request #2216)
IO-2999 IO Test Report Server Migration
2025-03-17 18:37:32 +00:00
Dave Richer
f8df351de6 Merged in release/2025-03-14 (pull request #2214)
Release/2025 03 14
2025-03-14 15:29:10 +00:00
Dave Richer
b8c096f4ff Merged in release/2025-03-14 (pull request #2211)
IO-3172 RO Basic Payments V2
2025-03-13 21:03:15 +00:00
Dave Richer
93ad23b615 Merged in release/2025-03-14 (pull request #2209)
Release/2025 03 14
2025-03-13 19:34:49 +00:00
Dave Richer
0a918535bb Merged in release/2025-03-14 (pull request #2208)
IO-3096-GlobalNotifications - Verify status reporter is a function and exists prior to calling it in cleanup task
2025-03-13 19:00:54 +00:00
Dave Richer
4863b16b5f Merged in release/2025-03-14 (pull request #2206)
IO-3096-GlobalNotifications - Add in a function to exclude extra logging from production
2025-03-13 17:58:20 +00:00
Dave Richer
a27f5e2153 Merged in release/2025-03-14 (pull request #2203)
IO-3170-HotfixFoRedis
2025-03-13 15:52:48 +00:00
Dave Richer
3ffea50072 Merged in release/2025-03-14 (pull request #2201)
IO-3166-Global-Notifications-Part-2: Remove unused event handler (hasura),
2025-03-13 15:32:21 +00:00
Dave Richer
34af7d3880 Merged in release/2025-03-14 (pull request #2198)
IO-3166-Global-Notifications-Part-2: add additional key prefixes for dev v prod
2025-03-13 01:14:18 +00:00
Dave Richer
4432721c27 Merged in release/2025-03-14 (pull request #2195)
IO-3166-Global-Notifications-Part-2: Make sure BULLMQ prefixes do not collide
2025-03-13 00:03:35 +00:00
Dave Richer
65ad4d9426 Merged in release/2025-03-14 (pull request #2192)
Release/2025-03-14 into test-AIO - IO-3172 IO-3166
2025-03-12 16:09:26 +00:00
Dave Richer
18924b4f08 Merged in release/2025-03-14 (pull request #2188)
Release/2025 03 14
2025-03-11 19:16:00 +00:00
Dave Richer
c524f5f0e0 Merged in release/2025-03-14 (pull request #2185)
Release/2025 03 14
2025-03-11 17:18:59 +00:00
Dave Richer
2fbac78eec Merged in release/2025-03-14 (pull request #2181)
IO-3166-Global-Notifications-Part-2: getAwsClusterFix
2025-03-07 21:00:29 +00:00
Dave Richer
4734971d48 Merged in release/2025-03-14 (pull request #2178)
IO-3170-Enhanced-GetRedisEndpointsFromAWS - Fix to prevent breaking
2025-03-07 20:30:58 +00:00
Dave Richer
fc1055c644 Merged in release/2024-03-14 (pull request #2174)
IO-3166-Global-Notifications-Part-2 - Improved GetRedisNodesFromAWS
2025-03-07 20:13:18 +00:00
Dave Richer
24798390b5 Merged in release/2024-03-14 (pull request #2170)
IO-3166-Global-Notifications-Part-2 - Small styling change
2025-03-07 18:52:24 +00:00
Dave Richer
a992dead04 Merged in release/2024-03-14 (pull request #2167)
IO-3166-Global-Notifications-Part-2 - Checkpoint
2025-03-07 16:05:48 +00:00
Dave Richer
f039cd8d0d Merged in release/2024-03-14 (pull request #2164)
Release/2024 03 14
2025-03-06 22:44:23 +00:00
Dave Richer
494e691230 Merged in release/2024-03-14 (pull request #2161)
Release/2024 03 14
2025-03-06 21:08:05 +00:00
Dave Richer
4cc7366290 Merged in release/2024-03-14 (pull request #2158)
Release/2024 03 14
2025-03-06 18:40:37 +00:00
Dave Richer
fd9d660a61 Merged in release/2024-03-14 (pull request #2155)
Release/2024 03 14
2025-03-05 22:33:06 +00:00
Dave Richer
0b5bd4f718 Merged in release/2024-03-14 (pull request #2152)
Release/2024 03 14
2025-03-05 18:56:08 +00:00
Dave Richer
7511b42bd4 Merged in release/2024-03-14 (pull request #2149)
Release/2024 03 14
2025-03-05 16:47:40 +00:00
Dave Richer
26f94c4d5b Merged in release/2024-03-14 (pull request #2146)
Release/2024 03 14
2025-03-04 22:56:23 +00:00
Dave Richer
aa55f4840b Merged in release/2024-03-14 (pull request #2142)
Release/2024 03 14
2025-03-04 16:58:41 +00:00
Dave Richer
2810428d19 test-AIO - Merge in GlobalNotifications branch 2025-03-04 11:35:12 -05:00
Patrick Fic
83da64f96b Merged in feature/IO-3162-sentry-improvements (pull request #2137)
IO-3162 Resize test CI boxes.
2025-02-28 23:27:49 +00:00
Patrick Fic
1f8d027f97 Merged in feature/IO-3162-sentry-improvements (pull request #2136)
feature/IO-3162-sentry-improvements

Approved-by: Patrick Fic
2025-02-28 23:19:23 +00:00
Patrick Fic
2f8ba20a5b Merged in feature/IO-3162-sentry-improvements (pull request #2135)
feature/IO-3162-sentry-improvements

Approved-by: Patrick Fic
2025-02-28 23:04:41 +00:00
Patrick Fic
b525f920e0 Merged in feature/IO-3162-sentry-improvements (pull request #2134)
feature/IO-3162-sentry-improvements
2025-02-28 22:49:41 +00:00
Dave Richer
91fe6745fe Merged in release/2025-02-28 (pull request #2133)
IO-2561 Return Items Modal
2025-02-28 17:37:25 +00:00
Patrick Fic
b9073fe3f5 Merged in feature/IO-3092-imgproxy (pull request #2132)
IO-3092 Refactor exports.

Approved-by: Dave Richer
2025-02-28 17:35:07 +00:00
Patrick Fic
2c95b49ae1 Merged in feature/IO-3092-imgproxy (pull request #2130)
Feature/IO-3092 imgproxy
2025-02-27 21:55:00 +00:00
Patrick Fic
9bde06e110 Merged in release/2025-02-28 (pull request #2127)
Add catch error handling.

Approved-by: Dave Richer
2025-02-21 16:50:57 +00:00
Patrick Fic
30449ca113 Merged in release/2025-02-28 (pull request #2126)
Remove email from handler.

Approved-by: Patrick Fic
2025-02-21 00:38:01 +00:00
Patrick Fic
0405d19f98 Merged in release/2025-02-28 (pull request #2125)
release/2025-02-28

Approved-by: Patrick Fic
2025-02-20 23:46:37 +00:00
Patrick Fic
2c5310403b Merged in release/2025-02-28 (pull request #2124)
release/2025-02-28

Approved-by: Patrick Fic
2025-02-20 23:38:06 +00:00
Dave Richer
e2ef4f1caf Merged in release/2025-02-28 (pull request #2119)
feature/IO-3146-Hotfix-For-Email-Translations
2025-02-19 15:40:47 +00:00
Allan Carr
b32a2d4d86 Merged in release/2025-02-14 (pull request #2116)
Release/2025 02 14

Approved-by: Dave Richer
2025-02-13 17:38:26 +00:00
Dave Richer
7c92484ae0 Merged in release/2025-02-14 (pull request #2113)
Release/2025-02-14 into test-AIO - IO-3127 IO-3128 IO-3077 IO-3131
2025-02-12 19:07:08 +00:00
Allan Carr
67cada5d8e Merged in hotfix/2025-02-06 (pull request #2109)
Hotfix/2025 02 06
2025-02-06 16:34:52 +00:00
Dave Richer
4bf68b637f Merged in release/2025-01-31 (pull request #2101)
release/2025-01-31 - fix teams icon
2025-02-04 18:48:35 +00:00
Dave Richer
b40c433865 Merged in release/2025-01-31 (pull request #2100)
Release/2025 01 31 into test-AIO - IO-3096 IO-2825 IO-3123
2025-02-04 18:04:45 +00:00
Dave Richer
55ed499ab5 Merged in release/2025-01-31 (pull request #2095)
Release/2025 01 31
2025-01-31 18:25:32 +00:00
Dave Richer
353bc3bc05 Merged in release/2025-01-31 (pull request #2091)
Release/2025 01 31 into test-AIO - IO-2681
2025-01-30 20:18:08 +00:00
Dave Richer
df5c96345c Merged in release/2025-01-31 (pull request #2089)
Release/2025 01 31
2025-01-30 17:12:44 +00:00
Dave Richer
2c7c187c45 Merged in release/2025-01-31 (pull request #2084)
Release/2025 01 31 into test-AIO - IO-3108 IO-2676
2025-01-27 18:11:07 +00:00
Dave Richer
3a5a78d60a Merged in release/2025-01-31 (pull request #2076)
feature/IO-3103-Ant5-Notifications - Job Icons fixed (spacing)
2025-01-22 18:48:25 +00:00
Dave Richer
6dd2871c07 Merged in release/2025-01-31 (pull request #2074)
Release/2025 01 31 into test-AIO -  IO-2952, IO-3099, IO-3101, IO-3103
2025-01-22 18:11:57 +00:00
Dave Richer
ef36ab9da0 Merged in release/2025-01-17 (pull request #2065)
hotfix/AdditionalProductFruitsIds - Add additional IDs for product fruits
2025-01-17 18:14:49 +00:00
Dave Richer
a917f6bcdf Merged in release/2025-01-17 (pull request #2063)
Release/2025 01 17 into test-AIO - IO-2951 IO-999 IO-3096
2025-01-17 17:54:01 +00:00
Dave Richer
c5d6457146 Merged in release/2025-01-17 (pull request #2060)
IO-3063 LOU on Schedule PopOver
2025-01-16 15:24:29 +00:00
Dave Richer
f3831e934f Merged in release/2025-01-17 (pull request #2058)
Release/2025 01 17
2025-01-15 15:50:58 +00:00
105 changed files with 6499 additions and 5668 deletions

7
.gitignore vendored
View File

@@ -121,3 +121,10 @@ logs/oAuthClient-log.log
/*.env.*
.idea/*
.idea
# Vitest
vitest-report*/
vitest-coverage/
*.vitest.log
test-output.txt
server/job/test/fixtures

View File

@@ -56,4 +56,5 @@ COPY . .
EXPOSE 4000 9229
# Start the application
CMD ["nodemon", "--legacy-watch", "--inspect=0.0.0.0:9229", "server.js"]
RUN echo "Starting the application..."
CMD ["nodemon", "--ignore", "./server/job/test/fixtures", "--legacy-watch", "--inspect=0.0.0.0:9229", "server.js"]

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,8 @@
"license": "ISC",
"description": "",
"dependencies": {
"express": "^4.21.1",
"mailparser": "^3.7.1",
"express": "^5.1.0",
"mailparser": "^3.7.2",
"node-fetch": "^3.3.2"
}
}

View File

@@ -12,3 +12,5 @@ VITE_APP_AXIOS_BASE_API_URL=/api/
VITE_APP_REPORTS_SERVER_URL=https://reports.test.imex.online
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
VITE_APP_INSTANCE=IMEX
TEST_USERNAME="test@imex.dev"
TEST_PASSWORD="test123"

View File

@@ -14,3 +14,5 @@ VITE_APP_REPORTS_SERVER_URL=https://reports.test.romeonline.io
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
VITE_APP_COUNTRY=USA
VITE_APP_INSTANCE=ROME
TEST_USERNAME="test@imex.dev"
TEST_PASSWORD="test123"

11
client/.gitignore vendored
View File

@@ -1,3 +1,14 @@
# Vitest
vitest-report*/
vitest-coverage/
*.vitest.log
test-output.txt
# Playwright
playwright-report/
test-results/
playwright/.cache/
*.playwright.log
# Sentry Config File
.sentryclirc

2674
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,24 +8,23 @@
"private": true,
"proxy": "http://localhost:4000",
"dependencies": {
"@ant-design/pro-layout": "^7.22.3",
"@apollo/client": "^3.13.5",
"@ant-design/pro-layout": "^7.22.4",
"@apollo/client": "^3.13.6",
"@emotion/is-prop-valid": "^1.3.1",
"@fingerprintjs/fingerprintjs": "^4.6.1",
"@firebase/analytics": "^0.10.12",
"@firebase/app": "^0.11.3",
"@firebase/auth": "^1.9.1",
"@firebase/app": "^0.11.4",
"@firebase/auth": "^1.10.0",
"@firebase/firestore": "^4.7.10",
"@firebase/messaging": "^0.12.17",
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.6.1",
"@sentry/cli": "^2.42.4",
"@sentry/react": "^9.9.0",
"@sentry/vite-plugin": "^3.2.2",
"@splitsoftware/splitio-react": "^2.0.1",
"@sentry/cli": "^2.43.0",
"@sentry/react": "^9.11.0",
"@sentry/vite-plugin": "^3.3.1",
"@splitsoftware/splitio-react": "^2.1.1",
"@tanem/react-nprogress": "^5.0.53",
"@vitejs/plugin-react": "^4.3.4",
"antd": "^5.24.5",
"antd": "^5.24.6",
"apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^4.2.0",
"autosize": "^6.0.1",
@@ -58,7 +57,7 @@
"react-dom": "^18.3.1",
"react-drag-listview": "^2.0.0",
"react-grid-gallery": "^1.0.1",
"react-grid-layout": "1.3.4",
"react-grid-layout": "^1.3.4",
"react-i18next": "^15.4.1",
"react-icons": "^5.5.0",
"react-image-lightbox": "^5.1.4",
@@ -71,16 +70,16 @@
"react-router-dom": "^6.30.0",
"react-sticky": "^6.0.3",
"react-virtuoso": "^4.12.5",
"recharts": "^2.15.0",
"recharts": "^2.15.2",
"redux": "^5.0.1",
"redux-actions": "^3.0.3",
"redux-persist": "^6.0.0",
"redux-saga": "^1.3.0",
"redux-state-sync": "^3.1.4",
"reselect": "^5.1.1",
"sass": "^1.86.0",
"sass": "^1.86.3",
"socket.io-client": "^4.8.1",
"styled-components": "^6.1.16",
"styled-components": "^6.1.17",
"subscriptions-transport-ws": "^0.11.0",
"use-memo-one": "^1.1.3",
"vite-plugin-ejs": "^1.7.0",
@@ -100,7 +99,14 @@
"build:production:imex": "env-cmd -f .env.production.imex npm run build",
"build:production:rome": "env-cmd -f .env.production.rome npm run build",
"madge": "madge --image ./madge-graph.svg --extensions js,jsx,ts,tsx --circular .",
"eulaize": "node src/utils/eulaize.js"
"eulaize": "node src/utils/eulaize.js",
"test:unit": "vitest run",
"test:watch": "vitest",
"test:e2e:imex": "playwright test --config playwright.config.js",
"test:e2e:rome": "playwright test --config playwright.rome.config.js",
"test:e2e:imex:headed": "playwright test --config playwright.config.js --headed",
"test:e2e:rome:headed": "playwright test --config playwright.rome.config.js --headed",
"test:e2e:report": "playwright show-report"
},
"browserslist": {
"production": [
@@ -124,29 +130,37 @@
"@ant-design/icons": "^6.0.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.26.3",
"@dotenvx/dotenvx": "^1.39.0",
"@dotenvx/dotenvx": "^1.39.1",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.23.0",
"@sentry/webpack-plugin": "^3.2.2",
"@eslint/js": "^9.24.0",
"@playwright/test": "^1.51.1",
"@sentry/webpack-plugin": "^3.3.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@vitejs/plugin-react": "^4.3.4",
"browserslist": "^4.24.4",
"browserslist-to-esbuild": "^2.1.1",
"chalk": "^5.4.1",
"eslint": "^8.57.1",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0",
"jsdom": "^26.0.0",
"memfs": "^4.17.0",
"os-browserify": "^0.3.0",
"playwright": "^1.51.1",
"react-error-overlay": "^6.1.0",
"redux-logger": "^3.0.6",
"source-map-explorer": "^2.5.3",
"vite": "^6.2.3",
"vite": "^6.2.5",
"vite-plugin-babel": "^1.3.0",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-pwa": "^0.21.2",
"vite-plugin-pwa": "^1.0.0",
"vite-plugin-style-import": "^2.0.0",
"vitest": "^3.1.1",
"workbox-window": "^7.3.0"
}
}

View File

@@ -0,0 +1,25 @@
import { defineConfig } from "@playwright/test";
import dotenv from "dotenv";
dotenv.config({
path: "./.env.development.imex",
prefix: "TEST_"
});
export default defineConfig({
testDir: "./tests/e2e",
testMatch: "*.e2e.js",
timeout: 60 * 1000,
reporter: [["list"], ["html"]],
use: {
baseURL: "https://localhost:3000",
browser: "chromium",
ignoreHTTPSErrors: true
},
webServer: {
command: "npm run start:imex",
ignoreHTTPSErrors: true,
url: "https://localhost:3000/health", // Health check endpoint will tell us when the server is ready
reuseExistingServer: !process.env.CI // Reuse server locally, not in CI
}
});

View File

@@ -0,0 +1,25 @@
import { defineConfig } from "@playwright/test";
import dotenv from "dotenv";
dotenv.config({
path: "./.env.development.rome",
prefix: "TEST_"
});
export default defineConfig({
testDir: "./tests/e2e",
testMatch: "*.e2e.js",
timeout: 60 * 1000,
reporter: [["list"], ["html"]],
use: {
baseURL: "https://localhost:3000",
browser: "chromium",
ignoreHTTPSErrors: true
},
webServer: {
command: "npm run start:rome",
ignoreHTTPSErrors: true,
url: "https://localhost:3000/health", // Health check endpoint will tell us when the server is ready
reuseExistingServer: !process.env.CI // Reuse server locally, not in CI
}
});

View File

@@ -10,6 +10,7 @@ import client from "../utils/GraphQLClient";
import App from "./App";
import * as Sentry from "@sentry/react";
import themeProvider from "./themeProvider";
import { CookiesProvider } from "react-cookie";
// Base Split configuration
const config = {
@@ -38,26 +39,28 @@ function AppContainer() {
const { t } = useTranslation();
return (
<ApolloProvider client={client}>
<ConfigProvider
input={{ autoComplete: "new-password" }}
locale={enLocale}
theme={themeProvider}
form={{
validateMessages: {
// eslint-disable-next-line no-template-curly-in-string
required: t("general.validation.required", { label: "${label}" })
}
}}
>
<GlobalLoadingBar />
<SplitFactoryProvider config={config}>
<SplitClientProvider>
<App />
</SplitClientProvider>
</SplitFactoryProvider>
</ConfigProvider>
</ApolloProvider>
<CookiesProvider>
<ApolloProvider client={client}>
<ConfigProvider
input={{ autoComplete: "new-password" }}
locale={enLocale}
theme={themeProvider}
form={{
validateMessages: {
// eslint-disable-next-line no-template-curly-in-string
required: t("general.validation.required", { label: "${label}" })
}
}}
>
<GlobalLoadingBar />
<SplitFactoryProvider config={config}>
<SplitClientProvider>
<App />
</SplitClientProvider>
</SplitFactoryProvider>
</ConfigProvider>
</ApolloProvider>
</CookiesProvider>
);
}

View File

@@ -21,8 +21,8 @@ import "./App.styles.scss";
import Eula from "../components/eula/eula.component";
import InstanceRenderMgr from "../utils/instanceRenderMgr";
import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx";
import { SocketProvider } from "../contexts/SocketIO/useSocket.jsx";
import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx";
import SocketProvider from "../contexts/SocketIO/socketProvider.jsx";
const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component"));
const ManagePage = lazy(() => import("../pages/manage/manage.page.container"));
@@ -142,11 +142,10 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
>
<ProductFruitsWrapper
currentUser={currentUser}
workspaceCode={InstanceRenderMgr({
imex: null,
rome: "9BkbEseqNqxw8jUH"
})}
bodyshop={bodyshop}
workspaceCode={bodyshop?.tours_enabled ? "9BkbEseqNqxw8jUH" : ""}
/>
<NotificationProvider>
<Routes>
<Route

View File

@@ -1,8 +1,16 @@
import React from "react";
import { ProductFruits } from "react-product-fruits";
import PropTypes from "prop-types";
import { ProductFruits } from "react-product-fruits";
import dayjs from "dayjs";
const ProductFruitsWrapper = React.memo(({ currentUser, bodyshop, workspaceCode }) => {
const featureProps = bodyshop?.features
? Object.entries(bodyshop.features).reduce((acc, [key, value]) => {
acc[key] = value === true || (typeof value === "string" && dayjs(value).isAfter(dayjs()));
return acc;
}, {})
: {};
const ProductFruitsWrapper = React.memo(({ currentUser, workspaceCode }) => {
return (
workspaceCode &&
currentUser?.authorized === true &&
@@ -14,7 +22,8 @@ const ProductFruitsWrapper = React.memo(({ currentUser, workspaceCode }) => {
language="en"
user={{
email: currentUser.email,
username: currentUser.email
username: currentUser.email,
props: featureProps
}}
/>
)
@@ -28,5 +37,6 @@ ProductFruitsWrapper.propTypes = {
authorized: PropTypes.bool,
email: PropTypes.string
}),
workspaceCode: PropTypes.string
workspaceCode: PropTypes.string,
bodyshop: PropTypes.object
};

View File

@@ -1,3 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Alert component should render Alert component 1`] = `ShallowWrapper {}`;

View File

@@ -1,19 +0,0 @@
import { shallow } from "enzyme";
import React from "react";
import Alert from "./alert.component";
describe("Alert component", () => {
let wrapper;
beforeEach(() => {
const mockProps = {
type: "error",
message: "Test error message."
};
wrapper = shallow(<Alert {...mockProps} />);
});
it("should render Alert component", () => {
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,31 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import AlertComponent from "./alert.component";
describe("AlertComponent", () => {
it("renders with default props", () => {
render(<AlertComponent message="Default Alert" />);
expect(screen.getByText("Default Alert")).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveClass("ant-alert");
});
it("applies type prop correctly", () => {
render(<AlertComponent message="Success Alert" type="success" />);
const alert = screen.getByRole("alert");
expect(screen.getByText("Success Alert")).toBeInTheDocument();
expect(alert).toHaveClass("ant-alert-success");
});
it("displays description when provided", () => {
render(<AlertComponent message="Error Alert" description="Something went wrong" type="error" />);
expect(screen.getByText("Error Alert")).toBeInTheDocument();
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveClass("ant-alert-error");
});
it("is closable and shows icon when props are set", () => {
render(<AlertComponent message="Warning Alert" type="warning" showIcon closable />);
expect(screen.getByText("Warning Alert")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /close/i })).toBeInTheDocument(); // Close button
});
});

View File

@@ -1,5 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AllocationsAssignmentComponent component should create an allocation on save 1`] = `ReactWrapper {}`;
exports[`AllocationsAssignmentComponent component should render AllocationsAssignmentComponent component 1`] = `ReactWrapper {}`;

View File

@@ -1,35 +0,0 @@
import { mount } from "enzyme";
import React from "react";
import { MockBodyshop } from "../../utils/TestingHelpers";
import { AllocationsAssignmentComponent } from "./allocations-assignment.component";
describe("AllocationsAssignmentComponent component", () => {
let wrapper;
beforeEach(() => {
const mockProps = {
bodyshop: MockBodyshop,
handleAssignment: jest.fn(),
assignment: {},
setAssignment: jest.fn(),
visibilityState: [false, jest.fn()],
maxHours: 4
};
wrapper = mount(<AllocationsAssignmentComponent {...mockProps} />);
});
it("should render AllocationsAssignmentComponent component", () => {
expect(wrapper).toMatchSnapshot();
});
it("should render a list of employees", () => {
const empList = wrapper.find("#employeeSelector");
expect(empList.children()).to.have.lengthOf(2);
});
it("should create an allocation on save", () => {
wrapper.find("Button").simulate("click");
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -3,11 +3,11 @@ import { getToken } from "@firebase/messaging";
import axios from "axios";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { messaging, requestForToken } from "../../firebase/firebase.utils";
import ChatPopupComponent from "../chat-popup/chat-popup.component";
import "./chat-affix.styles.scss";
import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
export function ChatAffixContainer({ bodyshop, chatVisible }) {
const { t } = useTranslation();

View File

@@ -3,10 +3,10 @@ import { Button } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TOGGLE_CONVERSATION_ARCHIVE } from "../../graphql/conversations.queries";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -4,10 +4,10 @@ import { Link } from "react-router-dom";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { REMOVE_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -3,11 +3,11 @@ import axios from "axios";
import { useCallback, useEffect, useState } from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import ChatConversationComponent from "./chat-conversation.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,

View File

@@ -4,11 +4,11 @@ import { Input, Spin, Tag, Tooltip } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { UPDATE_CONVERSATION_LABEL } from "../../graphql/conversations.queries";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -5,7 +5,7 @@ import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser

View File

@@ -7,7 +7,7 @@ import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { searchingForConversation } from "../../redux/messaging/messaging.selectors";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({

View File

@@ -12,9 +12,9 @@ import ChatConversationListComponent from "../chat-conversation-list/chat-conver
import ChatConversationContainer from "../chat-conversation/chat-conversation.container";
import ChatNewConversation from "../chat-new-conversation/chat-new-conversation.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import "./chat-popup.styles.scss";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,

View File

@@ -8,10 +8,10 @@ import { logImEXEvent } from "../../firebase/firebase.utils";
import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import { SEARCH_FOR_JOBS } from "../../graphql/jobs.queries";
import ChatTagRo from "./chat-tag-ro.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -20,6 +20,7 @@ function FeatureWrapper({
children,
upsellComponent,
bypass,
// eslint-disable-next-line no-unused-vars
...restProps
}) {
const { t } = useTranslation();
@@ -78,7 +79,11 @@ export function HasFeatureAccess({ featureName, bodyshop, bypass, debug = false
}
return true;
}
return bodyshop?.features?.allAccess || dayjs(bodyshop?.features[featureName]).isAfter(dayjs());
return (
bodyshop?.features?.allAccess ||
bodyshop?.features?.[featureName] ||
dayjs(bodyshop?.features[featureName]).isAfter(dayjs())
);
}
export default connect(mapStateToProps, null)(FeatureWrapper);

View File

@@ -40,7 +40,6 @@ import { RiSurveyLine } from "react-icons/ri";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
import { selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors";
import { setModalContext } from "../../redux/modals/modals.actions";
@@ -51,6 +50,7 @@ import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
// Redux mappings
const mapStateToProps = createStructuredSelector({

View File

@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { setModalContext } from "../../redux/modals/modals.actions";

View File

@@ -6,7 +6,8 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { QUERY_JOB_CARD_DETAILS, UPDATE_JOB } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions.js";

View File

@@ -1,9 +1,9 @@
import { Col, Row } from "antd";
import React, { useState } from "react";
import { useState } from "react";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import JobReconciliationBillsTable from "../job-reconciliation-bills-table/job-reconciliation-bills-table.component";
import JobReconciliationPartsTable from "../job-reconciliation-parts-table/job-reconciliation-parts-table.component";
import JobReconciliationTotals from "../job-reconciliation-totals/job-reconciliation-totals.component";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
export default function JobReconciliationModalComponent({ job, bills }) {
const jobLineState = useState([]);
@@ -20,7 +20,7 @@ export default function JobReconciliationModalComponent({ job, bills }) {
const filterFunction = InstanceRenderManager({
imex: (j) =>
(j.part_type !== null && j.part_type !== "PAE") ||
(j.part_type !== null && j.part_type !== "PAE" && j.act_price !== 0 && j.part_qty !== 0) ||
(j.line_desc && j.line_desc.toLowerCase().includes("towing") && j.lbr_op === "OP13") ||
j.db_ref === "936004", //ADD SHIPPING LINE.
rome: (j) =>

View File

@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { auth, logImEXEvent } from "../../firebase/firebase.utils";
import { CANCEL_APPOINTMENTS_BY_JOB_ID, INSERT_MANUAL_APPT } from "../../graphql/appointments.queries";
import { GET_CURRENT_QUESTIONSET_ID, INSERT_CSI } from "../../graphql/csi.queries";
@@ -133,6 +133,16 @@ export function JobsDetailHeaderActions({
const { socket } = useSocket();
const notification = useNotification();
const isDevEnv = import.meta.env.DEV;
const isProdEnv = import.meta.env.PROD;
const userEmail = currentUser?.email || "";
const devEmails = ["imex.dev", "rome.dev"];
const prodEmails = ["imex.prod", "rome.prod", "imex.test", "rome.test"];
const hasValidEmail = (emails) => emails.some((email) => userEmail.endsWith(email));
const canSubmitForTesting = (isDevEnv && hasValidEmail(devEmails)) || (isProdEnv && hasValidEmail(prodEmails));
const {
treatments: { ImEXPay }
} = useSplitTreatments({
@@ -171,7 +181,7 @@ export function JobsDetailHeaderActions({
{ defaultOpenStatus: bodyshop.md_ro_statuses.default_imported },
(newJobId) => {
history(`/manage/jobs/${newJobId}`);
notification["success"]({
notification.success({
message: t("jobs.successes.duplicated")
});
},
@@ -181,7 +191,7 @@ export function JobsDetailHeaderActions({
const handleDuplicateConfirm = () =>
DuplicateJob(client, job.id, { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported }, (newJobId) => {
history(`/manage/jobs/${newJobId}`);
notification["success"]({
notification.success({
message: t("jobs.successes.duplicated")
});
});
@@ -217,13 +227,13 @@ export function JobsDetailHeaderActions({
const result = await deleteJob({ variables: { id: job.id } });
if (!result.errors) {
notification["success"]({
notification.success({
message: t("jobs.successes.delete")
});
//go back to jobs list.
history(`/manage/`);
} else {
notification["error"]({
notification.error({
message: t("jobs.errors.deleted", {
error: JSON.stringify(result.errors)
})
@@ -275,9 +285,9 @@ export function JobsDetailHeaderActions({
});
if (!result.errors) {
notification["success"]({ message: t("csi.successes.created") });
notification.success({ message: t("csi.successes.created") });
} else {
notification["error"]({
notification.error({
message: t("csi.errors.creating", {
message: JSON.stringify(result.errors)
})
@@ -316,7 +326,7 @@ export function JobsDetailHeaderActions({
`${window.location.protocol}//${window.location.host}/csi/${result.data.insert_csi.returning[0].id}`
);
} else {
notification["error"]({
notification.error({
message: t("messaging.error.invalidphone")
});
}
@@ -328,7 +338,7 @@ export function JobsDetailHeaderActions({
);
}
} else {
notification["error"]({
notification.error({
message: t("csi.errors.notconfigured")
});
}
@@ -358,7 +368,7 @@ export function JobsDetailHeaderActions({
});
setMessage(`${window.location.protocol}//${window.location.host}/csi/${job.csiinvites[0].id}`);
} else {
notification["error"]({
notification.error({
message: t("messaging.error.invalidphone")
});
}
@@ -398,7 +408,7 @@ export function JobsDetailHeaderActions({
});
if (!result.errors) {
notification["success"]({
notification.success({
message: t("jobs.successes.voided")
});
insertAuditTrail({
@@ -409,7 +419,7 @@ export function JobsDetailHeaderActions({
//go back to jobs list.
history(`/manage/`);
} else {
notification["error"]({
notification.error({
message: t("jobs.errors.voiding", {
error: JSON.stringify(result.errors)
})
@@ -442,7 +452,7 @@ export function JobsDetailHeaderActions({
console.log("handle -> XML", QbXmlResponse);
} catch (error) {
console.log("Error getting QBXML from Server.", error);
notification["error"]({
notification.error({
message: t("jobs.errors.exporting", {
error: "Unable to retrieve QBXML. " + JSON.stringify(error.message)
})
@@ -460,7 +470,7 @@ export function JobsDetailHeaderActions({
});
} catch (error) {
console.log("Error connecting to quickbooks or partner.", error);
notification["error"]({
notification.error({
message: t("jobs.errors.exporting-partner")
});
@@ -556,7 +566,7 @@ export function JobsDetailHeaderActions({
}
});
if (!jobUpdate.errors) {
notification["success"]({
notification.success({
message: t("appointments.successes.canceled")
});
insertAuditTrail({
@@ -931,11 +941,11 @@ export function JobsDetailHeaderActions({
});
if (!result.errors) {
notification["success"]({
notification.success({
message: t("jobs.successes.partsqueue")
});
} else {
notification["error"]({
notification.error({
message: t("jobs.errors.saving", {
error: JSON.stringify(result.errors)
})
@@ -1111,6 +1121,27 @@ export function JobsDetailHeaderActions({
});
}
if (canSubmitForTesting) {
menuItems.push({
key: "submitfortesting",
id: "job-actions-submitfortesting",
label: t("menus.jobsactions.submit-for-testing"),
onClick: async () => {
try {
await axios.post("/job/totals-recorder", { id: job.id });
notification.success({
message: t("general.messages.submit-for-testing")
});
} catch (err) {
console.error(`Error submitting job for testing: ${err?.message}`);
notification.error({
message: t("general.errors.submit-for-testing-error")
});
}
}
});
}
const menu = {
items: menuItems,
key: "popovermenu"

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { GET_JOB_BY_PK_QUICK_INTAKE, JOB_PRODUCTION_TOGGLE } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
@@ -12,7 +13,6 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { DateTimeFormatterFunction } from "../../utils/DateFormatter";
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
const mapStateToProps = createStructuredSelector({
@@ -46,7 +46,16 @@ export function JobsDetailHeaderActionsToggleProduction({
if (data?.jobs_by_pk) {
form.setFieldsValue({
actual_in: data.jobs_by_pk.actual_in ? data.jobs_by_pk.actual_in : dayjs(),
scheduled_completion: data.jobs_by_pk.scheduled_completion,
scheduled_completion: data.jobs_by_pk.scheduled_completion
? data.jobs_by_pk.scheduled_completion
: data.jobs_by_pk.labhrs &&
data.jobs_by_pk.larhrs &&
dayjs().businessDaysAdd(
(data.jobs_by_pk.labhrs.aggregate.sum.mod_lb_hrs ||
0 + data.jobs_by_pk.larhrs.aggregate.sum.mod_lb_hrs ||
0) / bodyshop.target_touchtime,
"day"
),
actual_completion: data.jobs_by_pk.actual_completion,
scheduled_delivery: data.jobs_by_pk.scheduled_delivery,
actual_delivery: data.jobs_by_pk.actual_delivery

View File

@@ -3,10 +3,10 @@ import { useQuery } from "@apollo/client";
import { connect } from "react-redux";
import NotificationCenterComponent from "./notification-center.component";
import { GET_NOTIFICATIONS } from "../../graphql/notifications.queries";
import { INITIAL_NOTIFICATIONS, useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import day from "../../utils/day.js";
import { INITIAL_NOTIFICATIONS, useSocket } from "../../contexts/SocketIO/useSocket.js";
// This will be used to poll for notifications when the socket is disconnected
const NOTIFICATION_POLL_INTERVAL_SECONDS = 60;

View File

@@ -10,7 +10,7 @@ import { createStructuredSelector } from "reselect";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,

View File

@@ -12,7 +12,7 @@ import { QUERY_KANBAN_SETTINGS } from "../../graphql/user.queries";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import ProductionBoardKanbanComponent from "./production-board-kanban.component";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,

View File

@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { logImEXEvent } from "../../firebase/firebase.utils.js";
import { QUERY_JOB_CARD_DETAILS, UPDATE_JOB } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions.js";

View File

@@ -10,7 +10,7 @@ import {
import ProductionListTable from "./production-list-table.component";
import _ from "lodash";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) {
const client = useApolloClient();

View File

@@ -8,7 +8,7 @@ import { selectCurrentUser } from "../../redux/user/user.selectors";
import { logImEXEvent, updateCurrentPassword } from "../../firebase/firebase.utils";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import NotificationSettingsForm from "../notification-settings/notification-settings-form.component.jsx";
const mapStateToProps = createStructuredSelector({

View File

@@ -1,4 +1,5 @@
import { createContext, useContext, useEffect, useRef, useState } from "react";
// SocketProvider.js
import { useEffect, useRef, useState } from "react";
import SocketIO from "socket.io-client";
import { auth } from "../../firebase/firebase.utils";
import { store } from "../../redux/store";
@@ -15,10 +16,7 @@ import {
import { useMutation } from "@apollo/client";
import { useTranslation } from "react-i18next";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
const SocketContext = createContext(null);
const INITIAL_NOTIFICATIONS = 10;
import { SocketContext, INITIAL_NOTIFICATIONS } from "./useSocket.js";
/**
* Socket Provider - Scenario Notifications / Web Socket related items
@@ -216,7 +214,6 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
};
const handleNotification = (data) => {
// Scenario Notifications have been disabled, bail.
if (Realtime_Notifications_UI?.treatment !== "on") {
return;
}
@@ -336,7 +333,6 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
};
const handleSyncNotificationRead = ({ notificationId, timestamp }) => {
// Scenario Notifications have been disabled, bail.
if (Realtime_Notifications_UI?.treatment !== "on") {
return;
}
@@ -378,7 +374,6 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
};
const handleSyncAllNotificationsRead = ({ timestamp }) => {
// Scenario Notifications have been disabled, bail.
if (Realtime_Notifications_UI?.treatment !== "on") {
return;
}
@@ -490,11 +485,4 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
);
};
const useSocket = () => {
const context = useContext(SocketContext);
// NOTE: Not sure if we absolutely require this, does cause slipups on dev env
if (!context) throw new Error("useSocket must be used within a SocketProvider");
return context;
};
export { SocketContext, SocketProvider, INITIAL_NOTIFICATIONS, useSocket };
export default SocketProvider;

View File

@@ -0,0 +1,13 @@
import { createContext, useContext } from "react";
const SocketContext = createContext(null);
const INITIAL_NOTIFICATIONS = 10;
const useSocket = () => {
const context = useContext(SocketContext);
if (!context) throw new Error("useSocket must be used within a SocketProvider");
return context;
};
export { SocketContext, INITIAL_NOTIFICATIONS, useSocket };

View File

@@ -57,6 +57,7 @@ export const QUERY_BODYSHOP = gql`
logo_img_path
md_ro_statuses
md_order_statuses
tours_enabled
md_functionality_toggles
shopname
state
@@ -186,6 +187,7 @@ export const UPDATE_SHOP = gql`
phone
federal_tax_id
id
tours_enabled
insurance_vendor_id
logo_img_path
md_ro_statuses

View File

@@ -2570,6 +2570,20 @@ export const GET_JOB_BY_PK_QUICK_INTAKE = gql`
actual_completion
scheduled_delivery
actual_delivery
labhrs: joblines_aggregate(where: { _and: [{ mod_lbr_ty: { _neq: "LAR" } }, { removed: { _eq: false } }] }) {
aggregate {
sum {
mod_lb_hrs
}
}
}
larhrs: joblines_aggregate(where: { _and: [{ mod_lbr_ty: { _eq: "LAR" } }, { removed: { _eq: false } }] }) {
aggregate {
sum {
mod_lb_hrs
}
}
}
}
}
`;

View File

@@ -78,6 +78,9 @@ export const QUERY_PARTS_ORDER_OEC = gql`
}
ro_number
clm_no
cieca_stl
cieca_ttl
cieca_pfl
asgn_no
asgn_date
state_tax_rate
@@ -164,6 +167,7 @@ export const QUERY_PARTS_ORDER_OEC = gql`
loss_desc
loss_of_use
loss_type
materials
ownr_addr1
ownr_addr2
ownr_city

View File

@@ -56,7 +56,7 @@ import { DateTimeFormat } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
import UndefinedToNull from "../../utils/undefinedtonull";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import JobWatcherToggleContainer from "../../components/job-watcher-toggle/job-watcher-toggle.container.jsx";
const mapStateToProps = createStructuredSelector({

View File

@@ -20,7 +20,6 @@ import PartnerPingComponent from "../../components/partner-ping/partner-ping.com
import PrintCenterModalContainer from "../../components/print-center-modal/print-center-modal.container";
import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-status.component";
import { requestForToken } from "../../firebase/firebase.utils";
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
import { selectBodyshop, selectInstanceConflict } from "../../redux/user/user.selectors";
import UpdateAlert from "../../components/update-alert/update-alert.component";
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
@@ -29,6 +28,7 @@ import WssStatusDisplayComponent from "../../components/wss-status-display/wss-s
import { selectAlerts } from "../../redux/application/application.selectors.js";
import { addAlerts } from "../../redux/application/application.actions.js";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const JobsPage = lazy(() => import("../jobs/jobs.page"));

View File

@@ -340,6 +340,7 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
args: [],
imex: () => {
window.$crisp.push(["set", "user:company", [payload.shopname]]);
window.$crisp.push(["set", "session:segments", [[`region:${payload.region_config}`]]]);
if (authRecord[0] && authRecord[0].user.validemail) {
window.$crisp.push(["set", "user:email", [authRecord[0].user.email]]);
}

View File

@@ -1,4 +0,0 @@
import { configure } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
configure({ adapter: new Adapter() });

View File

@@ -1220,7 +1220,8 @@
"errors": {
"fcm": "You must allow notification permissions to have real time messaging. Click to try again.",
"notfound": "No record was found.",
"sizelimit": "The selected items exceed the size limit."
"sizelimit": "The selected items exceed the size limit.",
"submit-for-testing": "Error submitting Job for testing."
},
"itemtypes": {
"contract": "CC Contract",
@@ -1322,7 +1323,8 @@
"partnernotrunning": "{{app}} has detected that the partner is not running. Please ensure it is running to enable full functionality.",
"rbacunauth": "You are not authorized to view this content. Please reach out to your shop manager to change your access level.",
"unsavedchanges": "You have unsaved changes.",
"unsavedchangespopup": "You have unsaved changes. Are you sure you want to leave?"
"unsavedchangespopup": "You have unsaved changes. Are you sure you want to leave?",
"submit-for-testing": "Submitted Job for testing successfully."
},
"validation": {
"dateRangeExceeded": "The date range has been exceeded.",
@@ -2314,7 +2316,8 @@
"duplicate": "Duplicate this Job",
"duplicatenolines": "Duplicate this Job without Repair Data",
"newcccontract": "Create Courtesy Car Contract",
"void": "Void Job"
"void": "Void Job",
"submit-for-testing": "Submit for Testing"
},
"jobsdetail": {
"claimdetail": "Claim Details",
@@ -3679,7 +3682,8 @@
"signinerror": {
"auth/user-disabled": "User account disabled. ",
"auth/user-not-found": "A user with this email does not exist.",
"auth/wrong-password": "The email and password combination you provided is incorrect."
"auth/wrong-password": "The email and password combination you provided is incorrect.",
"auth/invalid-email": "A user with this email does not exist."
}
}
},

View File

@@ -1220,7 +1220,8 @@
"errors": {
"fcm": "",
"notfound": "",
"sizelimit": ""
"sizelimit": "",
"submit-for-testing": ""
},
"itemtypes": {
"contract": "",
@@ -1322,7 +1323,8 @@
"partnernotrunning": "",
"rbacunauth": "",
"unsavedchanges": "Usted tiene cambios no guardados.",
"unsavedchangespopup": ""
"unsavedchangespopup": "",
"submit-for-testing": ""
},
"validation": {
"dateRangeExceeded": "",
@@ -2314,7 +2316,8 @@
"duplicate": "",
"duplicatenolines": "",
"newcccontract": "",
"void": ""
"void": "",
"submit-for-testing": ""
},
"jobsdetail": {
"claimdetail": "Detalles de la reclamación",
@@ -3679,7 +3682,8 @@
"signinerror": {
"auth/user-disabled": "",
"auth/user-not-found": "",
"auth/wrong-password": ""
"auth/wrong-password": "",
"auth/invalid-email": ""
}
}
},

View File

@@ -1220,7 +1220,8 @@
"errors": {
"fcm": "",
"notfound": "",
"sizelimit": ""
"sizelimit": "",
"submit-for-testing": ""
},
"itemtypes": {
"contract": "",
@@ -1322,7 +1323,9 @@
"partnernotrunning": "",
"rbacunauth": "",
"unsavedchanges": "Vous avez des changements non enregistrés.",
"unsavedchangespopup": ""
"unsavedchangespopup": "",
"submit-for-testing": ""
},
"validation": {
"dateRangeExceeded": "",
@@ -2314,7 +2317,8 @@
"duplicate": "",
"duplicatenolines": "",
"newcccontract": "",
"void": ""
"void": "",
"submit-for-testing": ""
},
"jobsdetail": {
"claimdetail": "Détails de la réclamation",
@@ -3679,7 +3683,8 @@
"signinerror": {
"auth/user-disabled": "",
"auth/user-not-found": "",
"auth/wrong-password": ""
"auth/wrong-password": "",
"auth/invalid-email": ""
}
}
},

View File

@@ -1,139 +0,0 @@
export const MockBodyshop = {
address1: "123 Fake St",
address2: "Unit #100",
city: "Vancouver",
country: "Canada",
created_at: "2019-12-10T20:03:06.420853+00:00",
email: "snaptsoft@gmail.com",
federal_tax_id: "GST10150492",
id: "52b7357c-0edd-4c95-85c3-dfdbcdfad9ac",
insurance_vendor_id: "F123456",
logo_img_path: "https://www.snapt.ca/assets/logo-placeholder.png",
md_ro_statuses: {
statuses: [
"Open",
"Scheduled",
"Arrived",
"Repair Plan",
"Parts",
"Body",
"Prep",
"Paint",
"Reassembly",
"Sublet",
"Detail",
"Completed",
"Delivered",
"Invoiced",
"Exported"
],
open_statuses: ["Open", "Scheduled", "Arrived", "Repair Plan", "Parts", "Body", "Prep", "Paint"],
default_arrived: "Arrived",
default_exported: "Exported",
default_imported: "Open",
default_invoiced: "Invoiced",
default_completed: "Completed",
default_delivered: "Delivered",
default_scheduled: "Scheduled"
},
md_order_statuses: {
statuses: ["Ordered", "Received", "Canceled", "Backordered"],
default_bo: "Backordered",
default_ordered: "Ordered",
default_canceled: "Canceled",
default_received: "Received"
},
shopname: "Testing Collision",
state: "BC",
state_tax_id: "PST1000-2991",
updated_at: "2020-03-23T22:06:03.509544+00:00",
zip_post: "V6B 1M9",
region_config: "CA_BC",
md_responsibility_centers: {
costs: [
"Aftermarket",
"ATS",
"Body",
"Detail",
"Daignostic",
"Electrical",
"Chrome",
"Frame",
"Mechanical",
"Refinish",
"Structural",
"Existing",
"Glass",
"LKQ",
"OEM",
"OEM Partial",
"Re-cored",
"Remanufactured",
"Other",
"Sublet",
"Towing"
],
profits: [
"Aftermarket",
"ATS",
"Body",
"Detail",
"Daignostic",
"Electrical",
"Chrome",
"Frame",
"Mechanical",
"Refinish",
"Structural",
"Existing",
"Glass",
"LKQ",
"OEM",
"OEM Partial",
"Re-cored",
"Remanufactured",
"Other",
"Sublet",
"Towing"
],
defaults: {
ATS: "ATS",
LAB: "Body",
LAD: "Diagnostic",
LAE: "Electrical",
LAF: "Frame",
LAG: "Glass",
LAM: "Mechanical",
LAR: "Refinish",
LAS: "Structural",
LAU: "Detail",
PAA: "Aftermarket",
PAC: "Chrome",
PAL: "LKQ",
PAM: "Remanufactured",
PAN: "OEM",
PAO: "Other",
PAP: "OEM Partial",
PAR: "16",
TOW: "Towing"
}
},
employees: [
{
id: "075b744c-8919-49ca-abb2-ccd51040326d",
first_name: "Patrick",
last_name: "BODY123",
employee_number: "101",
cost_center: "Body",
__typename: "employees"
},
{
id: "8cc787d3-1cfe-49d3-8a15-8469cd5c2e41",
first_name: "Patrick",
last_name: "Painter",
employee_number: "10211",
cost_center: "REFINISH",
__typename: "employees"
}
]
};

View File

@@ -0,0 +1,14 @@
// Button.test.jsx
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { Button } from "antd";
import "antd/dist/reset.css"; // Optional: include if needed for styling reset
describe("AntD Button", () => {
it("renders with correct text", () => {
render(<Button>Click me</Button>);
const button = screen.getByRole("button", { name: /click me/i });
expect(button).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,6 @@
import { test, expect } from "@playwright/test";
test("homepage loads correctly", async ({ page }) => {
await page.goto("/");
await expect(page.locator("h1")).toContainText("ImEX Online");
});

View File

@@ -0,0 +1,28 @@
import { test, expect } from "@playwright/test";
import { login } from "./utils/login";
test.describe("SignInComponent", () => {
test("successfully logs in with valid credentials", async ({ page }) => {
const email = process.env.TEST_USERNAME;
const password = process.env.TEST_PASSWORD;
await login(page, { email, password });
// Additional assertions after login (optional)
await expect(page).toHaveURL(/\/manage\//);
});
test("displays error on invalid credentials", async ({ page }) => {
await page.goto("/"); // Adjust if login route differs
// Fill form with invalid credentials
await page.fill('input[placeholder="Username"]', "wronguser@example.com");
await page.fill('input[placeholder="Password"]', "wrongpassword");
await page.click("button.login-btn");
// Check for error alert
const alert = page.locator(".ant-alert-error");
await expect(alert).toBeVisible();
await expect(alert).toContainText("A user with this email does not exist.");
});
});

View File

@@ -0,0 +1,21 @@
import { expect } from "@playwright/test";
export async function login(page, { email, password }) {
// Navigate to the login page
await page.goto("/"); // Adjust if your login route differs (e.g., '/login')
// Fill email field
await page.fill('input[placeholder="Username"]', email); // Matches Ant Design Input placeholder
// Fill password field
await page.fill('input[placeholder="Password"]', password);
// Click login button
await page.click("button.login-btn");
// Wait for navigation or success indicator (e.g., redirect to /manage/)
await page.waitForURL(/\/manage\//, { timeout: 10000 }); // Adjust based on redirect
// Verify successful login (e.g., check for a dashboard element)
await expect(page.locator("text=Manage")).toBeVisible(); // Adjust to your apps post-login UI
}

5
client/tests/setup.js Normal file
View File

@@ -0,0 +1,5 @@
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
import "@testing-library/jest-dom";
afterEach(() => cleanup());

27
client/tests/setupI18n.js Normal file
View File

@@ -0,0 +1,27 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en_Translation from "../src/translations/en_us/common.json";
import es_Translation from "../src/translations/es/common.json";
import fr_Translation from "../src/translations/fr/common.json";
const resources = {
"en-US": en_Translation,
"fr-CA": fr_Translation,
"es-MX": es_Translation
};
i18n.use(initReactI18next).init({
resources,
lng: "en-US", // Default to en-US for tests (no LanguageDetector)
fallbackLng: "en-US",
debug: false, // Disable debug in tests
react: {
useSuspense: false // Disable Suspense for Vitest
},
interpolation: {
escapeValue: false, // React handles XSS
skipOnVariables: false
}
});
export default i18n;

View File

@@ -191,11 +191,13 @@ export default defineConfig({
"@sentry/react": ["@sentry/react"],
"@splitsoftware/splitio-react": ["@splitsoftware/splitio-react"],
logrocket: ["logrocket"],
"@firebase/analytics": ["@firebase/analytics"],
"@firebase/app": ["@firebase/app"],
"@firebase/firestore": ["@firebase/firestore"],
"@firebase/auth": ["@firebase/auth"],
"@firebase/messaging": ["@firebase/messaging"],
firebase: [
"@firebase/analytics",
"@firebase/app",
"@firebase/firestore",
"@firebase/auth",
"@firebase/messaging"
],
markerjs2: ["markerjs2"],
"@apollo/client": ["@apollo/client"],
"libphonenumber-js": ["libphonenumber-js"]
@@ -218,7 +220,13 @@ export default defineConfig({
"react-router-dom",
"dayjs",
"redux",
"react-redux"
"react-redux",
"@firebase/app",
"@firebase/analytics",
"@firebase/firestore",
"@firebase/auth",
"@firebase/messaging",
"@firebase/util"
],
esbuildOptions: {
// Update for Vite 6: Use proper file extensions

19
client/vitest.config.js Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: "./tests/setup.js",
testTimeout: 10000 // 10 seconds (in milliseconds)
},
css: {
preprocessorOptions: {
scss: {
silenceDeprecations: ["import"] // Suppress @import warnings
}
}
}
});

View File

@@ -117,6 +117,7 @@ services:
aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key
aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-job-totals --create-bucket-configuration LocationConstraint=ca-central-1
"
# Node App: The Main IMEX API
node-app:

View File

@@ -0,0 +1,77 @@
const fs = require("fs");
const path = require("path");
const logger = require("./server/utils/logger"); // Assuming same logger utility
const s3Client = require("./server/utils/s3"); // Using the S3 client utilities with LocalStack support
// Set bucket name for development with LocalStack
const S3_BUCKET_NAME = "imex-job-totals";
// Set fixtures directory path
const FIXTURES_DIR = path.join(__dirname, "server", "job", "test", "fixtures", "job-totals");
const ensureFixturesDirectory = () => {
if (!fs.existsSync(FIXTURES_DIR)) {
fs.mkdirSync(FIXTURES_DIR, { recursive: true });
logger.log(`Created fixtures directory: ${FIXTURES_DIR}`, "info");
}
};
const downloadJsonFiles = async (userInfo = { email: "system" }) => {
logger.log(`Starting download of JSON files from bucket: ${S3_BUCKET_NAME}`, "debug", userInfo.email);
try {
ensureFixturesDirectory();
const contents = await s3Client.listFilesInS3Bucket(S3_BUCKET_NAME);
if (!contents.length) {
logger.log("No files found in bucket", "info", userInfo.email);
return;
}
logger.log(`Found ${contents.length} files in bucket`, "info", userInfo.email);
for (const item of contents) {
if (!item.Key.endsWith(".json")) {
logger.log(`Skipping non-JSON file: ${item.Key}`, "debug", userInfo.email);
continue;
}
logger.log(`Downloading: ${item.Key}`, "debug", userInfo.email);
const fileData = await s3Client.downloadFileFromS3({
bucketName: S3_BUCKET_NAME,
key: item.Key
});
const fileContent = await fileData.transformToString();
const fileName = path.basename(item.Key);
const filePath = path.join(FIXTURES_DIR, fileName);
fs.writeFileSync(filePath, fileContent);
logger.log(`Saved: ${filePath}`, "info", userInfo.email);
}
logger.log("Download completed successfully", "info", userInfo.email);
} catch (error) {
logger.log("Failed to download JSON files", "error", userInfo.email, null, {
error: error?.message,
stack: error?.stack
});
throw error; // Re-throw to trigger process exit with error code
}
};
// Run the download if script is executed directly
if (require.main === module) {
(async () => {
try {
await downloadJsonFiles();
console.log("Script completed successfully");
process.exit(0); // Explicitly exit with success code
} catch (error) {
console.error("Fatal error downloading files:", error);
process.exit(1); // Explicitly exit with error code
}
})();
}
module.exports = downloadJsonFiles;

View File

@@ -965,6 +965,7 @@
- insurance_vendor_id
- intakechecklist
- intellipay_config
- intellipay_merchant_id
- jc_hourly_rates
- jobsizelimit
- last_name_first
@@ -1023,6 +1024,7 @@
- template_header
- textid
- timezone
- tours_enabled
- tt_allow_post_to_invoiced
- tt_enforce_hours_for_tech_console
- updated_at

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."bodyshops" add column "intellipay_merchant_id" text
-- null unique;

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" add column "intellipay_merchant_id" text
null unique;

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."bodyshops" add column "tours_enabled" boolean
-- not null default 'true';

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" add column "tours_enabled" boolean
not null default 'true';

2863
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,17 +10,20 @@
"setup": "rm -rf node_modules && npm i && cd client && rm -rf node_modules && npm i",
"setup:win": "rimraf node_modules && npm i && cd client && rimraf node_modules && npm i",
"start": "node server.js",
"makeitpretty": "prettier --write \"**/*.{css,js,json,jsx,scss}\""
"makeitpretty": "prettier --write \"**/*.{css,js,json,jsx,scss}\"",
"test:unit": "vitest run",
"test:watch": "vitest",
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
},
"dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.772.0",
"@aws-sdk/client-elasticache": "^3.772.0",
"@aws-sdk/client-s3": "^3.772.0",
"@aws-sdk/client-secrets-manager": "^3.772.0",
"@aws-sdk/client-ses": "^3.772.0",
"@aws-sdk/credential-provider-node": "^3.772.0",
"@aws-sdk/lib-storage": "^3.743.0",
"@aws-sdk/s3-request-presigner": "^3.731.1",
"@aws-sdk/client-cloudwatch-logs": "^3.782.0",
"@aws-sdk/client-elasticache": "^3.782.0",
"@aws-sdk/client-s3": "^3.782.0",
"@aws-sdk/client-secrets-manager": "^3.782.0",
"@aws-sdk/client-ses": "^3.782.0",
"@aws-sdk/credential-provider-node": "^3.782.0",
"@aws-sdk/lib-storage": "^3.782.0",
"@aws-sdk/s3-request-presigner": "^3.782.0",
"@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0",
@@ -30,8 +33,7 @@
"bee-queue": "^1.7.1",
"better-queue": "^3.8.12",
"bluebird": "^3.7.2",
"body-parser": "^1.20.3",
"bullmq": "^5.44.4",
"bullmq": "^5.48.0",
"chart.js": "^4.4.8",
"cloudinary": "^2.6.0",
"compression": "^1.8.0",
@@ -39,7 +41,7 @@
"cors": "2.8.5",
"crisp-status-reporter": "^1.2.2",
"csrf": "^3.1.0",
"dd-trace": "^5.43.0",
"dd-trace": "^5.45.0",
"dinero.js": "^1.9.1",
"dotenv": "^16.4.5",
"express": "^4.21.1",
@@ -67,7 +69,7 @@
"socket.io": "^4.8.1",
"socket.io-adapter": "^2.5.5",
"ssh2-sftp-client": "^11.0.0",
"twilio": "^5.5.1",
"twilio": "^5.5.2",
"uuid": "^11.1.0",
"winston": "^3.17.0",
"winston-cloudwatch": "^6.3.0",
@@ -75,13 +77,16 @@
"xmlbuilder2": "^3.1.1"
},
"devDependencies": {
"@eslint/js": "^9.23.0",
"@eslint/js": "^9.24.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"eslint": "^9.23.0",
"eslint-plugin-react": "^7.37.4",
"eslint": "^9.24.0",
"eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0",
"mock-require": "^3.0.3",
"p-limit": "^3.1.0",
"prettier": "^3.5.3",
"source-map-explorer": "^2.5.2"
"source-map-explorer": "^2.5.2",
"supertest": "^7.1.0",
"vitest": "^3.1.1"
}
}

View File

@@ -16,7 +16,6 @@ const cors = require("cors");
const http = require("http");
const Redis = require("ioredis");
const express = require("express");
const bodyParser = require("body-parser");
const compression = require("compression");
const cookieParser = require("cookie-parser");
const { Server } = require("socket.io");
@@ -84,8 +83,8 @@ const SOCKETIO_CORS_ORIGIN_DEV = ["http://localhost:3333", "https://localhost:33
const applyMiddleware = ({ app }) => {
app.use(compression());
app.use(cookieParser());
app.use(bodyParser.json({ limit: "50mb" }));
app.use(bodyParser.urlencoded({ limit: "50mb", extended: true }));
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ limit: "50mb", extended: true }));
app.use(cors({ credentials: true, exposedHeaders: ["set-cookie"] }));
// Helper middleware

View File

@@ -39,12 +39,14 @@ exports.createShop = async (req, res) => {
try {
const result = await client.request(
`mutation INSERT_BODYSHOPS($bs: bodyshops_insert_input!){
insert_bodyshops_one(object:$bs){
id
}
}`,
`mutation INSERT_BODYSHOPS($bs: bodyshops_insert_input!) {
insert_bodyshops_one(object: $bs) {
id
vendors {
id
}
}
}`,
{
bs: {
...bodyshop,
@@ -54,12 +56,39 @@ exports.createShop = async (req, res) => {
{ countertype: "ihbnum", count: 1 },
{ countertype: "paymentnum", count: 1 }
]
},
vendors: {
data: [{ name: "In-House" }]
}
}
}
);
res.json(result);
const bodyshopId = result.insert_bodyshops_one.id;
const vendorId = result.insert_bodyshops_one.vendors[0].id;
if (!bodyshopId || !vendorId) {
throw new Error("Failed to create bodyshop or vendor");
}
const updateBodyshop = await client.request(
`mutation UPDATE_BODYSHOP($id: uuid!, $inhousevendorid: uuid!) {
update_bodyshops_by_pk(pk_columns: { id: $id }, _set: { inhousevendorid: $inhousevendorid }) {
id
}
}`,
{
id: bodyshopId,
inhousevendorid: vendorId
}
);
res.status(200).json(updateBodyshop);
} catch (error) {
logger.log("admin-create-shop-error", "error", req.user.email, null, {
message: error.message,
stack: error.stack,
request: req.body,
ioadmin: true
});
res.status(500).json(error);
}
};

View File

@@ -55,7 +55,12 @@ exports.default = async (req, res) => {
const csv = converter.json2csv(shopList, { emptyFieldValue: "" });
emailer
.sendTaskEmail({
to: ["patrick.fic@convenient-brands.com", "bradley.rhoades@convenient-brands.com", "jrome@rometech.com"],
to: [
"patrick.fic@convenient-brands.com",
"bradley.rhoades@convenient-brands.com",
"jrome@rometech.com",
"ivana@imexsystems.ca"
],
subject: `RO Usage Report - ${moment().format("MM/DD/YYYY")}`,
text: `
Usage Report for ${moment().format("MM/DD/YYYY")} for Rome Online Customers.

View File

@@ -1,5 +1,3 @@
const moment = require("moment");
const { default: RenderInstanceManager } = require("../utils/instanceMgr");
const { header, end, start } = require("./html");
// Required Strings
@@ -7,19 +5,6 @@ const { header, end, start } = require("./html");
// - subHeader - The subheader of the email
// - body - The body of the email
// Optional Strings (Have default values)
// - footer - The footer of the email
// - dateLine - The date line of the email
const defaultFooter = () => {
return RenderInstanceManager({
imex: "ImEX Online Collision Repair Management System",
rome: "Rome Technologies"
});
};
const now = () => moment().format("MM/DD/YYYY @ hh:mm a");
/**
* Generate the email template
* @param strings
@@ -32,81 +17,48 @@ const generateEmailTemplate = (strings) => {
header +
start +
`
<table class="row">
<tbody>
<tr>
<th class="small-12 large-12 columns first last">
<table>
<tbody>
<tr>
<td>
<h6 style="text-align:left"><strong>${strings.header}</strong></h6>
</td>
</tr>
<tr>
<td>
<p style="font-size:90%">${strings.subHeader}</p>
</td>
</tr>
</tbody>
</table>
</th>
</tr>
</tbody>
</table>
<!-- Report Title -->
${
strings.header &&
`
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 8px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<h6 style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; color: inherit; word-wrap: normal; font-weight: normal; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 23px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; text-align: center;"><strong style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">${strings.header}</strong></h6>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
`
}
${
strings.subHeader &&
`
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 95%;">${strings.subHeader}</p>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
`
}
<!-- End Report Title -->
<!-- Task Detail -->
<table class="row">
<tbody>
<tr>
<th class="small-12 large-12 columns first last">
<table>
<tbody>
<tr>
<td>${strings.body}</td>
</tr>
</tbody>
</table>
</th>
</tr>
</tbody>
</table>
<!-- End Task Detail -->
<!-- Footer -->
<table class="row collapsed footer" id="non-printable">
<tbody>
<tr>
<th class="small-3 large-3 columns first">
<table>
<tbody>
<tr>
<td><p style="font-size:70%; padding-right:10px">${strings?.dateLine || now()}</p></td>
</tr>
</tbody>
</table>
</th>
<th class="small-6 large-6 columns">
<table>
<tbody>
<tr>
<td><p style="font-size:70%; text-align:center">${strings?.footer || defaultFooter()}</p></td>
</tr>
</tbody>
</table>
</th>
<th class="small-3 large-3 columns last">
<table>
<tbody>
<tr>
<td><p style="font-size:70%">&nbsp;</p></td>
</tr>
</tbody>
</table>
</th>
</tr>
</tbody>
</table>` +
end
${
strings.body &&
`
<!-- Report Detail -->
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
${strings.body}
</td></tr>
</tbody></table></th>
</tr></tbody></table>
<!-- End Report Detail -->
`
}
` +
end(strings.dateLine)
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -40,7 +40,9 @@ const logEmail = async (req, email) => {
to: req?.body?.to,
cc: req?.body?.cc,
subject: req?.body?.subject,
email
email,
errorMessage: error?.message,
errorStack: error?.stack
// info,
});
}
@@ -68,6 +70,7 @@ const sendServerEmail = async ({ subject, text }) => {
]
}
},
// eslint-disable-next-line no-unused-vars
(err, info) => {
logger.log("server-email-failure", err ? "error" : "debug", null, null, {
message: err?.message,
@@ -80,6 +83,108 @@ const sendServerEmail = async ({ subject, text }) => {
}
};
const sendWelcomeEmail = async ({ to, resetLink, dateLine, features, bcc }) => {
try {
await mailer.sendMail({
from: InstanceManager({
imex: `ImEX Online <noreply@imex.online>`,
rome: `Rome Online <noreply@romeonline.io>`
}),
to,
bcc,
subject: InstanceManager({
imex: "Welcome to the ImEX Online platform.",
rome: "Welcome to the Rome Online platform."
}),
html: generateEmailTemplate({
header: InstanceManager({
imex: "Welcome to the ImEX Online platform.",
rome: "Welcome to the Rome Online platform."
}),
subHeader: `Your ${InstanceManager({imex: features?.allAccess ? "ImEX Online": "ImEX Lite", rome: features?.allAccess ? "RO Manager" : "RO Basic"})} shop setup has been completed, and this email will include all the information you need to begin.`,
body: `
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">To finish setting up your account, visit this link and enter your desired password. <a href=${resetLink} style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">Reset Password</a></p>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">To access your ${InstanceManager({imex: features.allAccess ? "ImEX Online": "ImEX Lite", rome: features.allAccess ? "RO Manager" : "RO Basic"})} shop, visit <a href=${InstanceManager({imex: "https://imex.online/", rome: "https://romeonline.io/"})} style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">${InstanceManager({imex: "imex.online", rome: "romeonline.io"})}</a>. Your username is your email, and your password is what you previously set up. Contact support for additional logins.</p>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
${InstanceManager({
rome: `
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">To push estimates over from your estimating system, you must download the Web-Est EMS Unzipper & Rome Online Partner (Computers using Windows only). Here are some steps to help you get started.</p>
</td><tr>
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<ul style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 1%; padding-left: 30px;">
<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">Download and set up the Web-Est EMS Unzipper - <a href="https://help.imex.online/en/article/how-to-set-up-the-ems-unzip-downloader-on-web-est-n9hbcv/" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">How to setup the EMS Unzip Downloader on Web-Est</a></li>
<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">Download and set up Rome Online Partner - <a href="https://help.imex.online/en/article/setting-up-the-rome-online-partner-1xsw8tb/" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">Setting up the Rome Online Partner</a></li>
</ul>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">Once you successfully set up the partner, now it's time to do some initial in-product items: Please note, <b>an estimate must be exported from the estimating platform to use tours.</b></p>
</td><tr>
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<ul style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 1%; padding-left: 30px;">
<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">Send estimate from Web-Est to RO Basic - <a href="https://help.imex.online/en/article/how-to-send-estimates-from-web-est-to-the-management-system-ox0h9a/" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">How to setup the EMS Unzip Downloader on Web-Est</a></li>
<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">Once completed, learn how to use RO Basic by accessing the tours at the bottom middle of the screen (labeled “Training Tours”). These walkthroughs will show you how to navigate from creating an RO to closing an RO - <a href="https://www.youtube.com/watch?v=gcbSe5med0I" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">ROME Collision Management Youtube Training Videos</a></li>
</ul>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">If you need any assistance with setting up the programs, or if you want a dedicated Q&A session with one of our customer success specialists, schedule by clicking this link - <a href="https://rometech.zohobookings.com/#/PSAT" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">Rome Basic Training Booking</a></p>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">If you have additional questions or need any support, feel free to use the RO Basic support chat (blue chat box located in the bottom right corner) or give us a call at <a href="tel:14103576700" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">(410) 357-6700</a>. We are here to help make your experience seamless!</p>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
`
})}
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">In addition to the training tour, you can also book a live one-on-one demo to see exactly how our system can help streamline the repair process at your shop, schedule by clicking this link - <a href="https://outlook.office.com/bookwithme/user/0aa3ae2c6d59497d9f93fb72479848dc@imexsystems.ca/meetingtype/Qy7CsXl5MkuUJ0NRD7B1AA2?anonymous&ep=mlink" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">${InstanceManager({imex: "ImEX Lite", rome: "Rome Basic"})} Demo Booking</a></p>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 8px; width: 734px; padding-left: 0px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">Thanks,</p>
</td></tr>
</tbody></table></th>
</tr></tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 8px; width: 734px; padding-left: 0px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;">
<tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">The ${InstanceManager({imex: "ImEX Online", rome: "Rome Online"})} Team</p>
`,
dateLine
})
});
} catch (error) {
logger.log("server-email-failure", "error", null, null, { error });
}
};
const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachments }) => {
try {
mailer.sendMail(
@@ -93,6 +198,7 @@ const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachmen
...(type === "text" ? { text } : { html }),
attachments: attachments || null
},
// eslint-disable-next-line no-unused-vars
(err, info) => {
// (message, type, user, record, meta
logger.log("server-email", err ? "error" : "debug", null, null, { message: err?.message, stack: err?.stack });
@@ -143,22 +249,20 @@ const sendEmail = async (req, res) => {
to: req.body.to,
cc: req.body.cc,
subject: req.body.subject,
attachments:
[
...((req.body.attachments &&
req.body.attachments.map((a) => {
return {
filename: a.filename,
path: a.path
};
})) ||
[]),
...downloadedMedia.map((a) => {
attachments: [
...(req.body.attachments &&
req.body.attachments.map((a) => {
return {
path: a
filename: a.filename,
path: a.path
};
})
] || null,
})),
...downloadedMedia.map((a) => {
return {
path: a
};
})
],
html: isObject(req.body?.templateStrings) ? generateEmailTemplate(req.body.templateStrings) : req.body.html,
ses: {
// optional extra arguments for SendRawEmail
@@ -273,6 +377,7 @@ ${body.bounce?.bouncedRecipients.map(
)}
`
},
// eslint-disable-next-line no-unused-vars
(err, info) => {
logger.log("sns-error", err ? "error" : "debug", "api", null, {
errorMessage: err?.message,
@@ -294,5 +399,6 @@ module.exports = {
sendEmail,
sendServerEmail,
sendTaskEmail,
emailBounce
emailBounce,
sendWelcomeEmail
};

View File

@@ -17,11 +17,13 @@ const { formatTaskPriority } = require("../notifications/stringHelpers");
const tasksEmailQueue = taskEmailQueue();
// Cleanup function for the Tasks Email Queue
// eslint-disable-next-line no-unused-vars
const tasksEmailQueueCleanup = async () => {
try {
// Example async operation
// console.log("Performing Tasks Email Reminder process cleanup...");
await new Promise((resolve) => tasksEmailQueue.destroy(() => resolve()));
// eslint-disable-next-line no-unused-vars
} catch (err) {
// console.error("Tasks Email Reminder process cleanup failed:", err);
}
@@ -254,10 +256,15 @@ const tasksRemindEmail = async (req, res) => {
header: `${allTasks.length} Tasks require your attention`,
subHeader: `Please click on the Tasks below to view the Task.`,
dateLine,
body: `<ul>
body: `
<ul style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; margin: 1%; padding-left: 30px;">
${allTasks
.map((task) =>
`<li><a href="${InstanceEndpoints()}/manage/tasks/alltasks?taskid=${task.id}">${task.title} - Priority: ${formatTaskPriority(task.priority)} ${task.due_date ? `${formatDate(task.due_date)}` : ""} | Bodyshop: ${task.bodyshop.shopname}</a></li>`.trim()
`
<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">
<a href="${InstanceEndpoints()}/manage/tasks/alltasks?taskid=${task.id}" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">${task.title} - Priority: ${formatTaskPriority(task.priority)} ${task.due_date ? `${formatDate(task.due_date)}` : ""} | Bodyshop: ${task.bodyshop.shopname}</a>
</li>
`.trim()
)
.join("")}
</ul>`

View File

@@ -1,14 +1,10 @@
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const admin = require("firebase-admin");
const logger = require("../utils/logger");
//const { sendProManagerWelcomeEmail } = require("../email/sendemail");
const client = require("../graphql-client/graphql-client").client;
const serviceAccount = require(process.env.FIREBASE_ADMINSDK_JSON);
//const generateEmailTemplate = require("../email/generateTemplate");
const admin = require("firebase-admin");
const moment = require("moment-timezone");
const logger = require("../utils/logger");
const client = require("../graphql-client/graphql-client").client;
const { sendWelcomeEmail } = require("../email/sendemail");
const { GET_USER_BY_EMAIL } = require("../graphql-client/queries");
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
@@ -201,6 +197,94 @@ const unsubscribe = async (req, res) => {
}
};
const getWelcomeEmail = async (req, res) => {
const { authid, email, bcc } = req.body;
try {
// Fetch user from Firebase
const userRecord = await admin.auth().getUser(authid);
if (!userRecord) {
throw { status: 404, message: "User not found in Firebase." };
}
// Fetch user data from the database using GraphQL
const dbUserResult = await client.request(GET_USER_BY_EMAIL, { email: email.toLowerCase() });
const dbUser = dbUserResult?.users?.[0];
if (!dbUser) {
throw { status: 404, message: "User not found in database." };
}
// Validate email before proceeding
if (!dbUser.validemail) {
logger.log("admin-send-welcome-email-skip", "debug", req.user.email, null, {
message: "User email is not valid, skipping email.",
email
});
return res.status(200).json({ message: "User email is not valid, email not sent." });
}
// Generate password reset link
const resetLink = await admin.auth().generatePasswordResetLink(dbUser.email);
// Send welcome email
await sendWelcomeEmail({
to: dbUser.email,
resetLink,
dateLine: moment().tz(dbUser.associations?.[0]?.bodyshop?.timezone).format("MM/DD/YYYY @ hh:mm a"),
features: dbUser.associations?.[0]?.bodyshop?.features,
bcc
});
// Log success and return response
logger.log("admin-send-welcome-email", "debug", req.user.email, null, {
request: req.body,
ioadmin: true,
emailSentTo: email
});
return res.status(200).json({ message: "Welcome email sent successfully." });
} catch (error) {
logger.log("admin-send-welcome-email-error", "ERROR", req.user.email, null, { error });
if (!res.headersSent) {
return res.status(error.status || 500).json({
message: error.message || "Error sending welcome email.",
error
});
}
}
};
const getResetLink = async (req, res) => {
const { authid, email } = req.body;
logger.log("admin-reset-link", "debug", req.user.email, null, { authid, email });
try {
// Fetch user from Firebase
const userRecord = await admin.auth().getUser(authid);
if (!userRecord) {
throw { status: 404, message: "User not found in Firebase." };
}
// Generate password reset link
const resetLink = await admin.auth().generatePasswordResetLink(email);
// Log success and return response
logger.log("admin-reset-link-success", "debug", req.user.email, null, {
request: req.body,
ioadmin: true
});
return res.status(200).json({ message: "Reset link generated successfully.", resetLink });
} catch (error) {
return res.status(error.status || 500).json({
message: error.message || "Error generating reset link.",
error
});
}
};
module.exports = {
admin,
createUser,
@@ -208,23 +292,7 @@ module.exports = {
getUser,
sendNotification,
subscribe,
unsubscribe
unsubscribe,
getWelcomeEmail,
getResetLink
};
//Admin claims code.
// const uid = "JEqqYlsadwPEXIiyRBR55fflfko1";
// admin
// .auth()
// .getUser(uid)
// .then((user) => {
// console.log(user);
// admin.auth().setCustomUserClaims(uid, {
// ioadmin: true,
// "https://hasura.io/jwt/claims": {
// "x-hasura-default-role": "debug",
// "x-hasura-allowed-roles": ["admin"],
// "x-hasura-user-id": uid,
// },
// });
// });

View File

@@ -2768,6 +2768,9 @@ exports.GET_BODYSHOP_BY_ID = `
id
md_order_statuses
shopname
imexshopid
intellipay_config
state
}
}
`;
@@ -2829,3 +2832,42 @@ exports.GET_DOCUMENTS_BY_IDS = `
takenat
}
}`;
exports.GET_JOBID_BY_MERCHANTID_RONUMBER = `
query GET_JOBID_BY_MERCHANTID_RONUMBER($merchantID: String!, $roNumber: String!) {
jobs(where: {ro_number: {_eq: $roNumber}, bodyshop: {intellipay_merchant_id: {_eq: $merchantID}}}) {
id
shopid
bodyshop {
id
intellipay_config
email
}
}
}`;
exports.GET_BODYSHOP_BY_MERCHANT_ID = `
query GET_BODYSHOP_BY_MERCHANTID($merchantID: String!) {
bodyshops(where: {intellipay_merchant_id: {_eq: $merchantID}}) {
id
email
}
}`;
exports.GET_USER_BY_EMAIL = `
query GET_USER_BY_EMAIL($email: String!) {
users(where: {email: {_eq: $email}}) {
email
validemail
associations {
id
shopid
bodyshop {
id
convenient_company
features
timezone
}
}
}
}`;

View File

@@ -1,63 +1,22 @@
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const queries = require("../graphql-client/queries");
const Dinero = require("dinero.js");
const qs = require("query-string");
const axios = require("axios");
const moment = require("moment");
const logger = require("../utils/logger");
const { sendTaskEmail } = require("../email/sendemail");
const generateEmailTemplate = require("../email/generateTemplate");
const { isEmpty, isNumber } = require("lodash");
const handleCommentBasedPayment = require("./lib/handleCommentBasedPayment");
const handleInvoiceBasedPayment = require("./lib/handleInvoiceBasedPayment");
const logValidationError = require("./lib/handlePaymentValidationError");
const getCptellerUrl = require("./lib/getCptellerUrl");
const getShopCredentials = require("./lib/getShopCredentials");
const decodeComment = require("./lib/decodeComment");
const domain = process.env.NODE_ENV ? "secure" : "test";
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
const { InstanceRegion, InstanceEndpoints } = require("../utils/instanceMgr");
const client = new SecretsManagerClient({
region: InstanceRegion()
});
const gqlClient = require("../graphql-client/graphql-client").client;
const getShopCredentials = async (bodyshop) => {
// Development only
if (process.env.NODE_ENV === undefined) {
return {
merchantkey: process.env.INTELLIPAY_MERCHANTKEY,
apikey: process.env.INTELLIPAY_APIKEY
};
}
// Production code
if (bodyshop?.imexshopid) {
try {
const secret = await client.send(
new GetSecretValueCommand({
SecretId: `intellipay-credentials-${bodyshop.imexshopid}`,
VersionStage: "AWSCURRENT" // VersionStage defaults to AWSCURRENT if unspecified
})
);
return JSON.parse(secret.SecretString);
} catch (error) {
return {
error: error.message
};
}
}
};
const decodeComment = (comment) => {
try {
return comment ? JSON.parse(Buffer.from(comment, "base64").toString()) : null;
} catch (error) {
return null; // Handle malformed base64 string gracefully
}
};
exports.lightbox_credentials = async (req, res) => {
/**
* @description Get lightbox credentials for the shop
* @param req
* @param res
* @returns {Promise<void>}
*/
const lightboxCredentials = async (req, res) => {
const decodedComment = decodeComment(req.body?.comment);
const logMeta = {
iPayData: req.body?.iPayData,
@@ -73,17 +32,17 @@ exports.lightbox_credentials = async (req, res) => {
const shopCredentials = await getShopCredentials(req.body.bodyshop);
if (shopCredentials.error) {
if (shopCredentials?.error) {
logger.log("intellipay-credentials-error", "ERROR", req.user?.email, null, {
message: shopCredentials.error?.message,
...logMeta
});
res.json({
return res.json({
message: shopCredentials.error?.message,
type: "intellipay-credentials-error",
...logMeta
});
return;
}
try {
@@ -94,7 +53,10 @@ exports.lightbox_credentials = async (req, res) => {
...shopCredentials,
operatingenv: "businessattended"
}),
url: `https://${domain}.cpteller.com/api/custapi.cfc?method=autoterminal${req.body.refresh ? "_refresh" : ""}` //autoterminal_refresh
url: getCptellerUrl({
apiType: "custapi",
params: { method: `autoterminal${req.body.refresh ? "_refresh" : ""}` }
})
};
const response = await axios(options);
@@ -104,13 +66,14 @@ exports.lightbox_credentials = async (req, res) => {
...logMeta
});
res.send(response.data);
return res.send(response.data);
} catch (error) {
logger.log("intellipay-lightbox-error", "ERROR", req.user?.email, null, {
message: error?.message,
...logMeta
});
res.json({
return res.json({
message: error?.message,
type: "intellipay-lightbox-error",
...logMeta
@@ -118,7 +81,13 @@ exports.lightbox_credentials = async (req, res) => {
}
};
exports.payment_refund = async (req, res) => {
/**
* @description Process payment refund
* @param req
* @param res
* @returns {Promise<void>}
*/
const paymentRefund = async (req, res) => {
const decodedComment = decodeComment(req.body.iPayData?.comment);
const logResponseMeta = {
iPayData: req.body?.iPayData,
@@ -136,18 +105,17 @@ exports.payment_refund = async (req, res) => {
const shopCredentials = await getShopCredentials(req.body.bodyshop);
if (shopCredentials.error) {
if (shopCredentials?.error) {
logger.log("intellipay-refund-credentials-error", "ERROR", req.user?.email, null, {
credentialsError: shopCredentials.error,
...logResponseMeta
});
res.status(400).json({
return res.status(400).json({
credentialsError: shopCredentials.error,
type: "intellipay-refund-credentials-error",
...logResponseMeta
});
return;
}
try {
@@ -160,7 +128,11 @@ exports.payment_refund = async (req, res) => {
paymentid: req.body.paymentid,
amount: req.body.amount
}),
url: `https://${domain}.cpteller.com/api/26/webapi.cfc?method=payment_refund`
url: getCptellerUrl({
apiType: "webapi",
version: "26",
params: { method: "payment_refund" }
})
};
logger.log("intellipay-refund-options-prepared", "DEBUG", req.user?.email, null, {
@@ -175,13 +147,14 @@ exports.payment_refund = async (req, res) => {
...logResponseMeta
});
res.send(response.data);
return res.send(response.data);
} catch (error) {
logger.log("intellipay-refund-error", "ERROR", req.user?.email, null, {
message: error?.message,
...logResponseMeta
});
res.status(500).json({
return res.status(500).json({
message: error?.message,
type: "intellipay-refund-error",
...logResponseMeta
@@ -189,7 +162,13 @@ exports.payment_refund = async (req, res) => {
}
};
exports.generate_payment_url = async (req, res) => {
/**
* @description Generate payment URL for the shop
* @param req
* @param res
* @returns {Promise<void>}
*/
const generatePaymentUrl = async (req, res) => {
const decodedComment = decodeComment(req.body.comment);
const logResponseMeta = {
iPayData: req.body?.iPayData,
@@ -209,17 +188,17 @@ exports.generate_payment_url = async (req, res) => {
const shopCredentials = await getShopCredentials(req.body.bodyshop);
if (shopCredentials.error) {
if (shopCredentials?.error) {
logger.log("intellipay-generate-payment-url-credentials-error", "ERROR", req.user?.email, null, {
message: shopCredentials.error?.message,
...logResponseMeta
});
res.status(400).json({
return res.status(400).json({
message: shopCredentials.error?.message,
type: "intellipay-generate-payment-url-credentials-error",
...logResponseMeta
});
return;
}
try {
@@ -234,7 +213,10 @@ exports.generate_payment_url = async (req, res) => {
invoice: req.body.invoice,
createshorturl: true
}),
url: `https://${domain}.cpteller.com/api/custapi.cfc?method=generate_lightbox_url`
url: getCptellerUrl({
apiType: "custapi",
params: { method: "generate_lightbox_url" }
})
};
logger.log("intellipay-generate-payment-url-options-prepared", "DEBUG", req.user?.email, null, {
@@ -250,18 +232,25 @@ exports.generate_payment_url = async (req, res) => {
...logResponseMeta
});
res.send(response.data);
return res.send(response.data);
} catch (error) {
logger.log("intellipay-generate-payment-url-error", "ERROR", req.user?.email, null, {
message: error?.message,
...logResponseMeta
});
res.status(500).json({ message: error?.message, ...logResponseMeta });
return res.status(500).json({ message: error?.message, ...logResponseMeta });
}
};
//Reference: https://intellipay.com/dist/webapi26.html#operation/fee
exports.checkfee = async (req, res) => {
/**
* @description Check the fee for a given amount
* Reference: https://intellipay.com/dist/webapi26.html#operation/fee
* @param req
* @param res
* @returns {Promise<void>}
*/
const checkFee = async (req, res) => {
const logResponseMeta = {
bodyshop: {
id: req.body?.bodyshop?.id,
@@ -274,24 +263,24 @@ exports.checkfee = async (req, res) => {
logger.log("intellipay-checkfee-request-received", "DEBUG", req.user?.email, null, logResponseMeta);
if (!req.body.amount || req.body.amount <= 0) {
if (!isNumber(req.body?.amount) || req.body?.amount <= 0) {
logger.log("intellipay-checkfee-skip", "DEBUG", req.user?.email, null, {
message: "Amount is zero or undefined, skipping fee check.",
...logResponseMeta
});
res.json({ fee: 0 });
return;
return res.json({ fee: 0 });
}
const shopCredentials = await getShopCredentials(req.body.bodyshop);
if (shopCredentials.error) {
if (shopCredentials?.error) {
logger.log("intellipay-checkfee-credentials-error", "ERROR", req.user?.email, null, {
message: shopCredentials.error?.message,
...logResponseMeta
});
res.status(400).json({ error: shopCredentials.error?.message, ...logResponseMeta });
return;
return res.status(400).json({ error: shopCredentials.error?.message, ...logResponseMeta });
}
try {
@@ -312,7 +301,7 @@ exports.checkfee = async (req, res) => {
},
{ sort: false } // Ensure query string order is preserved
),
url: `https://${domain}.cpteller.com/api/26/webapi.cfc`
url: getCptellerUrl({ apiType: "webapi", version: "26" })
};
logger.log("intellipay-checkfee-options-prepared", "DEBUG", req.user?.email, null, {
@@ -327,196 +316,92 @@ exports.checkfee = async (req, res) => {
message: response.data?.error,
...logResponseMeta
});
res.status(400).json({
return res.status(400).json({
error: response.data?.error,
type: "intellipay-checkfee-api-error",
...logResponseMeta
});
} else if (response.data < 0) {
}
if (response.data < 0) {
logger.log("intellipay-checkfee-negative-fee", "ERROR", req.user?.email, null, {
message: "Fee amount returned is negative.",
...logResponseMeta
});
res.json({
return res.json({
error: "Fee amount negative. Check API credentials & account configuration.",
...logResponseMeta,
type: "intellipay-checkfee-negative-fee"
});
} else {
logger.log("intellipay-checkfee-success", "DEBUG", req.user?.email, null, {
fee: response.data,
...logResponseMeta
});
res.json({ fee: response.data, ...logResponseMeta });
}
logger.log("intellipay-checkfee-success", "DEBUG", req.user?.email, null, {
fee: response.data,
...logResponseMeta
});
return res.json({ fee: response.data, ...logResponseMeta });
} catch (error) {
logger.log("intellipay-checkfee-error", "ERROR", req.user?.email, null, {
message: error?.message,
...logResponseMeta
});
res.status(500).json({ error: error?.message, logResponseMeta });
return res.status(500).json({ error: error?.message, logResponseMeta });
}
};
exports.postback = async (req, res) => {
/**
* @description Handle the postback from Intellipay
* @param req
* @param res
* @returns {Promise<void>}
*/
/**
* Handle the postback from Intellipay payment system
*/
const postBack = async (req, res) => {
const { body: values } = req;
const decodedComment = decodeComment(values?.comment);
const logResponseMeta = {
bodyshop: {
id: req.body?.bodyshop?.id,
imexshopid: req.body?.bodyshop?.imexshopid,
name: req.body?.bodyshop?.shopname,
state: req.body?.bodyshop?.state
},
iprequest: values,
decodedComment
};
const ipMapping = req.body?.bodyshop?.intellipay_config?.payment_map;
const logMeta = { iprequest: values, decodedComment };
logger.log("intellipay-postback-received", "DEBUG", req.user?.email, null, logResponseMeta);
logger.log("intellipay-postback-received", "DEBUG", "api", null, logMeta);
try {
if ((!values.invoice || values.invoice === "") && !decodedComment) {
//invoice is specified through the pay link. Comment by IO.
logger.log("intellipay-postback-ignored", "DEBUG", req.user?.email, null, {
// Handle empty/invalid requests
if (isEmpty(values?.invoice) && !decodedComment) {
logger.log("intellipay-postback-ignored", "DEBUG", "api", null, {
message: "No invoice or comment provided",
...logResponseMeta
...logMeta
});
res.sendStatus(200);
return;
return res.sendStatus(200);
}
// Process payment based on data type
if (decodedComment) {
//Shifted the order to have this first to retain backwards compatibility for the old style of short link.
//This has been triggered by IO and may have multiple jobs.
const parsedComment = decodedComment;
logger.log("intellipay-postback-parsed-comment", "DEBUG", req.user?.email, null, {
parsedComment,
...logResponseMeta
});
//Adding in the user email to the short pay email.
//Need to check this to ensure backwards compatibility for clients that don't update.
const partialPayments = Array.isArray(parsedComment) ? parsedComment : parsedComment.payments;
// Fetch jobs by job IDs
const jobs = await gqlClient.request(queries.GET_JOBS_BY_PKS, {
ids: partialPayments.map((p) => p.jobid)
});
logger.log("intellipay-postback-jobs-fetched", "DEBUG", req.user?.email, null, {
jobs,
parsedComment,
...logResponseMeta
});
// Insert new payments
const paymentResult = await gqlClient.request(queries.INSERT_NEW_PAYMENT, {
paymentInput: partialPayments.map((p) => ({
amount: p.amount,
transactionid: values.authcode,
payer: "Customer",
type: ipMapping ? ipMapping[(values.cardtype || "").toLowerCase()] || values.cardtype : values.cardtype,
jobid: p.jobid,
date: moment(Date.now()),
payment_responses: {
data: {
amount: values.total,
bodyshopid: jobs.jobs[0].shopid,
jobid: p.jobid,
declinereason: "Approved",
ext_paymentid: values.paymentid,
successful: true,
response: values
}
}
}))
});
logger.log("intellipay-postback-payment-success", "DEBUG", req.user?.email, null, {
paymentResult,
jobs,
parsedComment,
...logResponseMeta
});
if (values.origin === "OneLink" && parsedComment.userEmail) {
sendTaskEmail({
to: parsedComment.userEmail,
subject: `New Payment(s) Received - RO ${jobs.jobs.map((j) => j.ro_number).join(", ")}`,
type: "html",
html: generateEmailTemplate({
header: "New Payment(s) Received",
subHeader: "",
body: jobs.jobs
.map(
(job) =>
`Reference: <a href="${InstanceEndpoints()}/manage/jobs/${job.id}">${job.ro_number || "N/A"}</a> | ${job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim()} | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()} | $${partialPayments.find((p) => p.jobid === job.id).amount}`
)
.join("<br/>")
})
}).catch((error) => {
logger.log("intellipay-postback-email-error", "ERROR", req.user?.email, null, {
message: error.message,
jobs,
paymentResult,
...logResponseMeta
});
});
}
res.sendStatus(200);
} else if (values.invoice) {
const job = await gqlClient.request(queries.GET_JOB_BY_PK, {
id: values.invoice
});
logger.log("intellipay-postback-invoice-job-fetched", "DEBUG", req.user?.email, null, {
job,
...logResponseMeta
});
const paymentResult = await gqlClient.request(queries.INSERT_NEW_PAYMENT, {
paymentInput: {
amount: values.total,
transactionid: values.authcode,
payer: "Customer",
type: ipMapping ? ipMapping[(values.cardtype || "").toLowerCase()] || values.cardtype : values.cardtype,
jobid: values.invoice,
date: moment(Date.now())
}
});
logger.log("intellipay-postback-invoice-payment-success", "DEBUG", req.user?.email, null, {
paymentResult,
...logResponseMeta
});
const responseResults = await gqlClient.request(queries.INSERT_PAYMENT_RESPONSE, {
paymentResponse: {
amount: values.total,
bodyshopid: job.jobs_by_pk.shopid,
paymentid: paymentResult.id,
jobid: values.invoice,
declinereason: "Approved",
ext_paymentid: values.paymentid,
successful: true,
response: values
}
});
logger.log("intellipay-postback-invoice-response-success", "DEBUG", req.user?.email, null, {
responseResults,
...logResponseMeta
});
res.sendStatus(200);
return await handleCommentBasedPayment(values, decodedComment, logger, logMeta, res);
} else if (values?.invoice) {
return await handleInvoiceBasedPayment(values, logger, logMeta, res);
} else {
// This should be caught by first validation, but as a safeguard
logValidationError("intellipay-postback-invalid", "No valid invoice or comment provided", logMeta);
return res.status(400).send("Bad Request: No valid invoice or comment provided");
}
} catch (error) {
logger.log("intellipay-postback-error", "ERROR", req.user?.email, null, {
logger.log("intellipay-postback-error", "ERROR", "api", null, {
message: error?.message,
...logResponseMeta
...logMeta
});
res.status(400).json({ successful: false, error: error.message, ...logResponseMeta });
return res.status(400).json({ successful: false, error: error.message, ...logMeta });
}
};
module.exports = {
lightboxCredentials,
paymentRefund,
generatePaymentUrl,
checkFee,
postBack
};

View File

@@ -0,0 +1,14 @@
/**
* @description Decode the comment from base64
* @param comment
* @returns {any|null}
*/
const decodeComment = (comment) => {
try {
return comment ? JSON.parse(Buffer.from(comment, "base64").toString()) : null;
} catch (error) {
return null; // Handle malformed base64 string gracefully
}
};
module.exports = decodeComment;

View File

@@ -0,0 +1,34 @@
/**
* Generates a properly formatted Cpteller API URL
* @param {Object} options - URL configuration options
* @param {string} options.apiType - 'webapi' or 'custapi'
* @param {string} [options.version] - API version (e.g., '26' for webapi)
* @param {Object} [options.params] - URL query parameters
* @returns {string} - The formatted Cpteller URL
*/
const getCptellerUrl = (options) => {
const domain = process.env?.NODE_ENV === "production" ? "secure" : "test";
const { apiType = "webapi", version, params = {} } = options;
// Base URL construction
let url = `https://${domain}.cpteller.com/api/`;
// Add version if specified for webapi
if (apiType === "webapi" && version) {
url += `${version}/`;
}
// Add the API endpoint
url += `${apiType}.cfc`;
// Add query parameters if any exist
const queryParams = new URLSearchParams(params).toString();
if (queryParams) {
url += `?${queryParams}`;
}
return url;
};
module.exports = getCptellerUrl;

View File

@@ -0,0 +1,12 @@
/**
* @description Get payment type based on IP mapping
* @param ipMapping
* @param cardType
* @returns {*}
*/
const getPaymentType = (ipMapping, cardType) => {
const normalizedCardType = (cardType || "").toLowerCase();
return ipMapping ? ipMapping[normalizedCardType] || cardType : cardType;
};
module.exports = getPaymentType;

View File

@@ -0,0 +1,40 @@
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
const { InstanceRegion } = require("../../utils/instanceMgr");
const client = new SecretsManagerClient({
region: InstanceRegion()
});
/**
* @description Get shop credentials from AWS Secrets Manager
* @param bodyshop
* @returns {Promise<{error}|{merchantkey: *, apikey: *}|any>}
*/
const getShopCredentials = async (bodyshop) => {
// In Dev/Testing we will use the environment variables
if (process.env?.NODE_ENV !== "production") {
return {
merchantkey: process.env.INTELLIPAY_MERCHANTKEY,
apikey: process.env.INTELLIPAY_APIKEY
};
}
// In Production, we will use the AWS Secrets Manager
if (bodyshop?.imexshopid) {
try {
const secret = await client.send(
new GetSecretValueCommand({
SecretId: `intellipay-credentials-${bodyshop.imexshopid}`,
VersionStage: "AWSCURRENT" // VersionStage defaults to AWSCURRENT if unspecified
})
);
return JSON.parse(secret.SecretString);
} catch (error) {
return {
error: error.message
};
}
}
};
module.exports = getShopCredentials;

View File

@@ -0,0 +1,81 @@
const sendPaymentNotificationEmail = require("./sendPaymentNotificationEmail");
const { INSERT_NEW_PAYMENT, GET_BODYSHOP_BY_ID, GET_JOBS_BY_PKS } = require("../../graphql-client/queries");
const getPaymentType = require("./getPaymentType");
const moment = require("moment");
const gqlClient = require("../../graphql-client/graphql-client").client;
/**
* @description Handle comment-based payment processing
* @param values
* @param decodedComment
* @param logger
* @param logMeta
* @param res
* @returns {Promise<*>}
*/
const handleCommentBasedPayment = async (values, decodedComment, logger, logMeta, res) => {
logger.log("intellipay-postback-parsed-comment", "DEBUG", "api", null, {
parsedComment: decodedComment,
...logMeta
});
const partialPayments = Array.isArray(decodedComment) ? decodedComment : decodedComment.payments;
// Fetch job data
const jobs = await gqlClient.request(GET_JOBS_BY_PKS, {
ids: partialPayments.map((p) => p.jobid)
});
// Fetch bodyshop data
const bodyshop = await gqlClient.request(GET_BODYSHOP_BY_ID, {
id: jobs.jobs[0].shopid
});
const ipMapping = bodyshop.bodyshops_by_pk.intellipay_config?.payment_map;
logger.log("intellipay-postback-jobs-fetched", "DEBUG", "api", null, {
jobs,
parsedComment: decodedComment,
...logMeta
});
// Create payment records
const paymentResult = await gqlClient.request(INSERT_NEW_PAYMENT, {
paymentInput: partialPayments.map((p) => ({
amount: p.amount,
transactionid: values.authcode,
payer: "Customer",
type: getPaymentType(ipMapping, values.cardtype),
jobid: p.jobid,
date: moment(Date.now()),
payment_responses: {
data: {
amount: values.total,
bodyshopid: bodyshop.bodyshops_by_pk.id,
jobid: p.jobid,
declinereason: "Approved",
ext_paymentid: values.paymentid,
successful: true,
response: values
}
}
}))
});
logger.log("intellipay-postback-payment-success", "DEBUG", "api", null, {
paymentResult,
jobs,
parsedComment: decodedComment,
...logMeta
});
// Send notification email if needed
if (values?.origin === "OneLink" && decodedComment?.userEmail) {
await sendPaymentNotificationEmail(decodedComment.userEmail, jobs, partialPayments, logger, logMeta);
}
return res.sendStatus(200);
};
module.exports = handleCommentBasedPayment;

View File

@@ -0,0 +1,131 @@
const handlePaymentValidationError = require("./handlePaymentValidationError");
const {
GET_JOBID_BY_MERCHANTID_RONUMBER,
INSERT_PAYMENT_RESPONSE,
INSERT_NEW_PAYMENT,
GET_BODYSHOP_BY_MERCHANT_ID
} = require("../../graphql-client/queries");
const { sendTaskEmail } = require("../../email/sendemail");
const getPaymentType = require("./getPaymentType");
const moment = require("moment");
const gqlClient = require("../../graphql-client/graphql-client").client;
/**
* @description Handle invoice-based payment processing
* @param values
* @param logger
* @param logMeta
* @param res
* @returns {Promise<*>}
*/
const handleInvoiceBasedPayment = async (values, logger, logMeta, res) => {
// Validate required fields
if (!values.merchantid) {
return handlePaymentValidationError(
res,
logger,
"intellipay-postback-no-merchantid",
"Merchant ID is missing",
logMeta
);
}
// Fetch job data
const result = await gqlClient.request(GET_JOBID_BY_MERCHANTID_RONUMBER, {
merchantID: values.merchantid,
roNumber: values.invoice
});
if (!result?.jobs?.length) {
// Fetch bodyshop data
const bodyshop = await gqlClient.request(GET_BODYSHOP_BY_MERCHANT_ID, {
merchantID: values.merchantid
});
if (bodyshop?.bodyshops?.[0]) {
// Note: changed bodyshops to bodyshop to match query name
const email = bodyshop.bodyshops[0].email;
await sendTaskEmail({
to: email,
subject: `Failed to Insert Payment`,
text: `The system has attempted to insert a payment that was generated by your merchant terminal but could not find an associated invoice. Transaction details are below. Please input this payment to your system manually.\n\n${Object.keys(
values
)
.map((key) => `${key}: ${values[key]}`)
.join("\n")}`
});
}
return handlePaymentValidationError(
res,
logger,
"intellipay-postback-job-not-found",
"Job not found",
logMeta,
200
);
}
const job = result.jobs[0];
const bodyshop = job?.bodyshop;
if (!bodyshop) {
return handlePaymentValidationError(
res,
logger,
"intellipay-postback-bodyshop-not-found",
"Bodyshop not found",
logMeta
);
}
const ipMapping = bodyshop.intellipay_config?.payment_map;
logger.log("intellipay-postback-invoice-job-fetched", "DEBUG", "api", null, {
job,
...logMeta
});
// Create payment record
const paymentResult = await gqlClient.request(INSERT_NEW_PAYMENT, {
paymentInput: {
amount: values.total,
transactionid: values.authcode,
payer: "Customer",
type: getPaymentType(ipMapping, values.cardtype),
jobid: job.id,
date: moment(Date.now())
}
});
logger.log("intellipay-postback-invoice-payment-success", "DEBUG", "api", null, {
paymentResult,
...logMeta
});
// Create payment response record
const responseResults = await gqlClient.request(INSERT_PAYMENT_RESPONSE, {
paymentResponse: {
amount: values.total,
bodyshopid: bodyshop.id,
paymentid: paymentResult.id,
jobid: job.id,
declinereason: "Approved",
ext_paymentid: values.paymentid,
successful: true,
response: values
}
});
logger.log("intellipay-postback-invoice-response-success", "DEBUG", "api", null, {
responseResults,
...logMeta
});
return res.sendStatus(200);
};
module.exports = handleInvoiceBasedPayment;

View File

@@ -0,0 +1,19 @@
/**
* @description Log validation error and send response
* @param res
* @param logger
* @param logCode
* @param message
* @param logMeta
* @param returnCode
* @returns {*}
*/
const handlePaymentValidationError = (res, logger, logCode, message, logMeta, returnCode) => {
logger.log(logCode, "ERROR", "api", null, {
message,
...logMeta
});
return res.status(returnCode || 400).send(`Bad Request: ${message}`);
};
module.exports = handlePaymentValidationError;

View File

@@ -0,0 +1,40 @@
const { sendTaskEmail } = require("../../email/sendemail");
const generateEmailTemplate = require("../../email/generateTemplate");
const { InstanceEndpoints } = require("../../utils/instanceMgr");
/**
* @description Send notification email to the user
* @param userEmail
* @param jobs
* @param partialPayments
* @param logger
* @param logMeta
* @returns {Promise<void>}
*/
const sendPaymentNotificationEmail = async (userEmail, jobs, partialPayments, logger, logMeta) => {
try {
await sendTaskEmail({
to: userEmail,
subject: `New Payment(s) Received - RO ${jobs.jobs.map((j) => j.ro_number).join(", ")}`,
type: "html",
html: generateEmailTemplate({
header: "New Payment(s) Received",
subHeader: "",
body: jobs.jobs
.map(
(job) =>
`<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;">Reference: <a href="${InstanceEndpoints()}/manage/jobs/${job.id}" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">${job.ro_number || "N/A"}</a> | ${job.ownr_co_nm ? job.ownr_co_nm : `${job.ownr_fn || ""} ${job.ownr_ln || ""}`.trim()} | ${`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${job.v_model_desc || ""}`.trim()} | $${partialPayments.find((p) => p.jobid === job.id).amount}</p>`
)
.join("")
})
});
} catch (error) {
logger.log("intellipay-postback-email-error", "ERROR", "api", null, {
message: error.message,
jobs,
...logMeta
});
}
};
module.exports = sendPaymentNotificationEmail;

View File

@@ -0,0 +1,152 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import mockRequire from "mock-require";
const gqlRequestMock = { request: vi.fn() };
const getPaymentTypeMock = vi.fn(() => "American Express");
const sendPaymentNotificationEmailMock = vi.fn();
let handleCommentBasedPayment;
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
// Mock dependencies using mock-require BEFORE requiring the target module
mockRequire("../../../graphql-client/graphql-client", {
client: gqlRequestMock
});
mockRequire("../getPaymentType", getPaymentTypeMock);
mockRequire("../sendPaymentNotificationEmail", sendPaymentNotificationEmailMock);
// Now require the module under test
handleCommentBasedPayment = require("../handleCommentBasedPayment");
// Chain your GraphQL mocks
gqlRequestMock.request
.mockResolvedValueOnce({
jobs: [
{
id: "c1ffe09c-e7d4-46b3-aac5-f23e39563181",
shopid: "bfec8c8c-b7f1-49e0-be4c-524455f4e582"
}
]
})
.mockResolvedValueOnce({
bodyshops_by_pk: {
id: "bfec8c8c-b7f1-49e0-be4c-524455f4e582",
intellipay_config: {
payment_map: {
amex: "American Express"
}
}
}
})
.mockResolvedValueOnce({
insert_payments: {
returning: [{ id: "5dfda3c4-c0a6-4b09-a73d-176ed0ac6499" }]
}
});
});
describe("handleCommentBasedPayment", () => {
const mockLogger = { log: vi.fn() };
const mockRes = { sendStatus: vi.fn() };
const values = {
authcode: "5557301",
total: "0.01",
origin: "Dejavoo",
paymentid: "24294378",
cardtype: "Amex"
};
const decodedComment = {
payments: [{ jobid: "c1ffe09c-e7d4-46b3-aac5-f23e39563181", amount: 0.01 }],
userEmail: "test@example.com"
};
const logMeta = { op: "xyz123" };
it("processes comment-based payment and returns 200", async () => {
await handleCommentBasedPayment(values, decodedComment, mockLogger, logMeta, mockRes);
expect(gqlRequestMock.request).toHaveBeenCalledTimes(3);
expect(getPaymentTypeMock).toHaveBeenCalledWith({ amex: "American Express" }, "Amex");
expect(sendPaymentNotificationEmailMock).not.toHaveBeenCalled();
expect(mockRes.sendStatus).toHaveBeenCalledWith(200);
});
it("sends notification if origin is OneLink and userEmail exists", async () => {
const oneLinkValues = { ...values, origin: "OneLink" };
await handleCommentBasedPayment(oneLinkValues, decodedComment, mockLogger, logMeta, mockRes);
expect(sendPaymentNotificationEmailMock).toHaveBeenCalledWith(
"test@example.com",
expect.anything(),
expect.anything(),
mockLogger,
logMeta
);
expect(mockRes.sendStatus).toHaveBeenCalledWith(200);
});
it("handles decodedComment as a direct array", async () => {
const arrayComment = [{ jobid: "c1ffe09c-e7d4-46b3-aac5-f23e39563181", amount: 0.01 }];
await handleCommentBasedPayment(values, arrayComment, mockLogger, logMeta, mockRes);
expect(gqlRequestMock.request).toHaveBeenCalledTimes(3);
expect(mockRes.sendStatus).toHaveBeenCalledWith(200);
});
it("does not send email if origin is OneLink but userEmail is missing", async () => {
const commentWithoutEmail = {
payments: decodedComment.payments
// no userEmail
};
const oneLinkValues = { ...values, origin: "OneLink" };
await handleCommentBasedPayment(oneLinkValues, commentWithoutEmail, mockLogger, logMeta, mockRes);
expect(sendPaymentNotificationEmailMock).not.toHaveBeenCalled();
expect(mockRes.sendStatus).toHaveBeenCalledWith(200);
});
it("logs important stages of the process", async () => {
await handleCommentBasedPayment(values, decodedComment, mockLogger, logMeta, mockRes);
const logCalls = mockLogger.log.mock.calls.map(([tag]) => tag);
expect(logCalls).toContain("intellipay-postback-parsed-comment");
expect(logCalls).toContain("intellipay-postback-payment-success");
});
it("handles missing payment_map safely", async () => {
gqlRequestMock.request.mockReset(); // 🧹 Clear previous .mockResolvedValueOnce calls
gqlRequestMock.request
.mockResolvedValueOnce({
jobs: [{ id: "job1", shopid: "shop1" }]
})
.mockResolvedValueOnce({
bodyshops_by_pk: {
id: "shop1",
intellipay_config: null
}
})
.mockResolvedValueOnce({
insert_payments: {
returning: [{ id: "payment1" }]
}
});
await handleCommentBasedPayment(values, decodedComment, mockLogger, logMeta, mockRes);
expect(getPaymentTypeMock).toHaveBeenCalledWith(undefined, "Amex");
expect(mockRes.sendStatus).toHaveBeenCalledWith(200);
});
});

View File

@@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import mockRequire from "mock-require";
const gqlRequestMock = { request: vi.fn() };
const getPaymentTypeMock = vi.fn(() => "Visa");
const handlePaymentValidationErrorMock = vi.fn();
let handleInvoiceBasedPayment;
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mockRequire("../../../graphql-client/graphql-client", {
client: gqlRequestMock
});
mockRequire("../getPaymentType", getPaymentTypeMock);
mockRequire("../handlePaymentValidationError", handlePaymentValidationErrorMock);
handleInvoiceBasedPayment = require("../handleInvoiceBasedPayment");
gqlRequestMock.request
.mockResolvedValueOnce({
jobs: [
{
id: "job123",
bodyshop: {
id: "shop123",
intellipay_config: {
payment_map: {
visa: "Visa"
}
}
}
}
]
})
.mockResolvedValueOnce({
id: "payment123"
})
.mockResolvedValueOnce({
insert_payment_response: {
returning: [{ id: "response123" }]
}
});
});
describe("handleInvoiceBasedPayment", () => {
const mockLogger = { log: vi.fn() };
const mockRes = { sendStatus: vi.fn() };
const values = {
merchantid: "m123",
invoice: "INV-001",
total: 100.0,
authcode: "AUTH123",
cardtype: "visa",
paymentid: "P789"
};
const logMeta = { op: "abc123" };
it("processes a valid invoice-based payment", async () => {
await handleInvoiceBasedPayment(values, mockLogger, logMeta, mockRes);
expect(gqlRequestMock.request).toHaveBeenCalledTimes(3);
expect(getPaymentTypeMock).toHaveBeenCalledWith({ visa: "Visa" }, "visa");
expect(mockRes.sendStatus).toHaveBeenCalledWith(200);
expect(handlePaymentValidationErrorMock).not.toHaveBeenCalled();
});
it("handles missing merchantid with validation error", async () => {
const invalidValues = { ...values, merchantid: undefined };
await handleInvoiceBasedPayment(invalidValues, mockLogger, logMeta, mockRes);
expect(handlePaymentValidationErrorMock).toHaveBeenCalledWith(
mockRes,
mockLogger,
"intellipay-postback-no-merchantid",
"Merchant ID is missing",
logMeta
);
expect(gqlRequestMock.request).not.toHaveBeenCalled();
});
it("handles job not found with validation error", async () => {
gqlRequestMock.request.mockReset();
gqlRequestMock.request.mockResolvedValueOnce({ jobs: [] });
await handleInvoiceBasedPayment(values, mockLogger, logMeta, mockRes);
expect(handlePaymentValidationErrorMock).toHaveBeenCalledWith(
mockRes,
mockLogger,
"intellipay-postback-job-not-found",
"Job not found",
logMeta,
200
);
});
it("handles missing bodyshop with validation error", async () => {
gqlRequestMock.request.mockReset();
gqlRequestMock.request.mockResolvedValueOnce({
jobs: [{ id: "job123", bodyshop: null }]
});
await handleInvoiceBasedPayment(values, mockLogger, logMeta, mockRes);
expect(handlePaymentValidationErrorMock).toHaveBeenCalledWith(
mockRes,
mockLogger,
"intellipay-postback-bodyshop-not-found",
"Bodyshop not found",
logMeta
);
});
it("logs all expected stages of the process", async () => {
await handleInvoiceBasedPayment(values, mockLogger, logMeta, mockRes);
const logTags = mockLogger.log.mock.calls.map(([tag]) => tag);
expect(logTags).toContain("intellipay-postback-invoice-job-fetched");
expect(logTags).toContain("intellipay-postback-invoice-payment-success");
expect(logTags).toContain("intellipay-postback-invoice-response-success");
});
});

View File

@@ -0,0 +1,277 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const getPaymentType = require("../getPaymentType");
const decodeComment = require("../decodeComment");
const getCptellerUrl = require("../getCptellerUrl");
const handlePaymentValidationError = require("../handlePaymentValidationError");
const getShopCredentials = require("../getShopCredentials");
describe("Payment Processing Functions", () => {
// DecodeComment Tests
describe("decodeComment", () => {
it("decodes a valid base64-encoded JSON comment", () => {
const encoded = "eyJ0ZXN0IjoiZGF0YSJ9";
const expected = { test: "data" };
expect(decodeComment(encoded)).toEqual(expected);
});
it("decodes a complex base64-encoded JSON with payments", () => {
const encoded = "eyJwYXltZW50cyI6W3siam9iaWQiOiIxMjMifV19";
const expected = { payments: [{ jobid: "123" }] };
expect(decodeComment(encoded)).toEqual(expected);
});
it("returns null when comment is null", () => {
expect(decodeComment(null)).toBeNull();
});
it("returns null when comment is undefined", () => {
expect(decodeComment(undefined)).toBeNull();
});
it("returns null when comment is an empty string", () => {
expect(decodeComment("")).toBeNull();
});
it("returns null when comment is malformed base64", () => {
expect(decodeComment("!@#$%")).toBeNull();
});
it("returns null when comment is valid base64 but not valid JSON", () => {
expect(decodeComment("aW52YWxpZA==")).toBeNull();
});
});
// GetPaymentType Tests
describe("getPaymentType", () => {
it("returns mapped value when card type exists in mapping", () => {
const ipMapping = { visa: "Visa Card", amex: "American Express" };
expect(getPaymentType(ipMapping, "visa")).toBe("Visa Card");
});
it("returns original value when card type not in mapping", () => {
const ipMapping = { visa: "Visa Card" };
expect(getPaymentType(ipMapping, "mastercard")).toBe("mastercard");
});
it("handles lowercase conversion", () => {
const ipMapping = { visa: "Visa Card" };
expect(getPaymentType(ipMapping, "VISA")).toBe("Visa Card");
});
it("handles null mapping", () => {
expect(getPaymentType(null, "visa")).toBe("visa");
});
it("handles undefined mapping", () => {
expect(getPaymentType(undefined, "visa")).toBe("visa");
});
it("handles empty string card type", () => {
const ipMapping = { visa: "Visa Card" };
expect(getPaymentType(ipMapping, "")).toBe("");
});
it("handles undefined card type", () => {
const ipMapping = { visa: "Visa Card" };
expect(getPaymentType(ipMapping, undefined)).toBe(undefined);
});
});
// GetCptellerUrl Tests
describe("getCptellerUrl", () => {
const originalEnv = process.env.NODE_ENV;
afterEach(() => {
process.env.NODE_ENV = originalEnv;
});
it("uses test domain in non-production environment", () => {
process.env.NODE_ENV = "";
const url = getCptellerUrl({ apiType: "webapi" });
expect(url).toEqual("https://test.cpteller.com/api/webapi.cfc");
});
it("uses secure domain in production environment", () => {
process.env.NODE_ENV = "production";
const url = getCptellerUrl({ apiType: "webapi" });
expect(url).toEqual("https://secure.cpteller.com/api/webapi.cfc");
});
it("adds version number for webapi type", () => {
process.env.NODE_ENV = "";
const url = getCptellerUrl({ apiType: "webapi", version: "26" });
expect(url).toEqual("https://test.cpteller.com/api/26/webapi.cfc");
});
it("constructs custapi URL without version number", () => {
process.env.NODE_ENV = "";
const url = getCptellerUrl({ apiType: "custapi", version: "26" });
expect(url).toEqual("https://test.cpteller.com/api/custapi.cfc");
});
it("adds query parameters to the URL", () => {
process.env.NODE_ENV = "";
const url = getCptellerUrl({
apiType: "webapi",
params: { method: "payment_refund", test: "value" }
});
expect(url).toEqual("https://test.cpteller.com/api/webapi.cfc?method=payment_refund&test=value");
});
it("handles empty params object", () => {
process.env.NODE_ENV = "";
const url = getCptellerUrl({ apiType: "webapi", params: {} });
expect(url).toEqual("https://test.cpteller.com/api/webapi.cfc");
});
it("defaults to webapi when no apiType is provided", () => {
process.env.NODE_ENV = "";
const url = getCptellerUrl({});
expect(url).toEqual("https://test.cpteller.com/api/webapi.cfc");
});
it("combines version and query parameters correctly", () => {
process.env.NODE_ENV = "";
const url = getCptellerUrl({
apiType: "webapi",
version: "26",
params: { method: "fee" }
});
expect(url).toEqual("https://test.cpteller.com/api/26/webapi.cfc?method=fee");
});
});
// GetShopCredentials Tests
describe("getShopCredentials", () => {
const originalEnv = { ...process.env };
let mockSend;
beforeEach(() => {
mockSend = vi.fn();
vi.mock("@aws-sdk/client-secrets-manager", () => {
return {
SecretsManagerClient: vi.fn(() => ({
send: mockSend
})),
GetSecretValueCommand: vi.fn((input) => input)
};
});
process.env.INTELLIPAY_MERCHANTKEY = "test-merchant-key";
process.env.INTELLIPAY_APIKEY = "test-api-key";
vi.resetModules();
});
afterEach(() => {
process.env = { ...originalEnv };
vi.restoreAllMocks();
vi.unmock("@aws-sdk/client-secrets-manager");
});
it("returns environment variables in non-production environment", async () => {
process.env.NODE_ENV = "development";
const result = await getShopCredentials({ imexshopid: "12345" });
expect(result).toEqual({
merchantkey: "test-merchant-key",
apikey: "test-api-key"
});
expect(mockSend).not.toHaveBeenCalled();
});
it("returns undefined when imexshopid is missing in production", async () => {
process.env.NODE_ENV = "production";
const result = await getShopCredentials({ name: "Test Shop" });
expect(result).toBeUndefined();
expect(mockSend).not.toHaveBeenCalled();
});
it("returns undefined for null bodyshop in production", async () => {
process.env.NODE_ENV = "production";
const result = await getShopCredentials(null);
expect(result).toBeUndefined();
expect(mockSend).not.toHaveBeenCalled();
});
it("returns undefined for undefined bodyshop in production", async () => {
process.env.NODE_ENV = "production";
const result = await getShopCredentials(undefined);
expect(result).toBeUndefined();
expect(mockSend).not.toHaveBeenCalled();
});
});
// HandlePaymentValidationError Tests
describe("handlePaymentValidationError", () => {
it("logs error and sends 400 response", () => {
const mockLog = vi.fn();
const mockLogger = { log: mockLog };
const mockRes = {
status: vi.fn().mockReturnThis(),
send: vi.fn().mockReturnThis()
};
const logCode = "test-validation-error";
const message = "Invalid data";
const logMeta = { field: "test", value: 123 };
const result = handlePaymentValidationError(mockRes, mockLogger, logCode, message, logMeta);
expect(mockLog).toHaveBeenCalledWith(logCode, "ERROR", "api", null, {
message,
...logMeta
});
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.send).toHaveBeenCalledWith(`Bad Request: ${message}`);
expect(result).toBe(mockRes);
});
it("formats different error messages correctly", () => {
const mockLog = vi.fn();
const mockLogger = { log: mockLog };
const mockRes = {
status: vi.fn().mockReturnThis(),
send: vi.fn().mockReturnThis()
};
handlePaymentValidationError(mockRes, mockLogger, "error-code", "Custom error");
expect(mockRes.send).toHaveBeenCalledWith("Bad Request: Custom error");
});
it("passes different logCodes to logger", () => {
const mockLog = vi.fn();
const mockLogger = { log: mockLog };
const mockRes = {
status: vi.fn().mockReturnThis(),
send: vi.fn().mockReturnThis()
};
handlePaymentValidationError(mockRes, mockLogger, "custom-log-code", "Error message");
expect(mockLog).toHaveBeenCalledWith("custom-log-code", "ERROR", "api", null, { message: "Error message" });
});
it("works with minimal logMeta", () => {
const mockLog = vi.fn();
const mockLogger = { log: mockLog };
const mockRes = {
status: vi.fn().mockReturnThis(),
send: vi.fn().mockReturnThis()
};
handlePaymentValidationError(mockRes, mockLogger, "error-code", "Error message", {});
expect(mockLog).toHaveBeenCalledWith("error-code", "ERROR", "api", null, { message: "Error message" });
});
it("works with undefined logMeta", () => {
const mockLog = vi.fn();
const mockLogger = { log: mockLog };
const mockRes = {
status: vi.fn().mockReturnThis(),
send: vi.fn().mockReturnThis()
};
handlePaymentValidationError(mockRes, mockLogger, "error-code", "Error message");
expect(mockLog).toHaveBeenCalledWith("error-code", "ERROR", "api", null, { message: "Error message" });
});
});
});

View File

@@ -1,7 +1,5 @@
const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries");
// const adminClient = require("../graphql-client/graphql-client").client;
// const _ = require("lodash");
const logger = require("../utils/logger");
const InstanceMgr = require("../utils/instanceMgr").default;
@@ -45,9 +43,11 @@ exports.totalsSsu = async function (req, res) {
}
});
if (result) {
res.status(200).send();
if (!result) {
throw new Error("Failed to update job totals");
}
res.status(200).send();
} catch (error) {
logger.log("job-totals-ssu-USA-error", "ERROR", req?.user?.email, id, {
jobid: id,
@@ -58,7 +58,7 @@ exports.totalsSsu = async function (req, res) {
}
};
//IMPORTANT*** These two functions MUST be mirrrored.
//IMPORTANT*** These two functions MUST be mirrored.
async function TotalsServerSide(req, res) {
const { job, client } = req.body;
await AtsAdjustmentsIfRequired({ job: job, client: client, user: req?.user });
@@ -133,6 +133,9 @@ async function TotalsServerSide(req, res) {
}
}
// Exported for testing purposes.
exports.TotalsServerSide = TotalsServerSide;
async function Totals(req, res) {
const { job, id } = req.body;

View File

@@ -0,0 +1,139 @@
const logger = require("../utils/logger");
const queries = require("../graphql-client/queries");
const moment = require("moment");
const { captureFixture } = require("./utils/seralizeHelper");
const { TotalsServerSide: totalsServerSideCA } = require("./job-totals"); // Canadian version (imex)
const { TotalsServerSide: totalsServerSideUS } = require("./job-totals-USA");
const InstanceMgr = require("../utils/instanceMgr").default;
const { uploadFileToS3 } = require("../utils/s3");
// requires two buckets be made per env, job-totals-test, job-totals-production, locally it will
// use `job-totals` in the owncloud stack
/**
* Returns the environment prefix based on NODE_ENV
* @returns {string}
*/
const getEnvPrefix = () => {
switch (process.env?.NODE_ENV) {
case "test":
return "test";
case "production":
return "production";
default:
return "test";
}
};
const envPrefix = getEnvPrefix();
const S3_BUCKET_NAME =
process.env?.NODE_ENV === "development"
? "imex-job-totals"
: InstanceMgr({
imex: `job-totals-${envPrefix}`,
rome: `job-totals-${envPrefix}-rome`
});
/**
* Generates a unique S3 key for the job totals file
* @param {string} jobId - The job ID
* @returns {string} - S3 key with timestamp
*/
const generateS3Key = (jobId) => `${jobId}-${moment().toISOString()}.json`;
/**
* Uploads job totals data to S3
* @param {object} data - The data to upload
* @param {string} jobId - The job ID
* @param {object} userInfo - User information for logging
* @returns {Promise<string>} - The S3 key
*/
const uploadJobTotalsToS3 = async (data, jobId, userInfo) => {
const key = generateS3Key(jobId);
try {
await uploadFileToS3({
bucketName: S3_BUCKET_NAME,
key: key,
content: JSON.stringify(data, null, 2),
contentType: "application/json"
});
logger.log(`Job totals uploaded successfully to ${key}`, "info", userInfo.email, jobId);
return key;
} catch (error) {
logger.log("Failed to upload job totals to S3", "error", userInfo.email, jobId, {
error: error?.message,
stack: error?.stack
});
throw error; // Re-throw for the main handler to catch
}
};
/**
* Fetches job data using GraphQL
* @param {object} client - GraphQL client
* @param {string} token - Bearer token
* @param {string} jobId - Job ID to fetch
* @returns {Promise<object>} - Job data
*/
const fetchJobData = async (client, token, jobId) => {
return client
.setHeaders({ Authorization: token })
.request(queries.GET_JOB_BY_PK, { id: jobId })
.then((response) => response.jobs_by_pk);
};
/**
* This function is used to capture job totals json files.
* @param {object} req - Express request
* @param {object} res - Express response
* @returns {Promise<void>}
*/
const jobTotalsRecorder = async (req, res) => {
const { id: jobId } = req.body;
const bearerToken = req.BearerToken;
const client = req.userGraphQLClient;
const userEmail = req?.user?.email;
logger.log("Starting job totals recording", "debug", userEmail, jobId);
try {
// Fetch job data
const jobData = await fetchJobData(client, bearerToken, jobId);
// Get the appropriate totals function based on instance
const totalsFunction = InstanceMgr({
imex: totalsServerSideCA,
rome: totalsServerSideUS
});
// Calculate the totals
const calculatedTotals = await totalsFunction({ body: { job: jobData, client }, req }, res, true);
// Prepare data for storage
const dataToSave = captureFixture(jobData, calculatedTotals);
// Upload to S3
await uploadJobTotalsToS3(dataToSave, jobId, { email: userEmail });
res.status(200).json({ success: true, message: "Job totals recorded successfully" });
} catch (error) {
logger.log("Failed to record job totals", "error", userEmail, jobId, {
error: error?.message,
stack: error?.stack
});
// Avoid sending response if it's already been sent
if (!res.headersSent) {
res.status(503).json({
success: false,
message: "Error processing job totals",
error: error.message
});
}
}
};
module.exports = jobTotalsRecorder;

View File

@@ -30,6 +30,7 @@ exports.totalsSsu = async function (req, res) {
id: id
});
// Capture the output of TotalsServerSide
const newTotals = await TotalsServerSide({ body: { job: job.jobs_by_pk, client: client } }, res, true);
const result = await client.setHeaders({ Authorization: BearerToken }).request(queries.UPDATE_JOB, {
@@ -57,7 +58,7 @@ exports.totalsSsu = async function (req, res) {
}
};
//IMPORTANT*** These two functions MUST be mirrrored.
//IMPORTANT*** These two functions MUST be mirrored.
async function TotalsServerSide(req, res) {
const { job, client } = req.body;
await AtsAdjustmentsIfRequired({ job: job, client: client, user: req?.user });
@@ -81,6 +82,9 @@ async function TotalsServerSide(req, res) {
}
}
// Exported for testing purposes
exports.TotalsServerSide = TotalsServerSide;
async function Totals(req, res) {
const { job, id } = req.body;

View File

@@ -0,0 +1,72 @@
import fs from "fs";
import path from "path";
import { describe, it, expect } from "vitest";
import { TotalsServerSide as TotalsServerSideCA } from "../job-totals"; // Canadian version (imex)
import { TotalsServerSide as TotalsServerSideUS } from "../job-totals-USA";
import { isFunction } from "lodash"; // US version (rome)
/**
* This function is used to replace the values in the object with their toObject() representation.
* @param key
* @param value
* @returns {*}
*/
const dineroReplacer = (key, value) => {
if (isFunction(value)) {
return value.toObject();
}
return value;
};
/**
* Normalizes the output of the TotalsServerSide function by converting
* @param obj
* @returns {any}
*/
const normalizeOutput = (obj) => {
return JSON.parse(JSON.stringify(obj, dineroReplacer));
};
/**
* This test suite is designed to validate the functionality of the TotalsServerSide function
*/
describe("TotalsServerSide fixture tests", () => {
const fixturesDir = path.join(__dirname, "fixtures", "job-totals");
const fixtureFiles = fs.readdirSync(fixturesDir).filter((f) => f.endsWith(".json"));
const dummyClient = {
request: async () => {
return {};
}
};
const dummyRes = {
status: () => ({ send: () => {} })
};
fixtureFiles.forEach((file) => {
it(`should produce matching output for fixture file ${file}`, async () => {
const fixturePath = path.join(fixturesDir, file);
const fixtureData = JSON.parse(fs.readFileSync(fixturePath, "utf8"));
const { environment, input, output: expectedOutput } = fixtureData;
const req = {
body: {
job: input,
client: dummyClient
},
user: {}
};
const computedOutput =
environment === "us" ? await TotalsServerSideUS(req, dummyRes) : await TotalsServerSideCA(req, dummyRes);
const normalizedComputed = normalizeOutput(computedOutput);
const normalizedExpected = normalizeOutput(expectedOutput);
expect(normalizedComputed).toEqual(normalizedExpected);
});
});
});

View File

@@ -0,0 +1,58 @@
const fs = require("fs");
const path = require("path");
const { default: InstanceMgr } = require("../../utils/instanceMgr");
const fixtureDir = path.join(__dirname, "..", "test", "fixtures", "job-totals");
/**
* Custom serializer for Dinero.js objects.
* @param key
* @param value
* @returns {*}
*/
const serializeDinero = (key, value) => {
if (value && typeof value === "object" && typeof value.toObject === "function") {
return value.toObject();
}
return value;
};
/**
* Capture a fixture for job totals.
* @param inputData
* @param outputData
* @param saveLocally
*/
const captureFixture = (inputData, outputData, saveLocally) => {
const fileName = `${inputData.id}.json`;
const filePath = path.join(fixtureDir, fileName);
const dataToSave = {
environment: InstanceMgr({
imex: "ca",
rome: "us"
}),
meta: {
ro_number: inputData.ro_number,
updated_at: inputData.updated_at
},
input: inputData,
output: outputData
};
// Save the file using our custom serializer.
if (saveLocally) {
if (!fs.existsSync(fixtureDir)) {
fs.mkdirSync(fixtureDir, { recursive: true });
}
fs.writeFileSync(filePath, JSON.stringify(dataToSave, serializeDinero, 2), "utf8");
}
return dataToSave;
};
module.exports = {
captureFixture,
serializeDinero
};

View File

@@ -133,11 +133,19 @@ const loadEmailQueue = async ({ pubClient, logger }) => {
subHeader: `Dear ${firstName},`,
dateLine: moment().tz(timezone).format("MM/DD/YYYY hh:mm a"),
body: `
<p>There have been updates to job ${jobRoNumber || "N/A"} at ${bodyShopName}:</p><br/>
<ul>
${messages.map((msg) => `<li>${msg}</li>`).join("")}
</ul><br/><br/>
<p><a href="${InstanceEndpoints()}/manage/jobs/${jobId}">Please check the job for more details.</a></p>
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 100%;">There have been updates to job ${jobRoNumber || "N/A"} at ${bodyShopName}:</p>
</td></tr></table></th>
</tr></tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<ul style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 1%; padding-left: 30px;">
${messages.map((msg) => `<li style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%;">${msg}</li>`).join("")}
</ul>
</td></tr></table></th>
</tr><tbody></table>
<table class="row" style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; padding: 0; width: 100%; position: relative; display: table;"><tbody style="font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; display: table-row-group;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;">
<th class="small-12 large-12 columns first last" style="word-wrap: break-word; -webkit-hyphens: auto; -moz-hyphens: auto; hyphens: auto; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; line-height: 1.2; margin: 0 auto; Margin: 0 auto; padding-bottom: 16px; width: 734px; padding-left: 8px; padding-right: 8px; border-collapse: collapse;"><table style="border-spacing: 0; border-collapse: collapse; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; width: 100%;"><tr style="padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; vertical-align: top; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif;"><td style="word-wrap: break-word; vertical-align: top; color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; margin: 0; Margin: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 15px; word-break: keep-all; -moz-hyphens: none; -ms-hyphens: none; -webkit-hyphens: none; hyphens: none; line-height: 1.2; border-collapse: collapse;">
<p style="color: #0a0a0a; font-weight: normal; padding-top: 0; padding-right: 0; padding-bottom: 0; padding-left: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; margin: 0 0 0 0px; Margin: 0 0 0 0px; line-height: 1.2; margin-bottom: 0px; Margin-bottom: 0px; font-size: 90%;"><a href="${InstanceEndpoints()}/manage/jobs/${jobId}" style="color: #2199e8; text-decoration: none; font-weight: normal; padding: 0; text-align: left; font-family: 'Montserrat', 'Montserrat Alternates', sans-serif; font-size: 90%; line-height: 1.2;">Please check the job for more details.</a></p>
`
});
await sendTaskEmail({
@@ -226,6 +234,7 @@ const getQueue = () => {
* @param {Object} options.logger - Logger instance for logging dispatch events.
* @returns {Promise<void>} Resolves when all notifications are added to the queue.
*/
// eslint-disable-next-line no-unused-vars
const dispatchEmailsToQueue = async ({ emailsToDispatch, logger }) => {
const emailAddQueue = getQueue();

View File

@@ -2,7 +2,7 @@ const express = require("express");
const router = express.Router();
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
const { createAssociation, createShop, updateShop, updateCounter } = require("../admin/adminops");
const { updateUser, getUser, createUser } = require("../firebase/firebase-handler");
const { updateUser, getUser, createUser, getWelcomeEmail, getResetLink } = require("../firebase/firebase-handler");
const validateAdminMiddleware = require("../middleware/validateAdminMiddleware");
router.use(validateFirebaseIdTokenMiddleware);
@@ -15,5 +15,7 @@ router.post("/updatecounter", updateCounter);
router.post("/updateuser", updateUser);
router.post("/getuser", getUser);
router.post("/createuser", createUser);
router.post("/sendwelcome", getWelcomeEmail);
router.post("/resetlink", getResetLink);
module.exports = router;

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