Compare commits

..

116 Commits

Author SHA1 Message Date
Dave Richer
bed87174d4 feature/IO-3000-Migrate-MSG-to-Sockets - Progress Checkpoint
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-19 08:52:07 -08:00
Dave Richer
03ae7bb160 feature/IO-3000-Migrate-MSG-to-Sockets - Progress Checkpoint
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-18 21:06:56 -08:00
Dave Richer
6e6c44f2b9 feature/IO-3000-Migrate-MSG-to-Sockets - Progress Checkpoint
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-18 19:51:10 -08:00
Dave Richer
ae0bfad89a feature/IO-3000-Migrate-MSG-to-Sockets - Add Conversation, merge master, packages
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-18 10:11:41 -08:00
Dave Richer
27de849be7 Merge remote-tracking branch 'origin/master-AIO' into feature/IO-3000-Migrate-MSG-to-Sockets 2024-11-18 10:10:40 -08:00
Allan Carr
289a666b6d Merged in release/2024-11-15 (pull request #1935)
IO-3030 Null Check memo
2024-11-18 16:48:26 +00:00
Allan Carr
b8836c7ae1 Merged in feature/IO-3030-QBO-Payment-Private-Note (pull request #1934)
IO-3030 Null Check memo
2024-11-18 16:40:15 +00:00
Allan Carr
eca31c5618 IO-3030 Null Check memo
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-18 08:42:52 -08:00
Allan Carr
7fdbedefce Merged in release/2024-11-15 (pull request #1933)
IO-3031 Appointment Schedule View Day
2024-11-16 01:40:16 +00:00
Allan Carr
7140b8d585 Merged in feature/IO-3031-Appointment-Schedule-View-Day (pull request #1931)
IO-3031 Appointment Schedule View Day
2024-11-16 01:33:29 +00:00
Allan Carr
5eed8d9809 IO-3031 Appointment Schedule View Day
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-15 17:35:49 -08:00
Dave Richer
57fe5b4c46 Merged in release/2024-11-15 (pull request #1930)
Release/2024 11 15 into master-AIO IO-2920, IO-3027, IO-3028, IO-3029, IO-3030, IO-3031, IO-3033
2024-11-15 23:59:53 +00:00
Allan Carr
f266ee1cfe Merged in feature/IO-3028-Word-Wrap-Line-Description (pull request #1928)
IO-3028 Adjust to TextArea with autoSize
2024-11-15 20:54:44 +00:00
Allan Carr
9550de5131 IO-3028 Adjust to TextArea with autoSize
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-15 12:57:08 -08:00
Patrick Fic
1f76ff882c Remove IO Event Logging. 2024-11-15 11:03:10 -08:00
Patrick Fic
749f73a272 Merged in feature/IO-2920-cash-discounting (pull request #1927)
IO-2920 Update config & totals for discount.
2024-11-15 18:58:54 +00:00
Patrick Fic
9c1774c417 Merge branch 'release/2024-11-15' into feature/IO-2920-cash-discounting 2024-11-15 10:58:22 -08:00
Allan Carr
26b3a43ce5 Merge branch 'feature/IO-3027-Datapumps-Refactor' into release/2024-11-15
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>

# Conflicts:
#	.vscode/settings.json
2024-11-15 10:05:08 -08:00
Allan Carr
78678dd3dc IO-3027 Datapumps Refactor
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-15 10:04:03 -08:00
Allan Carr
9dc4546b2e Merged in feature/IO-3033-Total-Loss-Indicator (pull request #1925)
IO-3033 Total Loss Indicator

Approved-by: Dave Richer
2024-11-15 17:47:56 +00:00
Dave Richer
1309d8ff65 feature/IO-3000-Migrate-MSG-to-Sockets - Major Progress
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-14 21:15:59 -08:00
Allan Carr
95aa0e45a6 IO-3033 Total Loss Indicator
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-14 16:47:35 -08:00
Allan Carr
ce9a77efcf IO-3027 Datapumps Refactor
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-14 16:15:17 -08:00
Dave Richer
e9e1e820a7 release/2024-11-15 - Expose S3 client through createS3Client
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-14 11:57:40 -08:00
Allan Carr
b027a4e618 IO-3031 Adjust prop
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-14 11:52:47 -08:00
Allan Carr
c7fc75aa5c Merged in feature/IO-3031-Appointment-Schedule-View-Day (pull request #1922)
IO-3031 View Day when Scheduling

Approved-by: Dave Richer
2024-11-14 19:50:34 +00:00
Allan Carr
98d2372daf Merged in feature/IO-3030-QBO-Payment-Private-Note (pull request #1920)
IO-3030 QBO Payment Private Note

Approved-by: Dave Richer
2024-11-14 19:40:20 +00:00
Allan Carr
bf51380167 IO-3031 View Day when Scheduling
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-14 11:19:09 -08:00
Dave Richer
1ec827097f Merged in feature/IO-3029-Enhanced-Logging-File-Based (pull request #1921)
feature/IO-3029-Enhanced-Logging-File-Based: Adjust XML and JSON log to always upload
2024-11-14 18:55:57 +00:00
Dave Richer
89fabf85e1 feature/IO-3029-Enhanced-Logging-File-Based: Adjust XML and JSON log to always upload
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-14 10:55:14 -08:00
Allan Carr
ff7dd7d3ea IO-3030 QBO Payment Private Note
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-14 10:37:08 -08:00
Dave Richer
8cc4f88fa7 Merged in feature/IO-3029-Enhanced-Logging-File-Based (pull request #1918)
feature/IO-3029-Enhanced-Logging-File-Based: Final Enhancements
2024-11-14 16:36:13 +00:00
Dave Richer
2439755f9e feature/IO-3029-Enhanced-Logging-File-Based: Final Enhancements
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-14 08:34:21 -08:00
Dave Richer
7e6ab3a5ff Merged in feature/IO-3029-Enhanced-Logging-File-Based (pull request #1916)
feature/IO-3029-Enhanced-Logging-File-Based: Update Stream Key name
2024-11-14 04:14:54 +00:00
Dave Richer
763384f05f feature/IO-3029-Enhanced-Logging-File-Based: Update Stream Key name
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-13 20:14:17 -08:00
Dave Richer
34f876f838 Merged in feature/IO-3029-Enhanced-Logging-File-Based (pull request #1914)
feature/IO-3029-Enhanced-Logging-File-Based: Add File based S3 Logging.
2024-11-14 03:57:24 +00:00
Dave Richer
cba2da8da7 feature/IO-3029-Enhanced-Logging-File-Based: Add fix bugs
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-13 13:09:37 -08:00
Dave Richer
f3d8aa3438 feature/IO-3029-Enhanced-Logging-File-Based: Add File based S3 Logging.
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-13 12:59:56 -08:00
Allan Carr
2f3eccf3d8 Merged in feature/IO-3028-Word-Wrap-Line-Description (pull request #1912)
IO-3028 Extend to Notes
2024-11-13 18:50:32 +00:00
Allan Carr
2b3e64d607 IO-3028 Extend to Notes
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-13 10:49:47 -08:00
Allan Carr
05b20505bb Merged in feature/IO-3028-Word-Wrap-Line-Description (pull request #1910)
IO-3028 Word Wrap Line Description

Approved-by: Dave Richer
2024-11-13 18:17:34 +00:00
Allan Carr
bddeae945c IO-3028 Word Wrap Line Description
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-13 10:06:01 -08:00
Patrick Fic
5b267f03b9 Add additional GIN indexes for db. 2024-11-12 20:21:43 -08:00
Dave Richer
357d916e0a Merged in release/2024-11-15 (pull request #1908)
[DO NOT MERGE] - Release/2024 11 15

Approved-by: Patrick Fic
2024-11-13 00:30:22 +00:00
Dave Richer
6ed12ebe7d Merged in feature/IO-3026-Enhanced-Notifications (pull request #1909)
feature/IO-3026-Enhanced-Notifications - final revisions
2024-11-12 22:52:13 +00:00
Dave Richer
6703bc025d feature/IO-3026-Enhanced-Notifications - final revisions
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-12 14:51:50 -08:00
Dave Richer
387dac6779 Merged in feature/IO-3026-Enhanced-Notifications (pull request #1906)
Feature/IO-3026 Enhanced Notifications
2024-11-12 22:23:35 +00:00
Dave Richer
6f454dd4cb feature/IO-3026-Enhanced-Notifications - final revisions
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-12 14:20:49 -08:00
Dave Richer
1440a60228 feature/IO-3026-Enhanced-Notifications - Initial commit
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-12 12:31:46 -08:00
Allan Carr
f2aa3960aa Merged in release/2024-11-08 (pull request #1905)
Release/2024 11 08 IO-2921 IO-3025
2024-11-09 08:37:27 +00:00
Allan Carr
06508f3ad8 Merged in feature/IO-2921-CARSTAR-Canada-Chatter-Integration (pull request #1903)
Feature/IO-2921 CARSTAR Canada Chatter Integration
2024-11-09 08:33:32 +00:00
Allan Carr
8d4195b596 IO-2921 Adjust SFTP setup
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-09 00:35:18 -08:00
Allan Carr
9e190e7fb7 Merged in feature/IO-3025-Autohouse-Datapump-Refactor (pull request #1902)
IO-3025 Adjust for promise and change processing
2024-11-09 08:32:49 +00:00
Allan Carr
5cbf00b0c8 IO-3025 Adjust for promise and change processing
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-09 00:32:51 -08:00
Allan Carr
655aeb86fc IO-2921 Adjust for Promise and change processing
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-08 23:43:18 -08:00
Allan Carr
225549275d Merged in release/2024-11-08 (pull request #1901)
Release/2024 11 08 IO-2921 IO-3025
2024-11-09 06:33:14 +00:00
Allan Carr
f0717b8b36 IO-2921 Shift Email outside of Batch
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-08 22:30:22 -08:00
Allan Carr
78771ae750 IO-3025 Shift Email send to outside of batch
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-08 22:28:02 -08:00
Allan Carr
0389908398 Merged in feature/IO-2921-CARSTAR-Canada-Chatter-Integration (pull request #1899)
IO-2921 Shift Email outside of Batch
2024-11-09 06:27:57 +00:00
Allan Carr
54bee763df Merged in feature/IO-3025-Autohouse-Datapump-Refactor (pull request #1898)
IO-3025 Shift Email send to outside of batch
2024-11-09 06:25:39 +00:00
Dave Richer
1117a94930 Merged in release/2024-11-08 (pull request #1897)
release/2024-11-08 - Small fix to font script
2024-11-09 05:21:03 +00:00
Dave Richer
5fbfb992c7 release/2024-11-08 - Small fix to font script
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-08 21:18:58 -08:00
Dave Richer
87b3b65f3e Merged in release/2024-11-08 (pull request #1893)
Release/2024-11-08 into master-AIO - IO-2921, IO-2969, IO-3001, IO-3015, IO-3017, IO-3018, IO-3025

Approved-by: Allan Carr
2024-11-09 04:52:01 +00:00
Allan Carr
9970190909 Merged in feature/IO-3025-Autohouse-Datapump-Refactor (pull request #1894)
IO-3025 Autohouse Datapump Refactor
2024-11-09 03:31:37 +00:00
Allan Carr
8eee371a90 IO-3025 Autohouse Datapump Refactor
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-08 19:30:33 -08:00
Dave Richer
ba97b1efef Merged in feature/IO-3015-addl-prod-indexes (pull request #1891)
Feature/IO-3015 addl prod indexes
2024-11-08 17:57:47 +00:00
Allan Carr
8d8887c28e Merged in feature/IO-3017-Lifecycle-Average-Time (pull request #1889)
IO-3017 Lifecycle Average Time

Approved-by: Dave Richer
2024-11-08 17:33:31 +00:00
Dave Richer
3b19432974 feature/IO-3017-Lifecycle-Average-Time - Small fixes
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-08 09:32:58 -08:00
Allan Carr
a14b2340b0 IO-3017 Lifecycle NaN prevention
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-08 09:24:58 -08:00
Allan Carr
624f8e77cb IO-3017 Lifecycle Average Time
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-07 13:56:53 -08:00
Dave Richer
fb624c817d Merged in feature/IO-2969-Fonts-For-Production (pull request #1888)
Feature/IO-2969 Fonts For Production into release
2024-11-07 20:42:26 +00:00
Dave Richer
c2b4b66ed1 hotfix/IO-2969-Fonts-For-Production
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-07 12:40:13 -08:00
Dave Richer
ffec03ab6c Merge remote-tracking branch 'origin/master-AIO' into hotfix/IO-2969-Fonts-For-Production 2024-11-07 12:37:01 -08:00
Allan Carr
552163d7b9 IO-2921 Upload directory
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-06 15:53:51 -08:00
Allan Carr
db1f59578c Merged in feature/IO-2921-CARSTAR-Canada-Chatter-Integration (pull request #1887)
IO-2921 Re-factor as batch and get docker compose dev working for sftp testing

Approved-by: Dave Richer
2024-11-06 23:51:46 +00:00
Allan Carr
8ec5831ec5 IO-2921 Re-factor as batch and get docker compose dev working for sftp testing
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-06 15:42:14 -08:00
Allan Carr
0146ac5b7b Merged in feature/IO-3018-QBO-Standarize-name (pull request #1886)
IO-3018 QBO Standardize name

Approved-by: Dave Richer
2024-11-06 16:37:47 +00:00
Allan Carr
a603e5c0b8 Merged in feature/IO-2921-CARSTAR-Canada-Chatter-Integration (pull request #1885)
IO-2921 Adjustment for SFTP Private Key

Approved-by: Dave Richer
2024-11-06 16:36:52 +00:00
Patrick Fic
9aab47d8f8 IO-2920 Update config & totals for discount. 2024-11-05 16:37:21 -08:00
Patrick Fic
f2f84e2da8 Merge branch 'master-AIO' into feature/IO-2920-cash-discounting 2024-11-05 16:00:47 -08:00
Allan Carr
338906e288 IO-3018 QBO Standardize name
Trim StandardizeName

Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-05 11:53:42 -08:00
Allan Carr
542997b1a7 IO-2921 Adjustment for SFTP Private Key
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-05 11:02:18 -08:00
Dave Richer
5fce548666 Merged in release/2024-11-01 (pull request #1884)
Release/2024-11-01 into master-AIO - IO-2921, IO-3006, IO-3008, IO-3009, IO-3010
2024-11-02 15:14:06 +00:00
Dave Richer
80322caad0 release/2024-11-01 - Update Trigger for job_updated - Make the callback work with old and new Hasura
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-02 08:11:22 -07:00
Patrick Fic
56472d24d9 IO-3015 add additional indexs. 2024-11-01 21:33:03 -07:00
Patrick Fic
db5dcc271d IO-30015 add new indexes to production. 2024-11-01 20:35:44 -07:00
Allan Carr
73ab02225e IO-2921 Adjustment to getting Secret
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-01 17:55:33 -07:00
Allan Carr
83a1b7690d Merged in feature/IO-2921-CARSTAR-Canada-Chatter-Integration (pull request #1881)
IO-2921 Adjustment to getting Secret
2024-11-02 00:54:56 +00:00
Allan Carr
c9e28b1ed2 Merge branch 'master-AIO' into feature/IO-2921-CARSTAR-Canada-Chatter-Integration
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-01 17:52:54 -07:00
Allan Carr
c25c66d00f Merged in feature/IO-3009-Clear-Dates (pull request #1879)
IO-3009 Correction for nulls
2024-11-01 17:16:23 +00:00
Allan Carr
d319ab49d4 IO-3009 Correction for nulls
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-01 10:18:14 -07:00
Allan Carr
a069989ea7 Merged in feature/IO-3014-Timeticket-UI-Sort (pull request #1876)
IO-3014 Change Polling Intervals

Approved-by: Dave Richer
2024-10-31 18:13:51 +00:00
Dave Richer
8e3aa186cb Merged in hotfix/2024-10-31-Database-Issues (pull request #1877)
Hotfix/2024 10 31 Database Issues into master-AIO - IO-3012 IO-3009 IO-3014
2024-10-31 18:06:08 +00:00
Allan Carr
01c55d6277 IO-3014 Change Polling Intervals
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-10-31 11:04:05 -07:00
Dave Richer
3438907d8d Merge remote-tracking branch 'origin/feature/IO-3014-Timeticket-UI-Sort' into hotfix/2024-10-31-Database-Issues 2024-10-31 11:02:09 -07:00
Allan Carr
ae020b651e IO-3014 Further Query Refinements for T/T
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-10-31 10:56:05 -07:00
Allan Carr
d22988df15 Merged in feature/IO-3014-Timeticket-UI-Sort (pull request #1875)
IO-3014 TimeTicket UI Sort

Approved-by: Dave Richer
2024-10-31 17:54:36 +00:00
Dave Richer
8136a56ad2 Merge remote-tracking branch 'origin/feature/IO-3014-Timeticket-UI-Sort' into hotfix/2024-10-31-Database-Issues 2024-10-31 10:53:56 -07:00
Allan Carr
830f6c0eea IO-3014 TimeTicket UI Sort
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-10-31 10:48:40 -07:00
Dave Richer
4c1849289a Merge remote-tracking branch 'origin/feature/IO-3014-Timeticket-UI-Sort' into hotfix/2024-10-31-Database-Issues 2024-10-31 10:46:21 -07:00
Dave Richer
c45a4780e3 Merge remote-tracking branch 'origin/feature/IO-3012-Remove-Sort-for-SB-TimeTickets-Query' into hotfix/2024-10-31-Database-Issues 2024-10-31 10:34:56 -07:00
Allan Carr
d4adc4c1aa Merged in feature/IO-3009-Clear-Dates (pull request #1872)
IO-3009 Clear Dates

Approved-by: Dave Richer
2024-10-31 17:06:38 +00:00
Allan Carr
d9e71423f5 Merged in feature/IO-3010-Task-Table-UI-Mods (pull request #1873)
IO-3010 Task Table UI refactor

Approved-by: Dave Richer
2024-10-31 17:06:02 +00:00
Allan Carr
2ab4615642 IO-3009 Clear Dates
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-10-30 12:48:27 -07:00
Dave Richer
dd5961d419 release/2024-11-01 - Update Trigger for job_updated
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-10-30 12:07:50 -07:00
Allan Carr
8190958ba3 Merged in feature/IO-3012-Remove-Sort-for-SB-TimeTickets-Query (pull request #1871)
IO-3012 Remove Sort from SB Timeticket Query

Approved-by: Dave Richer
2024-10-30 19:03:18 +00:00
Allan Carr
77e009f316 IO-3012 Remove Sort from SB Timeticket Query
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-10-30 11:19:46 -07:00
Dave Richer
2b2738a8d1 Merge branch 'release/2024-11-01' of bitbucket.org:snaptsoft/bodyshop into release/2024-11-01 2024-10-30 09:38:56 -07:00
Dave Richer
3d10c9da7f release/2024-11-01 - Misc fixes
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-10-30 09:38:27 -07:00
Allan Carr
e82c77d119 Merged in feature/IO-3008-Save-&-New-Time-Ticket (pull request #1868)
IO-3008 Save and New Flat Rate value

Approved-by: Dave Richer
2024-10-30 16:12:21 +00:00
Allan Carr
855a78be05 Merged in feature/IO-3006-CDK-PBS-Error-Log-INSERT_EXPORT_LOG (pull request #1867)
IO-3006 CDK PBS Error Log on INSERT_EXPORT_LOG

Approved-by: Dave Richer
2024-10-30 16:11:49 +00:00
Dave Richer
a29e840797 release/2024-11-01 - Misc fixes
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-10-30 09:09:16 -07:00
Allan Carr
1b30c1ab58 IO-3008 Save and New Flat Rate value
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-10-29 20:03:58 -07:00
Allan Carr
80f235f12e IO-3006 CDK PBS Error Log on INSERT_EXPORT_LOG
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-10-29 13:33:40 -07:00
Dave Richer
5b00ded5f6 hotfix/IO-2969-Fonts-For-Production - Register fonts
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-09-27 19:29:26 -04:00
Dave Richer
c5b19d8f22 hotfix/IO-2969-Fonts-For-Production - Register fonts
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-09-27 19:20:19 -04:00
90 changed files with 14707 additions and 13353 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text eol=lf

View File

@@ -0,0 +1,24 @@
#!/bin/bash
# Install required packages
dnf install -y fontconfig freetype
# Move to the /tmp directory for temporary download and extraction
cd /tmp
# Download the Montserrat font zip file
wget https://images.imex.online/fonts/montserrat.zip -O montserrat.zip
# Unzip the downloaded font file
unzip montserrat.zip -d montserrat
# Move the font files to the system fonts directory
mv montserrat/montserrat/*.ttf /usr/share/fonts
# Rebuild the font cache
fc-cache -fv
# Clean up
rm -rf /tmp/montserrat /tmp/montserrat.zip
echo "Montserrat fonts installed and cached successfully."

30
.vscode/settings.json vendored
View File

@@ -8,5 +8,35 @@
"pattern": "**/IMEX.xml",
"systemId": "logs/IMEX.xsd"
}
],
"cSpell.words": [
"antd",
"appointmentconfirmation",
"appt",
"autohouse",
"autohouseid",
"billlines",
"bodyshop",
"bodyshopid",
"bodyshops",
"CIECA",
"claimscorp",
"claimscorpid",
"Dinero",
"driveable",
"IMEX",
"imexshopid",
"jobid",
"joblines",
"Kaizen",
"labhrs",
"larhrs",
"mixdata",
"ownr",
"promanager",
"shopname",
"smartscheduling",
"timetickets",
"touchtime"
]
}

View File

@@ -11156,6 +11156,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>imexpay</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>insurancecos</name>
<definition_loaded>false</definition_loaded>
@@ -11198,27 +11219,6 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>intellipay</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>intellipay_cash_discount</name>
<definition_loaded>false</definition_loaded>
@@ -11747,6 +11747,48 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>ttl_adjustment</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>ttl_tax_adjustment</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
<folder_node>
@@ -11775,6 +11817,27 @@
</concept_node>
</children>
</folder_node>
<concept_node>
<name>romepay</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>scheduling</name>
<definition_loaded>false</definition_loaded>
@@ -36253,6 +36316,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_cust_payable_cash_discount</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_repairs</name>
<definition_loaded>false</definition_loaded>
@@ -48360,6 +48444,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>tasks_in_view</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>tasks_on_board</name>
<definition_loaded>false</definition_loaded>
@@ -48402,6 +48507,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_amount_in_view</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_amount_on_board</name>
<definition_loaded>false</definition_loaded>
@@ -48444,6 +48570,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_hours_in_view</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_hours_on_board</name>
<definition_loaded>false</definition_loaded>
@@ -48465,6 +48612,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_jobs_in_view</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_jobs_on_board</name>
<definition_loaded>false</definition_loaded>
@@ -48507,6 +48675,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_lab_in_view</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_lab_on_board</name>
<definition_loaded>false</definition_loaded>
@@ -48549,6 +48738,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_lar_in_view</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_lar_on_board</name>
<definition_loaded>false</definition_loaded>
@@ -48724,6 +48934,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>tasks_in_view</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>tasks_on_board</name>
<definition_loaded>false</definition_loaded>
@@ -48766,6 +48997,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_amount_in_view</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_amount_on_board</name>
<definition_loaded>false</definition_loaded>
@@ -48808,6 +49060,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_hours_in_view</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_hours_on_board</name>
<definition_loaded>false</definition_loaded>
@@ -48829,6 +49102,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_jobs_in_view</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_jobs_on_board</name>
<definition_loaded>false</definition_loaded>
@@ -48871,6 +49165,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_lab_in_view</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_lab_on_board</name>
<definition_loaded>false</definition_loaded>
@@ -48913,6 +49228,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_lar_in_view</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_lar_on_board</name>
<definition_loaded>false</definition_loaded>
@@ -51761,6 +52097,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>production_not_production_status</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>production_over_time</name>
<definition_loaded>false</definition_loaded>
@@ -54225,6 +54582,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>created_by</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>description</name>
<definition_loaded>false</definition_loaded>
@@ -54487,6 +54865,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>related_items</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>remind_at</name>
<definition_loaded>false</definition_loaded>

View File

@@ -1,7 +1,7 @@
import { Badge, Card, List, Space, Tag } from "antd";
import React from "react";
import { connect } from "react-redux";
import { AutoSizer, CellMeasurer, CellMeasurerCache, List as VirtualizedList } from "react-virtualized";
import { Virtuoso } from "react-virtuoso";
import { createStructuredSelector } from "reselect";
import { setSelectedConversation } from "../../redux/messaging/messaging.actions";
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
@@ -25,12 +25,7 @@ function ChatConversationListComponent({
setSelectedConversation,
loadMoreConversations
}) {
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 60
});
const rowRenderer = ({ index, key, style, parent }) => {
const renderConversation = (index) => {
const item = conversationList[index];
const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
const cardContentLeft =
@@ -52,6 +47,7 @@ function ChatConversationListComponent({
)}
</>
);
const cardExtra = <Badge count={item.messages_aggregate.aggregate.count || 0} />;
const getCardStyle = () =>
@@ -60,40 +56,27 @@ function ChatConversationListComponent({
: { backgroundColor: index % 2 === 0 ? "#f0f2f5" : "#ffffff" };
return (
<CellMeasurer key={key} cache={cache} parent={parent} columnIndex={0} rowIndex={index}>
<List.Item
onClick={() => setSelectedConversation(item.id)}
style={style}
className={`chat-list-item
${item.id === selectedConversation ? "chat-list-selected-conversation" : null}`}
>
<Card style={getCardStyle()} bordered={false} size="small" extra={cardExtra} title={cardTitle}>
<div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div>
<div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div>
</Card>
</List.Item>
</CellMeasurer>
<List.Item
key={item.id}
onClick={() => setSelectedConversation(item.id)}
className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`}
>
<Card style={getCardStyle()} bordered={false} size="small" extra={cardExtra} title={cardTitle}>
<div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div>
<div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div>
</Card>
</List.Item>
);
};
return (
<div className="chat-list-container">
<AutoSizer>
{({ height, width }) => (
<VirtualizedList
height={height}
width={width}
rowCount={conversationList.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
onScroll={({ scrollTop, scrollHeight, clientHeight }) => {
if (scrollTop + clientHeight === scrollHeight) {
loadMoreConversations();
}
}}
/>
)}
</AutoSizer>
<Virtuoso
data={conversationList}
itemContent={(index) => renderConversation(index)}
style={{ height: "100%", width: "100%" }}
endReached={loadMoreConversations} // Calls loadMoreConversations when scrolled to the bottom
/>
</div>
);
}

View File

@@ -1,7 +1,7 @@
.chat-list-container {
overflow: hidden;
height: 100%;
height: 100%; /* Ensure it takes up the full available height */
border: 1px solid gainsboro;
overflow: auto; /* Allow scrolling for the Virtuoso component */
}
.chat-list-item {
@@ -14,3 +14,24 @@
color: #ff7a00;
}
}
/* Virtuoso item container adjustments */
.chat-list-container > div {
height: 100%; /* Ensure Virtuoso takes full height */
display: flex;
flex-direction: column;
}
/* Add spacing and better alignment for items */
.chat-list-item {
padding: 0.5rem 0; /* Add spacing between list items */
.ant-card {
border-radius: 8px; /* Slight rounding for card edges */
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); /* Subtle shadow for better definition */
}
&:hover .ant-card {
border-color: #ff7a00; /* Highlight border on hover */
}
}

View File

@@ -1,81 +1,83 @@
import { useMutation, useQuery, useSubscription } from "@apollo/client";
import React, { useState } from "react";
import React, { useContext, useEffect, useState } from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
import { MARK_MESSAGES_AS_READ_BY_CONVERSATION } from "../../graphql/messages.queries";
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import ChatConversationComponent from "./chat-conversation.component";
import axios from "axios";
import { selectBodyshop } from "../../redux/user/user.selectors";
import ChatConversationComponent from "./chat-conversation.component";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,
bodyshop: selectBodyshop
});
export default connect(mapStateToProps, null)(ChatConversationContainer);
const mapDispatchToProps = (dispatch) => ({});
export function ChatConversationContainer({ bodyshop, selectedConversation }) {
const {
loading: convoLoading,
error: convoError,
data: convoData
} = useQuery(GET_CONVERSATION_DETAILS, {
variables: { conversationId: selectedConversation },
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
const { loading, error, data } = useSubscription(CONVERSATION_SUBSCRIPTION_BY_PK, {
variables: { conversationId: selectedConversation }
});
const { socket } = useContext(SocketContext);
const [conversationDetails, setConversationDetails] = useState({});
const [messages, setMessages] = useState([]);
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
const [loading, setLoading] = useState(false);
const [error] = useState(null);
const [markConversationRead] = useMutation(MARK_MESSAGES_AS_READ_BY_CONVERSATION, {
variables: { conversationId: selectedConversation },
refetchQueries: ["UNREAD_CONVERSATION_COUNT"],
update(cache) {
cache.modify({
id: cache.identify({
__typename: "conversations",
id: selectedConversation
}),
fields: {
messages_aggregate(cached) {
return { aggregate: { count: 0 } };
}
}
// Fetch conversation details and messages when a conversation is selected
useEffect(() => {
if (socket && selectedConversation) {
setLoading(true);
socket.emit("join-conversation", selectedConversation);
socket.on("conversation-details", (data) => {
setConversationDetails(data.conversation);
setMessages(data.messages);
setLoading(false);
});
socket.on("new-message", (message) => {
setMessages((prevMessages) => [...prevMessages, message]);
});
socket.on("conversation-list-updated", (data) => {
setConversationDetails(data.conversation);
setMessages(data.messages);
});
return () => {
socket.emit("leave-conversation", selectedConversation);
socket.off("conversation-details");
socket.off("new-message");
socket.off("conversation-list-updated");
};
}
});
const unreadCount =
data &&
data.messages &&
data.messages.reduce((acc, val) => {
return !val.read && !val.isoutbound ? acc + 1 : acc;
}, 0);
}, [socket, selectedConversation]);
// Mark messages as read
const handleMarkConversationAsRead = async () => {
if (unreadCount > 0 && !!selectedConversation && !markingAsReadInProgress) {
if (messages.some((msg) => !msg.read) && !markingAsReadInProgress) {
setMarkingAsReadInProgress(true);
await markConversationRead({});
await axios.post("/sms/markConversationRead", {
conversationid: selectedConversation,
imexshopid: bodyshop.imexshopid
// Emit a WebSocket event to mark messages as read
socket.emit("mark-as-read", {
conversationId: selectedConversation,
imexshopid: bodyshop.imexshopid,
bodyshopId: bodyshop.id
});
setMarkingAsReadInProgress(false);
}
};
if (!messages || !conversationDetails || !messages.length) {
return null;
}
return (
<ChatConversationComponent
subState={[loading || convoLoading, error || convoError]}
conversation={convoData ? convoData.conversations_by_pk : {}}
messages={data ? data.messages : []}
subState={[loading, error]}
conversation={conversationDetails}
messages={messages}
handleMarkConversationAsRead={handleMarkConversationAsRead}
/>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatConversationContainer);

View File

@@ -2,105 +2,90 @@ import Icon from "@ant-design/icons";
import { Tooltip } from "antd";
import i18n from "i18next";
import dayjs from "../../utils/day";
import React, { useEffect, useRef } from "react";
import React, { useRef, useEffect } from "react";
import { MdDone, MdDoneAll } from "react-icons/md";
import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from "react-virtualized";
import { Virtuoso } from "react-virtuoso";
import { DateTimeFormatter } from "../../utils/DateFormatter";
import "./chat-message-list.styles.scss";
export default function ChatMessageListComponent({ messages }) {
const virtualizedListRef = useRef(null);
const virtuosoRef = useRef(null);
const _cache = new CellMeasurerCache({
fixedWidth: true,
// minHeight: 50,
defaultHeight: 100
});
// Scroll to the bottom after a short delay when the component mounts
useEffect(() => {
const timer = setTimeout(() => {
if (virtuosoRef.current) {
virtuosoRef.current.scrollToIndex({
index: messages.length - 1,
behavior: "auto" // Instantly scroll to the bottom
});
}
}, 100); // Delay of 100ms to allow rendering
return () => clearTimeout(timer); // Cleanup the timer on unmount
}, [messages.length]); // Run only once on component mount
const scrollToBottom = (renderedrows) => {
//console.log("Scrolling to", messages.length);
// !!virtualizedListRef.current &&
// virtualizedListRef.current.scrollToRow(messages.length);
// Outstanding isue on virtualization: https://github.com/bvaughn/react-virtualized/issues/1179
//Scrolling does not work on this version of React.
};
// Scroll to the bottom after the new messages are rendered
useEffect(() => {
if (virtuosoRef.current) {
// Allow the DOM and Virtuoso to fully render the new data
setTimeout(() => {
virtuosoRef.current.scrollToIndex({
index: messages.length - 1,
align: "end", // Ensure the last message is fully visible
behavior: "smooth" // Smooth scrolling
});
}, 50); // Slight delay to ensure layout recalculates
}
}, [messages]); // Triggered when new messages are added
useEffect(scrollToBottom, [messages]);
const _rowRenderer = ({ index, key, parent, style }) => {
const renderMessage = (index) => {
const message = messages[index];
return (
<CellMeasurer cache={_cache} key={key} rowIndex={index} parent={parent}>
{({ measure, registerChild }) => (
<div
ref={registerChild}
onLoad={measure}
style={style}
className={`${messages[index].isoutbound ? "mine messages" : "yours messages"}`}
>
<div className="message msgmargin">
{MessageRender(messages[index])}
{StatusRender(messages[index].status)}
<div key={index} className={`${message.isoutbound ? "mine messages" : "yours messages"}`}>
<div className="message msgmargin">
<Tooltip title={DateTimeFormatter({ children: message.created_at })}>
<div>
{message.image_path &&
message.image_path.map((i, idx) => (
<div key={idx} style={{ display: "flex", justifyContent: "center" }}>
<a href={i} target="__blank" rel="noopener noreferrer">
<img alt="Received" className="message-img" src={i} />
</a>
</div>
))}
<div>{message.text}</div>
</div>
{messages[index].isoutbound && (
<div style={{ fontSize: 10 }}>
{i18n.t("messaging.labels.sentby", {
by: messages[index].userid,
time: dayjs(messages[index].created_at).format("MM/DD/YYYY @ hh:mm a")
})}
</div>
)}
</Tooltip>
{message.status && (
<div className="message-status">
<Icon
component={message.status === "sent" ? MdDone : message.status === "delivered" ? MdDoneAll : null}
className="message-icon"
/>
</div>
)}
</div>
{message.isoutbound && (
<div style={{ fontSize: 10 }}>
{i18n.t("messaging.labels.sentby", {
by: message.userid,
time: dayjs(message.created_at).format("MM/DD/YYYY @ hh:mm a")
})}
</div>
)}
</CellMeasurer>
</div>
);
};
return (
<div className="chat">
<AutoSizer>
{({ height, width }) => (
<List
ref={virtualizedListRef}
width={width}
height={height}
rowHeight={_cache.rowHeight}
rowRenderer={_rowRenderer}
rowCount={messages.length}
overscanRowCount={10}
estimatedRowSize={150}
scrollToIndex={messages.length}
/>
)}
</AutoSizer>
<Virtuoso
ref={virtuosoRef}
data={messages}
itemContent={(index) => renderMessage(index)}
followOutput="smooth" // Ensure smooth scrolling when new data is appended
style={{ height: "100%", width: "100%" }}
/>
</div>
);
}
const MessageRender = (message) => {
return (
<Tooltip title={DateTimeFormatter({ children: message.created_at })}>
<div>
{message.image_path &&
message.image_path.map((i, idx) => (
<div key={idx} style={{ display: "flex", justifyContent: "center" }}>
<a href={i} target="__blank">
<img alt="Received" className="message-img" src={i} />
</a>
</div>
))}
<div>{message.text}</div>
</div>
</Tooltip>
);
};
const StatusRender = (status) => {
switch (status) {
case "sent":
return <Icon component={MdDone} className="message-icon" />;
case "delivered":
return <Icon component={MdDoneAll} className="message-icon" />;
default:
return null;
}
};

View File

@@ -1,37 +1,30 @@
.message-icon {
//position: absolute;
// bottom: 0rem;
color: whitesmoke;
border: #000000;
position: absolute;
margin: 0 0.1rem;
bottom: 0.1rem;
right: 0.3rem;
z-index: 5;
}
.chat {
flex: 1;
//width: 300px;
//border: solid 1px #eee;
display: flex;
flex-direction: column;
margin: 0.8rem 0rem;
overflow: hidden; // Ensure the content scrolls correctly
}
.messages {
//margin-top: 30px;
display: flex;
flex-direction: column;
padding: 0.5rem; // Add padding to avoid edge clipping
}
.message {
border-radius: 20px;
padding: 0.25rem 0.8rem;
//margin-top: 5px;
// margin-bottom: 5px;
//display: inline-block;
.message-img {
max-width: 10rem;
@@ -56,7 +49,7 @@
position: relative;
}
.yours .message.last:before {
.yours .message:last-child:before {
content: "";
position: absolute;
z-index: 0;
@@ -68,7 +61,7 @@
border-bottom-right-radius: 15px;
}
.yours .message.last:after {
.yours .message:last-child:after {
content: "";
position: absolute;
z-index: 1;
@@ -88,12 +81,11 @@
color: white;
margin-left: 25%;
background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%);
background-attachment: fixed;
position: relative;
padding-bottom: 0.6rem;
}
.mine .message.last:before {
.mine .message:last-child:before {
content: "";
position: absolute;
z-index: 0;
@@ -102,11 +94,10 @@
height: 20px;
width: 20px;
background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%);
background-attachment: fixed;
border-bottom-left-radius: 15px;
}
.mine .message.last:after {
.mine .message:last-child:after {
content: "";
position: absolute;
z-index: 1;

View File

@@ -1,73 +1,59 @@
import { InfoCircleOutlined, MessageOutlined, ShrinkOutlined, SyncOutlined } from "@ant-design/icons";
import { useLazyQuery, useQuery } from "@apollo/client";
import { Badge, Card, Col, Row, Space, Tag, Tooltip, Typography } from "antd";
import React, { useCallback, useEffect, useState } from "react";
import { InfoCircleOutlined, MessageOutlined, ShrinkOutlined } from "@ant-design/icons";
import { Badge, Card, Col, Row, Space, Tooltip, Typography } from "antd";
import React, { useCallback, useContext, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { CONVERSATION_LIST_QUERY, UNREAD_CONVERSATION_COUNT } from "../../graphql/conversations.queries";
import { toggleChatVisible } from "../../redux/messaging/messaging.actions";
import { selectChatVisible, selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import {
selectChatVisible,
selectConversations,
selectSelectedConversation,
selectUnreadCount
} from "../../redux/messaging/messaging.selectors";
import ChatConversationListComponent from "../chat-conversation-list/chat-conversation-list.component";
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 "./chat-popup.styles.scss";
import { selectBodyshop } from "../../redux/user/user.selectors";
import SocketContext from "../../contexts/SocketIO/socketContext";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,
chatVisible: selectChatVisible
chatVisible: selectChatVisible,
bodyshop: selectBodyshop,
conversations: selectConversations,
unreadCount: selectUnreadCount
});
const mapDispatchToProps = (dispatch) => ({
toggleChatVisible: () => dispatch(toggleChatVisible())
});
export function ChatPopupComponent({ chatVisible, selectedConversation, toggleChatVisible }) {
export function ChatPopupComponent({
chatVisible,
selectedConversation,
toggleChatVisible,
bodyshop,
conversations,
unreadCount
}) {
const { t } = useTranslation();
const [pollInterval, setpollInterval] = useState(0);
const { data: unreadData } = useQuery(UNREAD_CONVERSATION_COUNT, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
...(pollInterval > 0 ? { pollInterval } : {})
});
const [getConversations, { loading, data, refetch, fetchMore }] = useLazyQuery(CONVERSATION_LIST_QUERY, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
skip: !chatVisible,
...(pollInterval > 0 ? { pollInterval } : {})
});
const fcmToken = sessionStorage.getItem("fcmtoken");
const { socket } = useContext(SocketContext);
// Emit event to open messaging when chat becomes visible
useEffect(() => {
if (fcmToken) {
setpollInterval(0);
} else {
setpollInterval(60000);
if (chatVisible && socket && bodyshop?.id) {
socket.emit("open-messaging", bodyshop.id);
}
}, [fcmToken]);
useEffect(() => {
if (chatVisible)
getConversations({
variables: {
offset: 0
}
});
}, [chatVisible, getConversations]);
}, [chatVisible, socket, bodyshop?.id]);
// Handle loading more conversations
const loadMoreConversations = useCallback(() => {
if (data)
fetchMore({
variables: {
offset: data.conversations.length
}
});
}, [data, fetchMore]);
const unreadCount = unreadData?.messages_aggregate.aggregate.count || 0;
if (socket) {
socket.emit("load-more-conversations", { offset: conversations.length });
}
}, [socket, conversations.length]);
return (
<Badge count={unreadCount}>
@@ -80,21 +66,19 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
<Tooltip title={t("messaging.labels.recentonly")}>
<InfoCircleOutlined />
</Tooltip>
<SyncOutlined style={{ cursor: "pointer" }} onClick={() => refetch()} />
{pollInterval > 0 && <Tag color="yellow">{t("messaging.labels.nopush")}</Tag>}
<ShrinkOutlined
onClick={() => toggleChatVisible()}
style={{ position: "absolute", right: ".5rem", top: ".5rem" }}
/>
</Space>
<ShrinkOutlined
onClick={() => toggleChatVisible()}
style={{ position: "absolute", right: ".5rem", top: ".5rem" }}
/>
<Row gutter={[8, 8]} className="chat-popup-content">
<Col span={8}>
{loading ? (
{conversations && conversations.length === 0 ? (
<LoadingSpinner />
) : (
<ChatConversationListComponent
conversationList={data ? data.conversations : []}
conversationList={conversations.conversations}
loadMoreConversations={loadMoreConversations}
/>
)}

View File

@@ -1,55 +1,66 @@
import { LoadingOutlined, SendOutlined } from "@ant-design/icons";
import { Input, Spin } from "antd";
import React, { useEffect, useRef, useState } from "react";
import React, { useContext, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { sendMessage, setMessage } from "../../redux/messaging/messaging.actions";
import { setMessage } from "../../redux/messaging/messaging.actions";
import { selectIsSending, selectMessage } from "../../redux/messaging/messaging.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import ChatMediaSelector from "../chat-media-selector/chat-media-selector.component";
import ChatPresetsComponent from "../chat-presets/chat-presets.component";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
isSending: selectIsSending,
message: selectMessage
message: selectMessage,
user: selectCurrentUser
});
const mapDispatchToProps = (dispatch) => ({
sendMessage: (message) => dispatch(sendMessage(message)),
setMessage: (message) => dispatch(setMessage(message))
});
function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSending, message, setMessage }) {
function ChatSendMessageComponent({ conversation, bodyshop, isSending, message, setMessage, user }) {
const { socket } = useContext(SocketContext); // Access WebSocket instance
const inputArea = useRef(null);
const [selectedMedia, setSelectedMedia] = useState([]);
const { t } = useTranslation();
useEffect(() => {
inputArea.current.focus();
}, [isSending, setMessage]);
const { t } = useTranslation();
const handleEnter = () => {
const selectedImages = selectedMedia.filter((i) => i.isSelected);
if ((message === "" || !message) && selectedImages.length === 0) return;
logImEXEvent("messaging_send_message");
if (selectedImages.length < 11) {
sendMessage({
const messageData = {
user,
to: conversation.phone_num,
body: message || "",
messagingServiceSid: bodyshop.messagingservicesid,
conversationid: conversation.id,
selectedMedia: selectedImages,
imexshopid: bodyshop.imexshopid
});
};
// Emit the send-message event via WebSocket
socket.emit("send-message", messageData);
setSelectedMedia(
selectedMedia.map((i) => {
return { ...i, isSelected: false };
})
);
// Optionally clear the input message
setMessage("");
}
};
@@ -74,15 +85,11 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
onChange={(e) => setMessage(e.target.value)}
onPressEnter={(event) => {
event.preventDefault();
if (!!!event.shiftKey) handleEnter();
if (!event.shiftKey) handleEnter();
}}
/>
</span>
<SendOutlined
className="imex-flex-row__margin"
// disabled={message === "" || !message}
onClick={handleEnter}
/>
<SendOutlined className="imex-flex-row__margin" onClick={handleEnter} />
<Spin
style={{ display: `${isSending ? "" : "none"}` }}
indicator={

View File

@@ -1,10 +1,10 @@
import { Card, Table, Tag } from "antd";
import LoadingSkeleton from "../../loading-skeleton/loading-skeleton.component";
import { useTranslation } from "react-i18next";
import React, { useEffect, useState } from "react";
import dayjs from "../../../utils/day";
import DashboardRefreshRequired from "../refresh-required.component";
import axios from "axios";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import dayjs from "../../../utils/day";
import LoadingSkeleton from "../../loading-skeleton/loading-skeleton.component";
import DashboardRefreshRequired from "../refresh-required.component";
const fortyFiveDaysAgo = () => dayjs().subtract(45, "day").toLocaleString();
@@ -46,6 +46,11 @@ export default function JobLifecycleDashboardComponent({ data, bodyshop, ...card
dataIndex: "humanReadable",
key: "humanReadable"
},
{
title: t("job_lifecycle.columns.average_human_readable"),
dataIndex: "averageHumanReadable",
key: "averageHumanReadable"
},
{
title: t("job_lifecycle.columns.status_count"),
key: "statusCount",

View File

@@ -44,7 +44,7 @@ function LogLevelHierarchy(level) {
return "orange";
case "INFO":
return "blue";
case "WARNING":
case "WARN":
return "yellow";
case "ERROR":
return "red";

View File

@@ -1,12 +1,12 @@
import { DatePicker } from "antd";
import { DatePicker, Space, TimePicker } from "antd";
import PropTypes from "prop-types";
import React, { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import dayjs from "../../utils/day";
import { fuzzyMatchDate } from "./formats.js";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
import dayjs from "../../utils/day";
import { fuzzyMatchDate } from "./formats.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -20,6 +20,7 @@ const DateTimePicker = ({
onlyFuture,
onlyToday,
isDateOnly = false,
isSeparatedTime = false,
bodyshop,
...restProps
}) => {
@@ -28,9 +29,8 @@ const DateTimePicker = ({
const handleChange = useCallback(
(newDate) => {
if (!newDate) return;
if (onChange) {
onChange(bodyshop?.timezone ? dayjs(newDate).tz(bodyshop.timezone, true) : newDate);
onChange(bodyshop?.timezone && newDate ? dayjs(newDate).tz(bodyshop.timezone, true) : newDate);
}
setIsManualInput(false);
},
@@ -88,24 +88,57 @@ const DateTimePicker = ({
return (
<div onKeyDown={handleKeyDown} id={id} style={{ width: "100%" }}>
<DatePicker
showTime={
isDateOnly
? false
: {
format: "hh:mm a",
minuteStep: 15,
defaultValue: dayjs(dayjs(), "HH:mm:ss")
}
}
format={isDateOnly ? "MM/DD/YYYY" : "MM/DD/YYYY hh:mm a"}
value={value ? dayjs(value) : null}
onChange={handleChange}
placeholder={isDateOnly ? t("general.labels.date") : t("general.labels.datetime")}
onBlur={onBlur || handleBlur}
disabledDate={handleDisabledDate}
{...restProps}
/>
{isSeparatedTime && (
<Space direction="vertical" style={{ width: "100%" }}>
<DatePicker
showTime={false}
format="MM/DD/YYYY"
value={value ? dayjs(value) : null}
onChange={handleChange}
placeholder={t("general.labels.date")}
onBlur={handleBlur}
disabledDate={handleDisabledDate}
isDateOnly={true}
{...restProps}
/>
{value && (
<TimePicker
format="hh:mm a"
minuteStep={15}
defaultOpenValue={dayjs(value)
.hour(dayjs().hour())
.minute(Math.floor(dayjs().minute() / 15) * 15)
.second(0)}
onChange={(value) => {
handleChange(value);
onBlur();
}}
placeholder={t("general.labels.time")}
{...restProps}
/>
)}
</Space>
)}
{!isSeparatedTime && (
<DatePicker
showTime={
isDateOnly
? false
: {
format: "hh:mm a",
minuteStep: 15,
defaultValue: dayjs(dayjs(), "HH:mm:ss")
}
}
format={isDateOnly ? "MM/DD/YYYY" : "MM/DD/YYYY hh:mm a"}
value={value ? dayjs(value) : null}
onChange={handleChange}
placeholder={isDateOnly ? t("general.labels.date") : t("general.labels.datetime")}
onBlur={onBlur || handleBlur}
disabledDate={handleDisabledDate}
{...restProps}
/>
)}
</div>
);
};
@@ -117,7 +150,8 @@ DateTimePicker.propTypes = {
id: PropTypes.string,
onlyFuture: PropTypes.bool,
onlyToday: PropTypes.bool,
isDateOnly: PropTypes.bool
isDateOnly: PropTypes.bool,
isSeparatedTime: PropTypes.bool
};
export default connect(mapStateToProps, null)(DateTimePicker);

View File

@@ -118,8 +118,7 @@ export function JobLinesComponent({
...(record.critical ? { boxShadow: " -.5em 0 0 #FFC107" } : {})
}
}),
sortOrder: state.sortedInfo.columnKey === "line_desc" && state.sortedInfo.order,
ellipsis: true
sortOrder: state.sortedInfo.columnKey === "line_desc" && state.sortedInfo.order
},
{
title: t("joblines.fields.oem_partno"),

View File

@@ -45,7 +45,8 @@ export default function JobLineNotePopup({ jobline, disabled }) {
if (editing)
return (
<div>
<Input
<Input.TextArea
autoSize
autoFocus
suffix={loading ? <LoadingSpinner /> : null}
value={note}

View File

@@ -1,10 +1,10 @@
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Form, Input, InputNumber, Modal, Select, Switch } from "antd";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import InputCurrency from "../form-items-formatted/currency-form-item.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import JoblinesPreset from "../job-lines-preset-button/job-lines-preset-button.component";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -61,7 +61,7 @@ export function JobLinesUpsertModalComponent({ bodyshop, open, jobLine, handleCa
]}
name="line_desc"
>
<Input />
<Input.TextArea autoSize />
</Form.Item>
<JoblinesPreset form={form} />
</LayoutFormRow>

View File

@@ -141,14 +141,16 @@ export function JobTotalsTableTotals({ bodyshop, job }) {
key: t("jobs.fields.ded_amt"),
total: job.job_totals.totals.custPayable.deductible
},
...(InstanceRenderManager({
imex: [{
key: t("jobs.fields.federal_tax_payable"),
total: job.job_totals.totals.custPayable.federal_tax
}],
...InstanceRenderManager({
imex: [
{
key: t("jobs.fields.federal_tax_payable"),
total: job.job_totals.totals.custPayable.federal_tax
}
],
rome: [],
promanager: "USE_ROME"
})),
}),
{
key: t("jobs.fields.other_amount_payable"),
total: job.job_totals.totals.custPayable.other_customer_amount
@@ -158,11 +160,32 @@ export function JobTotalsTableTotals({ bodyshop, job }) {
total: job.job_totals.totals.custPayable.dep_taxes
},
{
key: t("jobs.labels.total_cust_payable"),
total: job.job_totals.totals.custPayable.total,
bold: true
},
...(bodyshop.intellipay_config?.enable_cash_discount
? [
{
key: t("jobs.labels.total_cust_payable_cash_discount"),
total: job.job_totals.totals.custPayable.total,
bold: true
},
{
key: t("jobs.labels.total_cust_payable"),
total: Dinero(job.job_totals.totals.custPayable.total)
.add(
Dinero(job.job_totals.totals.custPayable.total).percentage(
bodyshop.intellipay_config?.cash_discount_percentage || 0
)
)
.toJSON(),
bold: true
}
]
: [
{
key: t("jobs.labels.total_cust_payable"),
total: job.job_totals.totals.custPayable.total,
bold: true
}
]),
{
key: t("jobs.labels.net_repairs"),
total: job.job_totals.totals.net_repairs,

View File

@@ -5,6 +5,7 @@ import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import FormItemEmail from "../form-items-formatted/email-form-item.component";
import FormItemPhone, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
@@ -12,7 +13,6 @@ import Car from "../job-damage-visual/job-damage-visual.component";
import JobsDetailChangeEstimator from "../jobs-detail-change-estimator/jobs-detail-change-estimator.component";
import JobsDetailChangeFileHandler from "../jobs-detail-change-filehandler/jobs-detail-change-filehandler.component";
import FormRow from "../layout-form-row/layout-form-row.component";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly,
@@ -185,6 +185,9 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
<Form.Item label={t("jobs.fields.towin")} name="towin" valuePropName="checked">
<Switch disabled={jobRO} />
</Form.Item>
<Form.Item label={t("jobs.fields.tlos_ind")} name="tlos_ind" valuePropName="checked">
<Switch disabled={jobRO} />
</Form.Item>
</FormRow>
</Col>
<Col {...lossColDamage}>

View File

@@ -1,6 +1,5 @@
import { Button, Col, Form, Input, Row, Select, Space, Switch, Typography } from "antd";
import axios from "axios";
import dayjs from "../../utils/day";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
@@ -8,13 +7,14 @@ import { createStructuredSelector } from "reselect";
import { calculateScheduleLoad } from "../../redux/application/application.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { DateFormatter } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component";
import EmailInput from "../form-items-formatted/email-form-item.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import ScheduleDayViewContainer from "../schedule-day-view/schedule-day-view.container";
import ScheduleExistingAppointmentsList from "../schedule-existing-appointments-list/schedule-existing-appointments-list.component";
import "./schedule-job-modal.scss";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -84,7 +84,7 @@ export function ScheduleJobModalComponent({
}
]}
>
<DateTimePicker onBlur={handleDateBlur} onlyFuture />
<DateTimePicker onBlur={handleDateBlur} onlyFuture isSeparatedTime />
</Form.Item>
<Form.Item
name="scheduled_completion"

View File

@@ -4,12 +4,12 @@ import ScoreboardChart from "../scoreboard-chart/scoreboard-chart.component";
import ScoreboardLastDays from "../scoreboard-last-days/scoreboard-last-days.component";
import ScoreboardTargetsTable from "../scoreboard-targets-table/scoreboard-targets-table.component";
import { useApolloClient, useQuery } from "@apollo/client";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { GET_BLOCKED_DAYS, QUERY_SCOREBOARD } from "../../graphql/scoreboard.queries";
import { selectBodyshop } from "../../redux/user/user.selectors";
import dayjs from "../../utils/day";
import { useApolloClient, useQuery } from "@apollo/client";
import { GET_BLOCKED_DAYS, QUERY_SCOREBOARD } from "../../graphql/scoreboard.queries";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
@@ -26,7 +26,7 @@ export function ScoreboardDisplayComponent({ bodyshop }) {
start: dayjs().startOf("month"),
end: dayjs().endOf("month")
},
pollInterval: 60000
pollInterval: 60000*5
});
const { data } = scoreboardSubscription;

View File

@@ -1,13 +1,13 @@
import { useQuery } from "@apollo/client";
import { Col, Row } from "antd";
import _ from "lodash";
import dayjs from "../../utils/day";
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { QUERY_TIME_TICKETS_IN_RANGE_SB } from "../../graphql/timetickets.queries";
import { selectBodyshop } from "../../redux/user/user.selectors";
import dayjs from "../../utils/day";
import AlertComponent from "../alert/alert.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import * as Utils from "../scoreboard-targets-table/scoreboard-targets-table.util";
@@ -86,7 +86,7 @@ export function ScoreboardTimeTicketsStats({ bodyshop }) {
},
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
pollInterval: 60000,
pollInterval: 60000*5,
skip: !fixedPeriods
});

View File

@@ -1,11 +1,11 @@
import { useQuery } from "@apollo/client";
import { Col, Row } from "antd";
import _ from "lodash";
import dayjs from "../../utils/day";
import queryString from "query-string";
import React, { useMemo } from "react";
import { useLocation } from "react-router-dom";
import { QUERY_TIME_TICKETS_IN_RANGE_SB } from "../../graphql/timetickets.queries";
import dayjs from "../../utils/day";
import AlertComponent from "../alert/alert.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import * as Utils from "../scoreboard-targets-table/scoreboard-targets-table.util";
@@ -68,7 +68,7 @@ export default function ScoreboardTimeTickets() {
},
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
pollInterval: 60000,
pollInterval: 60000*5,
skip: !fixedPeriods
});

View File

@@ -142,7 +142,7 @@ export function ShopInfoComponent({ bodyshop, form, saveLoading }) {
rome: [
{
key: "intellipay",
label: t("bodyshop.labels.intellipay"),
label: InstanceRenderManager({ rome: t("bodyshop.labels.romepay"), imex: t("bodyshop.labels.imexpay") }),
children: <ShopInfoIntellipay form={form} />
}
],

View File

@@ -676,7 +676,7 @@ export function ShopInfoGeneral({ form, bodyshop }) {
}
]}
>
<Input.TextArea rows={3} />
<Input.TextArea autoSize />
</Form.Item>
<Space wrap>
<DeleteFilled
@@ -737,7 +737,7 @@ export function ShopInfoGeneral({ form, bodyshop }) {
}
]}
>
<Input.TextArea rows={3} />
<Input.TextArea autoSize />
</Form.Item>
<Space wrap>
<DeleteFilled
@@ -1187,7 +1187,7 @@ export function ShopInfoGeneral({ form, bodyshop }) {
key={`${index}line_desc`}
name={[field.name, "line_desc"]}
>
<Input />
<Input.TextArea autoSize />
</Form.Item>
<Form.Item
label={t("joblines.fields.mod_lbr_ty")}
@@ -1330,7 +1330,7 @@ export function ShopInfoGeneral({ form, bodyshop }) {
}
]}
>
<Input />
<Input.TextArea autoSize />
</Form.Item>
<Space wrap>

View File

@@ -39,14 +39,13 @@ export function ShopInfoIntellipay({ bodyshop, form }) {
</Form.Item>
<Form.Item
label={t("bodyshop.fields.intellipay_config.cash_discount_percentage")}
valuePropName="checked"
dependencies={[["intellipay_config", "enable_cash_discount"]]}
name={["intellipay_config", "cash_discount_percentage"]}
rules={[
({ getFieldsValue }) => ({ required: form.getFieldValue(["intellipay_config", "enable_cash_discount"]) })
]}
>
<InputNumber min={0} max={100} precision={1} suffix='%'/>
<InputNumber min={0} max={100} precision={1} suffix="%" />
</Form.Item>
</LayoutFormRow>
</>

View File

@@ -39,7 +39,7 @@ export function TimeTicketList({
extra
}) {
const [state, setState] = useState({
sortedInfo: {},
sortedInfo: { columnKey: 'date', order: 'descend' },
filteredInfo: { text: "" }
});

View File

@@ -1,7 +1,7 @@
import { useMutation, useQuery } from "@apollo/client";
import { Button, Form, Modal, notification, Space } from "antd";
import { PageHeader } from "@ant-design/pro-layout";
import dayjs from "../../utils/day";
import { useMutation, useQuery } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Form, Modal, notification, Space } from "antd";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
@@ -11,9 +11,9 @@ import { INSERT_NEW_TIME_TICKET, UPDATE_TIME_TICKET } from "../../graphql/timeti
import { toggleModalVisible } from "../../redux/modals/modals.actions";
import { selectTimeTicket } from "../../redux/modals/modals.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import TimeTicketModalComponent from "./time-ticket-modal.component";
import dayjs from "../../utils/day";
import TimeTicketsCommitToggleComponent from "../time-tickets-commit-toggle/time-tickets-commit-toggle.component";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import TimeTicketModalComponent from "./time-ticket-modal.component";
const mapStateToProps = createStructuredSelector({
timeTicketModal: selectTimeTicket,
@@ -87,7 +87,7 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
if (enterAgain) {
//Capture the existing information and repopulate it.
const prev = form.getFieldsValue(["date", "employeeid"]);
const prev = form.getFieldsValue(["date", "employeeid", "flat_rate"]);
form.resetFields();

View File

@@ -1,11 +1,14 @@
import { useEffect, useState, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import SocketIO from "socket.io-client";
import { auth } from "../../firebase/firebase.utils";
import { store } from "../../redux/store";
import { setWssStatus } from "../../redux/application/application.actions";
import { addAlerts, setWssStatus } from "../../redux/application/application.actions";
import { useDispatch } from "react-redux";
const useSocket = (bodyshop) => {
const socketRef = useRef(null);
const [clientId, setClientId] = useState(null);
const dispatch = useDispatch();
useEffect(() => {
const unsubscribe = auth.onIdTokenChanged(async (user) => {
@@ -31,37 +34,58 @@ const useSocket = (bodyshop) => {
socketRef.current = socketInstance;
const handleBodyshopMessage = (message) => {
if (!message || !message?.type) return;
switch (message.type) {
case "alert-update":
store.dispatch(addAlerts(message.payload));
break;
}
if (!import.meta.env.DEV) return;
console.log(`Received message for bodyshop ${bodyshop.id}:`, message);
};
const handleConnect = () => {
console.log("Socket connected:", socketInstance.id);
socketInstance.emit("join-bodyshop-room", bodyshop.id);
setClientId(socketInstance.id);
store.dispatch(setWssStatus("connected"))
store.dispatch(setWssStatus("connected"));
};
const handleReconnect = (attempt) => {
console.log(`Socket reconnected after ${attempt} attempts`);
store.dispatch(setWssStatus("connected"))
store.dispatch(setWssStatus("connected"));
};
const handleConnectionError = (err) => {
console.error("Socket connection error:", err);
store.dispatch(setWssStatus("error"))
store.dispatch(setWssStatus("error"));
};
const handleDisconnect = () => {
console.log("Socket disconnected");
store.dispatch(setWssStatus("disconnected"))
store.dispatch(setWssStatus("disconnected"));
};
const handleMessagingList = (data) => {
dispatch({ type: "SET_CONVERSATIONS", payload: data.conversations });
};
const handleNewMessage = (message) => {
dispatch({ type: "ADD_MESSAGE", payload: message });
};
const handleNewConversation = (data) => {
dispatch({ type: "ADD_CONVERSATION", payload: data.conversation });
dispatch({ type: "ADD_MESSAGE", payload: data.message });
};
socketInstance.on("messaging-list", handleMessagingList);
socketInstance.on("new-message", handleNewMessage);
socketInstance.on("connect", handleConnect);
socketInstance.on("reconnect", handleReconnect);
socketInstance.on("connect_error", handleConnectionError);
socketInstance.on("disconnect", handleDisconnect);
socketInstance.on("bodyshop-message", handleBodyshopMessage);
socketInstance.on("new-conversation", handleNewConversation);
}
} else {
// User is not authenticated
@@ -80,7 +104,7 @@ const useSocket = (bodyshop) => {
socketRef.current = null;
}
};
}, [bodyshop]);
}, [bodyshop, dispatch]);
return { socket: socketRef.current, clientId };
};

View File

@@ -81,14 +81,14 @@ export const logImEXEvent = (eventName, additionalParams, stateProp = null) => {
user: (state.user && state.user.currentUser && state.user.currentUser.email) || null,
...additionalParams
};
axios.post("/ioevent", {
useremail: (state.user && state.user.currentUser && state.user.currentUser.email) || null,
bodyshopid: (state.user && state.user.bodyshop && state.user.bodyshop.id) || null,
operationName: eventName,
variables: additionalParams,
dbevent: false,
env: `master-AIO|${import.meta.env.VITE_APP_GIT_SHA_DATE}`
});
// axios.post("/ioevent", {
// useremail: (state.user && state.user.currentUser && state.user.currentUser.email) || null,
// bodyshopid: (state.user && state.user.bodyshop && state.user.bodyshop.id) || null,
// operationName: eventName,
// variables: additionalParams,
// dbevent: false,
// env: `master-AIO|${import.meta.env.VITE_APP_GIT_SHA_DATE}`
// });
// console.log(
// "%c[Analytics]",
// "background-color: green ;font-weight:bold;",

View File

@@ -114,3 +114,22 @@ export const UPDATE_CONVERSATION_LABEL = gql`
}
}
`;
export const GET_CONVERSATION_MESSAGES = gql`
query GET_CONVERSATION_MESSAGES($conversationId: uuid!) {
conversation: conversations_by_pk(id: $conversationId) {
id
phone_num
updated_at
label
}
messages: messages(where: { conversationid: { _eq: $conversationId } }, order_by: { created_at: asc }) {
id
text
created_at
read
isoutbound
userid
}
}
`;

View File

@@ -692,6 +692,7 @@ export const GET_JOB_BY_PK = gql`
tax_str_rt
tax_sub_rt
tax_tow_rt
tlos_ind
towin
towing_payable
unit_number

View File

@@ -2,7 +2,7 @@ import { gql } from "@apollo/client";
export const QUERY_TICKETS_BY_JOBID = gql`
query QUERY_TICKETS_BY_JOBID($jobid: uuid!) {
timetickets(where: { jobid: { _eq: $jobid } }, order_by: { date: desc_nulls_first }) {
timetickets(where: { jobid: { _eq: $jobid } }) {
actualhrs
cost_center
ciecacode
@@ -26,7 +26,7 @@ export const QUERY_TICKETS_BY_JOBID = gql`
export const QUERY_TIME_TICKETS_IN_RANGE = gql`
query QUERY_TIME_TICKETS_IN_RANGE($start: date!, $end: date!) {
timetickets(where: { date: { _gte: $start, _lte: $end } }, order_by: { date: desc_nulls_first }) {
timetickets(where: { date: { _gte: $start, _lte: $end } }) {
actualhrs
ciecacode
clockoff
@@ -69,7 +69,6 @@ export const QUERY_TIME_TICKETS_TECHNICIAN_IN_RANGE = gql`
) {
timetickets(
where: { date: { _gte: $start, _lte: $end }, employeeid: { _eq: $employeeid } }
order_by: { date: desc_nulls_first }
) {
actualhrs
ciecacode
@@ -101,7 +100,6 @@ export const QUERY_TIME_TICKETS_TECHNICIAN_IN_RANGE = gql`
}
fixedperiod: timetickets(
where: { date: { _gte: $fixedStart, _lte: $fixedEnd }, employeeid: { _eq: $employeeid } }
order_by: { date: desc_nulls_first }
) {
actualhrs
ciecacode
@@ -145,7 +143,6 @@ export const QUERY_TIME_TICKETS_IN_RANGE_SB = gql`
) {
timetickets(
where: { date: { _gte: $start, _lte: $end }, cost_center: { _neq: "timetickets.labels.shift" } }
order_by: { date: desc_nulls_first }
) {
actualhrs
ciecacode
@@ -180,7 +177,6 @@ export const QUERY_TIME_TICKETS_IN_RANGE_SB = gql`
}
fixedperiod: timetickets(
where: { date: { _gte: $fixedStart, _lte: $fixedEnd }, cost_center: { _neq: "timetickets.labels.shift" } }
order_by: { date: desc_nulls_first }
) {
actualhrs
ciecacode
@@ -335,7 +331,6 @@ export const UPDATE_TIME_TICKETS = gql`
export const QUERY_ACTIVE_TIME_TICKETS = gql`
query QUERY_ACTIVE_TIME_TICKETS($employeeId: uuid) {
timetickets(
order_by: { date: desc_nulls_first }
where: {
_and: {
clockoff: { _is_null: true }

View File

@@ -71,7 +71,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
...logs,
{
timestamp: new Date(),
level: "WARNING",
level: "WARN",
message: "Reconnected to CDK Export Service"
}
];
@@ -125,7 +125,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
>
<Select.Option key="DEBUG">DEBUG</Select.Option>
<Select.Option key="INFO">INFO</Select.Option>
<Select.Option key="WARNING">WARNING</Select.Option>
<Select.Option key="WARN">WARN</Select.Option>
<Select.Option key="ERROR">ERROR</Select.Option>
</Select>
<Button onClick={() => setLogs([])}>Clear Logs</Button>

View File

@@ -90,7 +90,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
...logs,
{
timestamp: new Date(),
level: "WARNING",
level: "warn",
message: "Reconnected to CDK Export Service"
}
];
@@ -175,7 +175,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
>
<Select.Option key="DEBUG">DEBUG</Select.Option>
<Select.Option key="INFO">INFO</Select.Option>
<Select.Option key="WARNING">WARNING</Select.Option>
<Select.Option key="WARN">WARN</Select.Option>
<Select.Option key="ERROR">ERROR</Select.Option>
</Select>
<Button onClick={() => setLogs([])}>Clear Logs</Button>

View File

@@ -1,4 +1,4 @@
import { FloatButton, Layout, Spin } from "antd";
import { FloatButton, Layout, notification, Spin } from "antd";
// import preval from "preval.macro";
import React, { lazy, Suspense, useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -21,11 +21,12 @@ import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-st
import { requestForToken } from "../../firebase/firebase.utils";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { selectBodyshop, selectInstanceConflict } from "../../redux/user/user.selectors";
import UpdateAlert from "../../components/update-alert/update-alert.component";
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
import "./manage.page.styles.scss";
import WssStatusDisplayComponent from "../../components/wss-status-display/wss-status-display.component.jsx";
import { selectAlerts } from "../../redux/application/application.selectors.js";
import { addAlerts } from "../../redux/application/application.actions.js";
const JobsPage = lazy(() => import("../jobs/jobs.page"));
@@ -104,16 +105,80 @@ const { Content, Footer } = Layout;
const mapStateToProps = createStructuredSelector({
conflict: selectInstanceConflict,
bodyshop: selectBodyshop
bodyshop: selectBodyshop,
alerts: selectAlerts
});
const mapDispatchToProps = (dispatch) => ({});
const ALERT_FILE_URL = InstanceRenderManager({
imex: "https://images.imex.online/alerts/alerts-imex.json",
rome: "https://images.imex.online/alerts/alerts-rome.json"
});
export function Manage({ conflict, bodyshop }) {
const mapDispatchToProps = (dispatch) => ({
setAlerts: (alerts) => dispatch(addAlerts(alerts))
});
export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
const { t } = useTranslation();
const [chatVisible] = useState(false);
const { socket, clientId } = useContext(SocketContext);
// State to track displayed alerts
const [displayedAlertIds, setDisplayedAlertIds] = useState([]);
// Fetch displayed alerts from localStorage on mount
useEffect(() => {
const displayedAlerts = JSON.parse(localStorage.getItem("displayedAlerts") || "[]");
setDisplayedAlertIds(displayedAlerts);
}, []);
// Fetch alerts from the JSON file and dispatch to Redux store
useEffect(() => {
const fetchAlerts = async () => {
try {
const response = await fetch(ALERT_FILE_URL);
const fetchedAlerts = await response.json();
setAlerts(fetchedAlerts);
} catch (error) {
console.error("Error fetching alerts:", error);
}
};
fetchAlerts();
}, [setAlerts]);
// Use useEffect to watch for new alerts
useEffect(() => {
if (alerts && Object.keys(alerts).length > 0) {
// Convert the alerts object into an array
const alertArray = Object.values(alerts);
// Filter out alerts that have already been dismissed
const newAlerts = alertArray.filter((alert) => !displayedAlertIds.includes(alert.id));
newAlerts.forEach((alert) => {
// Display the notification
notification.open({
key: "notification-alerts-" + alert.id,
message: alert.message,
description: alert.description,
type: alert.type || "info",
duration: 0,
placement: "bottomRight",
closable: true,
onClose: () => {
// When the notification is closed, update displayed alerts state and localStorage
setDisplayedAlertIds((prevIds) => {
const updatedIds = [...prevIds, alert.id];
localStorage.setItem("displayedAlerts", JSON.stringify(updatedIds));
return updatedIds;
});
}
});
});
}
}, [alerts, displayedAlertIds]);
useEffect(() => {
const widgetId = InstanceRenderManager({
imex: "IABVNO4scRKY11XBQkNr",
@@ -582,7 +647,7 @@ export function Manage({ conflict, bodyshop }) {
return (
<>
{import.meta.env.PROD && <ChatAffixContainer bodyshop={bodyshop} chatVisible={chatVisible} />}
{true && <ChatAffixContainer bodyshop={bodyshop} chatVisible={chatVisible} />}
<Layout style={{ minHeight: "100vh" }} className="layout-container">
<UpdateAlert />
<HeaderContainer />

View File

@@ -67,6 +67,12 @@ export const setUpdateAvailable = (isUpdateAvailable) => ({
type: ApplicationActionTypes.SET_UPDATE_AVAILABLE,
payload: isUpdateAvailable
});
export const addAlerts = (alerts) => ({
type: ApplicationActionTypes.ADD_ALERTS,
payload: alerts
});
export const setWssStatus = (status) => ({
type: ApplicationActionTypes.SET_WSS_STATUS,
payload: status

View File

@@ -15,7 +15,8 @@ const INITIAL_STATE = {
error: null
},
jobReadOnly: false,
partnerVersion: null
partnerVersion: null,
alerts: {}
};
const applicationReducer = (state = INITIAL_STATE, action) => {
@@ -91,6 +92,18 @@ const applicationReducer = (state = INITIAL_STATE, action) => {
case ApplicationActionTypes.SET_WSS_STATUS: {
return { ...state, wssStatus: action.payload };
}
case ApplicationActionTypes.ADD_ALERTS: {
const newAlertsMap = { ...state.alerts };
action.payload.alerts.forEach((alert) => {
newAlertsMap[alert.id] = alert;
});
return {
...state,
alerts: newAlertsMap
};
}
default:
return state;
}

View File

@@ -23,3 +23,4 @@ export const selectOnline = createSelector([selectApplication], (application) =>
export const selectProblemJobs = createSelector([selectApplication], (application) => application.problemJobs);
export const selectUpdateAvailable = createSelector([selectApplication], (application) => application.updateAvailable);
export const selectWssStatus = createSelector([selectApplication], (application) => application.wssStatus);
export const selectAlerts = createSelector([selectApplication], (application) => application.alerts);

View File

@@ -13,6 +13,7 @@ const ApplicationActionTypes = {
INSERT_AUDIT_TRAIL: "INSERT_AUDIT_TRAIL",
SET_PROBLEM_JOBS: "SET_PROBLEM_JOBS",
SET_UPDATE_AVAILABLE: "SET_UPDATE_AVAILABLE",
SET_WSS_STATUS: "SET_WSS_STATUS"
SET_WSS_STATUS: "SET_WSS_STATUS",
ADD_ALERTS: "ADD_ALERTS"
};
export default ApplicationActionTypes;

View File

@@ -2,9 +2,9 @@ import MessagingActionTypes from "./messaging.types";
export const toggleChatVisible = () => ({
type: MessagingActionTypes.TOGGLE_CHAT_VISIBLE
//payload: user
});
// Action to handle when a message is sent via WebSocket
export const sendMessage = (message) => ({
type: MessagingActionTypes.SEND_MESSAGE,
payload: message
@@ -19,17 +19,45 @@ export const sendMessageFailure = (error) => ({
type: MessagingActionTypes.SEND_MESSAGE_FAILURE,
payload: error
});
// Set the selected conversation by ID
export const setSelectedConversation = (conversationId) => ({
type: MessagingActionTypes.SET_SELECTED_CONVERSATION,
payload: conversationId
});
// Open chat by phone number (if theres a need to search for a conversation)
export const openChatByPhone = (phoneNumber) => ({
type: MessagingActionTypes.OPEN_CHAT_BY_PHONE,
payload: phoneNumber
});
// Set an individual message (e.g., from a new message event)
export const setMessage = (message) => ({
type: MessagingActionTypes.SET_MESSAGE,
payload: message
});
// Set the list of conversations received from WebSocket
export const setConversations = (conversations) => ({
type: MessagingActionTypes.SET_CONVERSATIONS,
payload: conversations
});
// Add a message to the conversation messages
export const addMessage = (message) => ({
type: MessagingActionTypes.ADD_MESSAGE,
payload: message
});
// Add a Conversation to the list of conversations
export const addConversation = (conversation) => ({
type: MessagingActionTypes.ADD_CONVERSATION,
payload: conversation
});
// Update unread count for a conversation (e.g., after marking messages as read)
export const updateUnreadCount = (conversationId, unreadCount) => ({
type: MessagingActionTypes.UPDATE_UNREAD_COUNT,
payload: { conversationId, unreadCount }
});

View File

@@ -6,6 +6,11 @@ const INITIAL_STATE = {
isSending: false,
error: null,
message: null,
conversations: {
conversations: []
},
messages: [],
unreadcnt: 0,
searchingForConversation: false
};
@@ -13,39 +18,74 @@ const messagingReducer = (state = INITIAL_STATE, action) => {
switch (action.type) {
case MessagingActionTypes.SET_MESSAGE:
return { ...state, message: action.payload };
case MessagingActionTypes.TOGGLE_CHAT_VISIBLE:
return {
...state,
open: !state.open
};
case MessagingActionTypes.OPEN_CHAT_BY_PHONE:
return {
...state,
searchingForConversation: true
};
case MessagingActionTypes.SET_SELECTED_CONVERSATION:
return {
...state,
open: true,
searchingForConversation: false,
selectedConversationId: action.payload
selectedConversationId: action.payload,
messages: [] // Reset messages when a new conversation is selected
};
case MessagingActionTypes.SEND_MESSAGE:
return {
...state,
error: null,
isSending: true
};
case MessagingActionTypes.SEND_MESSAGE_SUCCESS:
return {
...state,
message: "",
isSending: false
};
case MessagingActionTypes.SEND_MESSAGE_FAILURE:
return {
...state,
error: action.payload
error: action.payload,
isSending: false
};
case MessagingActionTypes.SET_CONVERSATIONS:
return {
...state,
conversations: action.payload
};
case MessagingActionTypes.ADD_MESSAGE:
return {
...state,
messages: [...state.messages, action.payload]
};
case MessagingActionTypes.ADD_CONVERSATION:
return {
...state,
conversations: {
...state.conversations,
conversations: [
...(state.conversations.conversations || []),
{
...action.payload, // Ensure all fields from payload are included
messages_aggregate: action.payload.messages_aggregate || { aggregate: { count: 0 } }
}
]
}
};
default:
return state;

View File

@@ -19,3 +19,8 @@ export const searchingForConversation = createSelector(
[selectMessaging],
(messaging) => messaging.searchingForConversation
);
// New selectors for conversations and unread count
export const selectConversations = createSelector([selectMessaging], (messaging) => messaging.conversations);
export const selectUnreadCount = createSelector([selectMessaging], (messaging) => messaging.unreadCount);

View File

@@ -5,6 +5,10 @@ const MessagingActionTypes = {
SEND_MESSAGE_FAILURE: "SEND_MESSAGE_FAILURE",
SET_SELECTED_CONVERSATION: "SET_SELECTED_CONVERSATION",
OPEN_CHAT_BY_PHONE: "OPEN_CHAT_BY_PHONE",
SET_MESSAGE: "SET_MESSAGE"
SET_MESSAGE: "SET_MESSAGE",
ADD_MESSAGE: "ADD_MESSAGE",
UPDATE_UNREAD_COUNT: "UPDATE_UNREAD_COUNT",
SET_CONVERSATIONS: "SET_CONVERSATIONS",
ADD_CONVERSATION: "ADD_CONVERSATION"
};
export default MessagingActionTypes;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,5 @@
import react from "@vitejs/plugin-react";
import { promises as fsPromises } from "fs";
import { createRequire } from "module";
import * as path from "path";
import * as url from "url";
import { createLogger, defineConfig } from "vite";
import { ViteEjsPlugin } from "vite-plugin-ejs";
import eslint from "vite-plugin-eslint";
@@ -18,28 +15,6 @@ process.env.VITE_APP_GIT_SHA_DATE = new Date().toLocaleString("en-US", {
const getFormattedTimestamp = () =>
new Date().toLocaleTimeString("en-US", { hour12: true }).replace("AM", "a.m.").replace("PM", "p.m.");
/** This is a hack around react-virtualized, should be removed when switching to react-virtuoso */
const WRONG_CODE = `import { bpfrpt_proptype_WindowScroller } from "../WindowScroller.js";`;
function reactVirtualizedFix() {
return {
name: "flat:react-virtualized",
configResolved: async () => {
const require = createRequire(import.meta.url);
const reactVirtualizedPath = require.resolve("react-virtualized");
const { pathname: reactVirtualizedFilePath } = new url.URL(reactVirtualizedPath, import.meta.url);
const file = reactVirtualizedFilePath.replace(
path.join("dist", "commonjs", "index.js"),
path.join("dist", "es", "WindowScroller", "utils", "onScroll.js")
);
const code = await fsPromises.readFile(file, "utf-8");
const modified = code.replace(WRONG_CODE, "");
await fsPromises.writeFile(file, modified);
}
};
}
/** End of hack */
export const logger = createLogger("info", {
allowClearScreen: false
});
@@ -108,7 +83,6 @@ export default defineConfig({
gcm_sender_id: "103953800507"
}
}),
reactVirtualizedFix(),
react(),
eslint()
],

View File

@@ -74,7 +74,7 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- SERVICES=ses,secretsmanager,cloudwatch,logs
- SERVICES=s3,ses,secretsmanager,cloudwatch,logs
- DEBUG=0
- AWS_ACCESS_KEY_ID=test
- AWS_SECRET_ACCESS_KEY=test
@@ -115,7 +115,8 @@ services:
aws --endpoint-url=http://localstack:4566 ses verify-domain-identity --domain imex.online --region ca-central-1
aws --endpoint-url=http://localstack:4566 ses verify-email-identity --email-address noreply@imex.online --region ca-central-1
aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/id_rsa
aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1
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
"
# Node App: The Main IMEX API
node-app:
@@ -167,6 +168,28 @@ services:
# volumes:
# - redis-insight-data:/db
# ##Optional Container for SFTP/SSH Server for testing
# ssh-sftp-server:
# image: atmoz/sftp:alpine # Using an image with SFTP support
# container_name: ssh-sftp-server
# hostname: ssh-sftp-server
# networks:
# - redis-cluster-net
# ports:
# - "2222:22" # Expose port 22 for SSH/SFTP (mapped to 2222 on the host)
# volumes:
# - ./certs/id_rsa.pub:/home/user/.ssh/keys/id_rsa.pub:ro # Mount the SSH public key
# - ./upload:/home/user/upload # Mount a local directory for SFTP uploads
# environment:
# - SFTP_USERS=user:password:1000::upload
# command: >
# /bin/sh -c "
# chmod -R 007 /home/user/upload &&
# echo 'Match User user' >> /etc/ssh/sshd_config &&
# sed -i -e 's#ForceCommand internal-sftp#ForceCommand internal-sftp -d /upload#' /etc/ssh/sshd_config &&
# /usr/sbin/sshd -D
# "
networks:
redis-cluster-net:
driver: bridge

View File

@@ -69,7 +69,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
jobline:
job:
@@ -180,7 +179,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
bodyshop:
associations:
@@ -387,7 +385,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
bodyshop:
associations:
@@ -504,7 +501,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
bill:
job:
@@ -671,7 +667,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
_and:
- job:
@@ -1285,7 +1280,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
courtesycar:
bodyshop:
@@ -1526,7 +1520,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
bodyshop:
associations:
@@ -1786,7 +1779,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
bodyshop:
associations:
@@ -1920,7 +1912,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
_or:
- job:
@@ -2105,7 +2096,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
employee_team:
bodyshop:
@@ -2268,7 +2258,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
employee:
bodyshop:
@@ -2449,7 +2438,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
bodyshop:
associations:
@@ -2696,7 +2684,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
bodyshop:
associations:
@@ -2808,7 +2795,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
conversation:
bodyshop:
@@ -3123,7 +3109,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
job:
bodyshop:
@@ -4232,7 +4217,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
bodyshop:
associations:
@@ -4248,41 +4232,41 @@
enable_manual: false
update:
columns:
- clm_no
- v_make_desc
- date_next_contact
- status
- employee_csr
- employee_prep
- clm_total
- suspended
- employee_body
- ro_number
- actual_in
- ownr_co_nm
- v_model_yr
- comment
- job_totals
- v_vin
- ownr_fn
- scheduled_completion
- special_coverage_policy
- v_color
- ca_gst_registrant
- scheduled_delivery
- actual_delivery
- actual_completion
- kanbanparent
- est_ct_fn
- alt_transport
- v_model_desc
- clm_no
- v_make_desc
- date_next_contact
- status
- employee_csr
- actual_in
- v_model_yr
- comment
- job_totals
- ownr_fn
- v_color
- ca_gst_registrant
- employee_refinish
- ownr_ph1
- date_last_contacted
- alt_transport
- inproduction
- est_ct_ln
- production_vars
- category
- v_model_desc
- date_invoiced
- est_co_nm
- ownr_ln
@@ -4295,6 +4279,12 @@
- name: event-secret
value_from_env: EVENT_SECRET
request_transform:
body:
action: transform
template: |-
{
"data": {{$body?.event?.data?.new}}
}
method: POST
query_params: {}
template_engine: Kriti
@@ -4496,7 +4486,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
conversation:
bodyshop:
@@ -4670,7 +4659,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
job:
bodyshop:
@@ -4805,7 +4793,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
bodyshop:
associations:
@@ -5110,7 +5097,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
parts_order:
job:
@@ -5243,7 +5229,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
job:
bodyshop:
@@ -5419,7 +5404,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
job:
bodyshop:
@@ -5559,7 +5543,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
bodyshop:
associations:
@@ -5670,7 +5653,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
_or:
- parentjob_rel:
@@ -5760,7 +5742,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
job:
bodyshop:
@@ -6045,7 +6026,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
bodyshop:
associations:
@@ -6541,7 +6521,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
bodyshop:
associations:
@@ -6698,7 +6677,6 @@
delete_permissions:
- role: user
permission:
backend_only: false
filter:
bodyshop:
associations:

View File

@@ -0,0 +1,3 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- CREATE INDEX idx_timetickets_date ON timetickets (date );

View File

@@ -0,0 +1 @@
CREATE INDEX idx_timetickets_date ON timetickets (date );

View File

@@ -0,0 +1,9 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- CREATE INDEX idx_jobs_ownr_fn ON jobs USING gin (ownr_fn gin_trgm_ops);
-- CREATE INDEX idx_jobs_ownr_ln ON jobs USING gin (ownr_ln gin_trgm_ops);
-- CREATE INDEX idx_jobs_ownr_co_nm ON jobs USING gin (ownr_co_nm gin_trgm_ops);
-- CREATE INDEX idx_jobs_clm_no ON jobs USING gin (clm_no gin_trgm_ops);
-- CREATE INDEX idx_jobs_v_make_desc ON jobs USING gin (v_make_desc gin_trgm_ops);
-- CREATE INDEX idx_jobs_v_model_desc ON jobs USING gin (v_model_desc gin_trgm_ops);
-- CREATE INDEX idx_jobs_plate_no ON jobs USING gin (plate_no gin_trgm_ops);

View File

@@ -0,0 +1,7 @@
CREATE INDEX idx_jobs_ownr_fn ON jobs USING gin (ownr_fn gin_trgm_ops);
CREATE INDEX idx_jobs_ownr_ln ON jobs USING gin (ownr_ln gin_trgm_ops);
CREATE INDEX idx_jobs_ownr_co_nm ON jobs USING gin (ownr_co_nm gin_trgm_ops);
CREATE INDEX idx_jobs_clm_no ON jobs USING gin (clm_no gin_trgm_ops);
CREATE INDEX idx_jobs_v_make_desc ON jobs USING gin (v_make_desc gin_trgm_ops);
CREATE INDEX idx_jobs_v_model_desc ON jobs USING gin (v_model_desc gin_trgm_ops);
CREATE INDEX idx_jobs_plate_no ON jobs USING gin (plate_no gin_trgm_ops);

View File

@@ -0,0 +1,3 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- CREATE INDEX idx_exportlog_createdat_desc ON exportlog (created_at desc);

View File

@@ -0,0 +1 @@
CREATE INDEX idx_exportlog_createdat_desc ON exportlog (created_at desc);

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- CREATE index idx_messages_unread_agg ON messages (read, isoutbound)
-- WHERE read = false AND isoutbound = false;

View File

@@ -0,0 +1,2 @@
CREATE index idx_messages_unread_agg ON messages (read, isoutbound)
WHERE read = false AND isoutbound = false;

View File

@@ -0,0 +1,10 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- CREATE INDEX jobs_search_gin_ro_number ON jobs USING GIN ((ro_number) gin_trgm_ops);
-- CREATE INDEX jobs_search_gin_ownrfn ON jobs USING GIN ((ownr_fn) gin_trgm_ops);
-- CREATE INDEX jobs_search_gin_clm_no ON jobs USING GIN ((clm_no) gin_trgm_ops);
-- CREATE INDEX jobs_search_gin_plate_no ON jobs USING GIN ((plate_no) gin_trgm_ops);
-- CREATE INDEX jobs_search_gin_v_make_desc ON jobs USING GIN (( v_make_desc) gin_trgm_ops);
-- CREATE INDEX jobs_search_gin_v_model_desc ON jobs USING GIN (( v_model_desc) gin_trgm_ops);
-- CREATE INDEX jobs_search_gin_ownr_ln ON jobs USING GIN (( ownr_ln) gin_trgm_ops);
-- CREATE INDEX jobs_search_gin_ownr_co_nm ON jobs USING GIN (( ownr_co_nm) gin_trgm_ops);

View File

@@ -0,0 +1,8 @@
CREATE INDEX jobs_search_gin_ro_number ON jobs USING GIN ((ro_number) gin_trgm_ops);
CREATE INDEX jobs_search_gin_ownrfn ON jobs USING GIN ((ownr_fn) gin_trgm_ops);
CREATE INDEX jobs_search_gin_clm_no ON jobs USING GIN ((clm_no) gin_trgm_ops);
CREATE INDEX jobs_search_gin_plate_no ON jobs USING GIN ((plate_no) gin_trgm_ops);
CREATE INDEX jobs_search_gin_v_make_desc ON jobs USING GIN (( v_make_desc) gin_trgm_ops);
CREATE INDEX jobs_search_gin_v_model_desc ON jobs USING GIN (( v_model_desc) gin_trgm_ops);
CREATE INDEX jobs_search_gin_ownr_ln ON jobs USING GIN (( ownr_ln) gin_trgm_ops);
CREATE INDEX jobs_search_gin_ownr_co_nm ON jobs USING GIN (( ownr_co_nm) gin_trgm_ops);

2686
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,12 +19,13 @@
"makeitpretty": "prettier --write \"**/*.{css,js,json,jsx,scss}\""
},
"dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.679.0",
"@aws-sdk/client-elasticache": "^3.675.0",
"@aws-sdk/client-secrets-manager": "^3.675.0",
"@aws-sdk/client-ses": "^3.675.0",
"@aws-sdk/credential-provider-node": "^3.675.0",
"@opensearch-project/opensearch": "^2.12.0",
"@aws-sdk/client-cloudwatch-logs": "^3.693.0",
"@aws-sdk/client-elasticache": "^3.693.0",
"@aws-sdk/client-s3": "^3.693.0",
"@aws-sdk/client-secrets-manager": "^3.693.0",
"@aws-sdk/client-ses": "^3.693.0",
"@aws-sdk/credential-provider-node": "^3.693.0",
"@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0",
"aws4": "^1.13.2",
@@ -33,20 +34,20 @@
"bluebird": "^3.7.2",
"body-parser": "^1.20.3",
"canvas": "^2.11.2",
"chart.js": "^4.4.5",
"chart.js": "^4.4.6",
"cloudinary": "^2.5.1",
"compression": "^1.7.4",
"compression": "^1.7.5",
"cookie-parser": "^1.4.7",
"cors": "2.8.5",
"csrf": "^3.1.0",
"dinero.js": "^1.9.1",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"firebase-admin": "^12.6.0",
"firebase-admin": "^13.0.0",
"graphql": "^16.9.0",
"graphql-request": "^6.1.0",
"inline-css": "^4.0.2",
"intuit-oauth": "^4.1.2",
"intuit-oauth": "^4.1.3",
"ioredis": "^5.4.1",
"json-2-csv": "^5.5.6",
"lodash": "^4.17.21",
@@ -55,18 +56,18 @@
"multer": "^1.4.5-lts.1",
"node-mailjet": "^6.0.6",
"node-persist": "^4.0.3",
"nodemailer": "^6.9.15",
"phone": "^3.1.51",
"nodemailer": "^6.9.16",
"phone": "^3.1.53",
"recursive-diff": "^1.0.9",
"redis": "^4.7.0",
"rimraf": "^6.0.1",
"soap": "^1.1.5",
"socket.io": "^4.8.0",
"soap": "^1.1.6",
"socket.io": "^4.8.1",
"socket.io-adapter": "^2.5.5",
"ssh2-sftp-client": "^10.0.3",
"twilio": "^4.23.0",
"uuid": "^10.0.0",
"winston": "^3.15.0",
"winston": "^3.17.0",
"winston-cloudwatch": "^6.3.0",
"xml2js": "^0.6.2",
"xmlbuilder2": "^3.1.1"

View File

@@ -21,7 +21,7 @@ const { applyRedisHelpers } = require("./server/utils/redisHelpers");
const { applyIOHelpers } = require("./server/utils/ioHelpers");
const { redisSocketEvents } = require("./server/web-sockets/redisSocketEvents");
const { ElastiCacheClient, DescribeCacheClustersCommand } = require("@aws-sdk/client-elasticache");
const { default: InstanceManager } = require("./server/utils/instanceMgr");
const { InstanceRegion } = require("./server/utils/instanceMgr");
const CLUSTER_RETRY_BASE_DELAY = 100;
const CLUSTER_RETRY_MAX_DELAY = 5000;
@@ -114,10 +114,7 @@ const applyRoutes = ({ app }) => {
*/
const getRedisNodesFromAWS = async () => {
const client = new ElastiCacheClient({
region: InstanceManager({
imex: "ca-central-1",
rome: "us-east-2"
})
region: InstanceRegion()
});
const params = {

View File

@@ -611,7 +611,7 @@ async function InsertFailedExportLog(socket, error) {
bodyshopid: socket.JobData.bodyshop.id,
jobid: socket.JobData.id,
successful: false,
message: [error],
message: JSON.stringify(error),
useremail: socket.user.email
}
});

View File

@@ -167,7 +167,7 @@ async function QueryVendorRecord(oauthClient, qbo_realmId, req, bill) {
async function InsertVendorRecord(oauthClient, qbo_realmId, req, bill) {
const Vendor = {
DisplayName: bill.vendor.name
DisplayName: StandardizeName(bill.vendor.name)
};
try {
const result = await oauthClient.makeApiCall({

View File

@@ -219,6 +219,11 @@ async function InsertPayment(oauthClient, qbo_realmId, req, payment, parentRef,
PaymentMethodRef: {
value: paymentMethods[payment.type]
},
PrivateNote: payment.memo
? payment.memo.length > 4000
? payment.memo.substring(0, 4000).trim()
: payment.memo.trim()
: "",
PaymentRefNum: payment.transactionid,
...(invoices && invoices.length === 1 && invoices[0]
? {

View File

@@ -10,7 +10,7 @@ function urlBuilder(realmId, object, query = null) {
}
function StandardizeName(str) {
return str.replace(new RegExp(/'/g), "\\'");
return str.replace(new RegExp(/'/g), "\\'").trim();
}
exports.urlBuilder = urlBuilder;

View File

@@ -0,0 +1,76 @@
const axios = require("axios");
const _ = require("lodash");
const { default: InstanceMgr } = require("../utils/instanceMgr"); // For deep object comparison
// Constants
const ALERTS_REDIS_KEY = "alerts_data"; // The key under which we'll store alerts in Redis
const GLOBAL_SOCKET_ID = "global"; // Use 'global' as a socketId to store global data
const ALERT_FILE_URL = InstanceMgr({
imex: "https://images.imex.online/alerts/alerts-imex.json",
rome: "https://images.imex.online/alerts/alerts-rome.json"
});
const alertCheck = async (req, res) => {
// Access Redis helper functions
const { ioRedis, logger } = req;
const { getSessionData, setSessionData } = req.sessionUtils;
try {
// Get the JSON Alert file from the server
const response = await axios.get(ALERT_FILE_URL);
const currentAlerts = response.data;
// Retrieve stored alerts from Redis using a global socketId
const storedAlerts = await getSessionData(GLOBAL_SOCKET_ID, ALERTS_REDIS_KEY);
if (!storedAlerts) {
// Alerts not in Redis, store them
await setSessionData(GLOBAL_SOCKET_ID, ALERTS_REDIS_KEY, currentAlerts);
logger.logger.debug("Alerts added to Redis for the first time.");
// Emit to clients
if (ioRedis) {
ioRedis.emit("bodyshop-message", {
type: "alert-update",
payload: currentAlerts
});
logger.logger.debug("Alerts emitted to clients for the first time.");
} else {
logger.log("Socket.IO instance not found. (1)", "error");
}
return res.status(200).send("Alerts added to Redis and emitted to clients.");
} else {
// Alerts are in Redis, compare them
if (!_.isEqual(currentAlerts, storedAlerts)) {
// Alerts are different, update Redis and emit to clients
await setSessionData(GLOBAL_SOCKET_ID, ALERTS_REDIS_KEY, currentAlerts);
logger.logger.debug("Alerts updated in Redis.");
// Emit the new alerts to all connected clients
if (ioRedis) {
ioRedis.emit("bodyshop-message", {
type: "alert-update",
payload: currentAlerts
});
logger.logger.debug("Alerts emitted to clients after update.");
} else {
logger.log("Socket.IO instance not found. (2)", "error");
}
return res.status(200).send("Alerts updated in Redis and emitted to clients.");
} else {
return res.status(200).send("No changes in alerts.");
}
}
} catch (error) {
logger.log("Error in alertCheck:", "error", null, null, {
error: {
message: error.message,
stack: error.stack
}
});
return res.status(500).send("Internal server error.");
}
};
module.exports = { alertCheck };

View File

@@ -995,7 +995,7 @@ async function InsertFailedExportLog(socket, error) {
bodyshopid: socket.JobData.bodyshop.id,
jobid: socket.JobData.id,
successful: false,
message: [error],
message: JSON.stringify(error),
useremail: socket.user.email
}
});

View File

@@ -20,7 +20,7 @@ function CheckCdkResponseForError(socket, soapResponse) {
//The response was null, this might be ok, it might not.
CdkBase.createLogEvent(
socket,
"WARNING",
"warn",
`Warning detected in CDK Response - it appears to be null. Stack: ${new Error().stack}`
);
return;

View File

@@ -13,6 +13,7 @@ let Client = require("ssh2-sftp-client");
const client = require("../graphql-client/graphql-client").client;
const { sendServerEmail } = require("../email/sendemail");
const AHDineroFormat = "0.00";
const AhDateFormat = "MMDDYYYY";
@@ -26,170 +27,180 @@ const ftpSetup = {
password: process.env.AUTOHOUSE_PASSWORD,
debug: (message, ...data) => logger.log(message, "DEBUG", "api", null, data),
algorithms: {
serverHostKey: ["ssh-rsa", "ssh-dss"]
serverHostKey: ["ssh-rsa", "ssh-dss", "rsa-sha2-256", "rsa-sha2-512", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384"]
}
};
const allxmlsToUpload = [];
const allErrors = [];
exports.default = async (req, res) => {
// Only process if in production environment.
if (process.env.NODE_ENV !== "production") {
res.sendStatus(403);
return;
}
//Query for the List of Bodyshop Clients.
logger.log("autohouse-start", "DEBUG", "api", null, null);
const { bodyshops } = await client.request(queries.GET_AUTOHOUSE_SHOPS);
const specificShopIds = req.body.bodyshopIds; // ['uuid]
const { start, end, skipUpload } = req.body; //YYYY-MM-DD
// Only process if the appropriate token is provided.
if (req.headers["x-imex-auth"] !== process.env.AUTOHOUSE_AUTH_TOKEN) {
res.sendStatus(401);
return;
}
const allxmlsToUpload = [];
const allErrors = [];
// Send immediate response and continue processing.
res.status(202).json({
success: true,
message: "Processing request ...",
timestamp: new Date().toISOString()
});
try {
for (const bodyshop of specificShopIds ? bodyshops.filter((b) => specificShopIds.includes(b.id)) : bodyshops) {
logger.log("autohouse-start", "DEBUG", "api", null, null);
const { bodyshops } = await client.request(queries.GET_AUTOHOUSE_SHOPS); //Query for the List of Bodyshop Clients.
const specificShopIds = req.body.bodyshopIds; // ['uuid];
const { start, end, skipUpload } = req.body; //YYYY-MM-DD
const batchSize = 10;
const shopsToProcess =
specificShopIds?.length > 0 ? bodyshops.filter((shop) => specificShopIds.includes(shop.id)) : bodyshops;
logger.log("autohouse-shopsToProcess-generated", "DEBUG", "api", null, null);
if (shopsToProcess.length === 0) {
logger.log("autohouse-shopsToProcess-empty", "DEBUG", "api", null, null);
return;
}
const batchPromises = [];
for (let i = 0; i < shopsToProcess.length; i += batchSize) {
const batch = shopsToProcess.slice(i, i + batchSize);
const batchPromise = (async () => {
await processBatch(batch, start, end);
if (skipUpload) {
for (const xmlObj of allxmlsToUpload) {
fs.writeFileSync(`./logs/${xmlObj.filename}`, xmlObj.xml);
}
} else {
await uploadViaSFTP(allxmlsToUpload);
}
})();
batchPromises.push(batchPromise);
}
await Promise.all(batchPromises);
await sendServerEmail({
subject: `Autohouse Report ${moment().format("MM-DD-YY")}`,
text: `Errors:\n${JSON.stringify(allErrors, null, 2)}\n\nUploaded:\n${JSON.stringify(
allxmlsToUpload.map((x) => ({ filename: x.filename, count: x.count, result: x.result })),
null,
2
)}`
});
logger.log("autohouse-end", "DEBUG", "api", null, null);
} catch (error) {
logger.log("autohouse-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
}
};
async function processBatch(batch, start, end) {
for (const bodyshop of batch) {
const erroredJobs = [];
try {
logger.log("autohouse-start-shop-extract", "DEBUG", "api", bodyshop.id, {
shopname: bodyshop.shopname
});
const erroredJobs = [];
try {
const { jobs, bodyshops_by_pk } = await client.request(queries.AUTOHOUSE_QUERY, {
bodyshopid: bodyshop.id,
start: start ? moment(start).startOf("day") : moment().subtract(5, "days").startOf("day"),
...(end && { end: moment(end).endOf("day") })
});
const autoHouseObject = {
AutoHouseExport: {
RepairOrder: jobs.map((j) =>
CreateRepairOrderTag({ ...j, bodyshop: bodyshops_by_pk }, function ({ job, error }) {
erroredJobs.push({ job: job, error: error.toString() });
})
)
}
};
if (erroredJobs.length > 0) {
logger.log("autohouse-failed-jobs", "ERROR", "api", bodyshop.id, {
count: erroredJobs.length,
jobs: JSON.stringify(erroredJobs.map((j) => j.job.ro_number))
});
}
var ret = builder
.create(
{
// version: "1.0",
// encoding: "UTF-8",
//keepNullNodes: true,
},
autoHouseObject
)
.end({ allowEmptyTags: true });
allxmlsToUpload.push({
count: autoHouseObject.AutoHouseExport.RepairOrder.length,
xml: ret,
filename: `IM_${bodyshop.autohouseid}_${moment().format("DDMMYYYY_HHMMss")}.xml`
});
logger.log("autohouse-end-shop-extract", "DEBUG", "api", bodyshop.id, {
shopname: bodyshop.shopname
});
} catch (error) {
//Error at the shop level.
logger.log("autohouse-error-shop", "ERROR", "api", bodyshop.id, {
...error
});
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
autuhouseid: bodyshop.autuhouseid,
fatal: true,
errors: [error.toString()]
});
} finally {
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
autohouseid: bodyshop.autohouseid,
errors: erroredJobs.map((ej) => ({
ro_number: ej.job?.ro_number,
jobid: ej.job?.id,
error: ej.error
}))
});
}
}
if (skipUpload) {
for (const xmlObj of allxmlsToUpload) {
fs.writeFileSync(`./logs/${xmlObj.filename}`, xmlObj.xml);
}
res.json(allxmlsToUpload);
sendServerEmail({
subject: `Autohouse Report ${moment().format("MM-DD-YY")}`,
text: `Errors: ${allErrors.map((e) => JSON.stringify(e, null, 2))}
Uploaded: ${JSON.stringify(
allxmlsToUpload.map((x) => ({ filename: x.filename, count: x.count })),
null,
2
)}
`
const { jobs, bodyshops_by_pk } = await client.request(queries.AUTOHOUSE_QUERY, {
bodyshopid: bodyshop.id,
start: start ? moment(start).startOf("day") : moment().subtract(5, "days").startOf("day"),
...(end && { end: moment(end).endOf("day") })
});
return;
}
let sftp = new Client();
sftp.on("error", (errors) =>
logger.log("autohouse-sftp-error", "ERROR", "api", null, {
...errors
})
);
try {
//Connect to the FTP and upload all.
const autoHouseObject = {
AutoHouseExport: {
RepairOrder: jobs.map((j) =>
CreateRepairOrderTag({ ...j, bodyshop: bodyshops_by_pk }, function ({ job, error }) {
erroredJobs.push({ job: job, error: error.toString() });
})
)
}
};
await sftp.connect(ftpSetup);
for (const xmlObj of allxmlsToUpload) {
logger.log("autohouse-sftp-upload", "DEBUG", "api", null, {
filename: xmlObj.filename
});
const uploadResult = await sftp.put(Buffer.from(xmlObj.xml), `/${xmlObj.filename}`);
logger.log("autohouse-sftp-upload-result", "DEBUG", "api", null, {
uploadResult
if (erroredJobs.length > 0) {
logger.log("autohouse-failed-jobs", "ERROR", "api", bodyshop.id, {
count: erroredJobs.length,
jobs: JSON.stringify(erroredJobs.map((j) => j.job.ro_number))
});
}
//***TODO Change filing naming when creating the cron job. IM_ShopInternalName_DDMMYYYY_HHMMSS.xml
const ret = builder.create({}, autoHouseObject).end({ allowEmptyTags: true });
allxmlsToUpload.push({
count: autoHouseObject.AutoHouseExport.RepairOrder.length,
xml: ret,
filename: `IM_${bodyshop.autohouseid}_${moment().format("DDMMYYYY_HHMMss")}.xml`
});
logger.log("autohouse-end-shop-extract", "DEBUG", "api", bodyshop.id, {
shopname: bodyshop.shopname
});
} catch (error) {
logger.log("autohouse-sftp-error", "ERROR", "api", null, {
...error
//Error at the shop level.
logger.log("autohouse-error-shop", "ERROR", "api", bodyshop.id, { error: error.message, stack: error.stack });
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
autohouseid: bodyshop.autohouseid,
fatal: true,
errors: [error.toString()]
});
} finally {
sftp.end();
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
autohouseid: bodyshop.autohouseid,
errors: erroredJobs.map((ej) => ({
ro_number: ej.job?.ro_number,
jobid: ej.job?.id,
error: ej.error
}))
});
}
sendServerEmail({
subject: `Autohouse Report ${moment().format("MM-DD-YY")}`,
text: `Errors: ${allErrors.map((e) => JSON.stringify(e, null, 2))}
Uploaded: ${JSON.stringify(
allxmlsToUpload.map((x) => ({ filename: x.filename, count: x.count })),
null,
2
)}
`
});
res.sendStatus(200);
} catch (error) {
res.status(200).json(error);
}
};
}
async function uploadViaSFTP(allxmlsToUpload) {
const sftp = new Client();
sftp.on("error", (errors) =>
logger.log("autohouse-sftp-connection-error", "ERROR", "api", null, { error: errors.message, stack: errors.stack })
);
try {
//Connect to the FTP and upload all.
await sftp.connect(ftpSetup);
for (const xmlObj of allxmlsToUpload) {
try {
xmlObj.result = await sftp.put(Buffer.from(xmlObj.xml), `${xmlObj.filename}`);
logger.log("autohouse-sftp-upload", "DEBUG", "api", null, {
filename: xmlObj.filename,
result: xmlObj.result
});
} catch (error) {
logger.log("autohouse-sftp-upload-error", "ERROR", "api", null, {
filename: xmlObj.filename,
error: error.message,
stack: error.stack
});
throw error;
}
}
} catch (error) {
logger.log("autohouse-sftp-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
throw error;
} finally {
sftp.end();
}
}
const CreateRepairOrderTag = (job, errorCallback) => {
//Level 2
@@ -287,8 +298,8 @@ const CreateRepairOrderTag = (job, errorCallback) => {
InsuranceCo: job.ins_co_nm || "",
CompanyName: job.ins_co_nm || "",
Address: job.ins_addr1 || "",
City: job.ins_addr1 || "",
State: job.ins_city || "",
City: job.ins_city || "",
State: job.ins_st || "",
Zip: job.ins_zip || "",
Phone: job.ins_ph1 || "",
Fax: job.ins_fax || "",
@@ -601,10 +612,7 @@ const CreateRepairOrderTag = (job, errorCallback) => {
};
return ret;
} catch (error) {
logger.log("autohouse-job-calculate-error", "ERROR", "api", null, {
error
});
logger.log("autohouse-job-calculate-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
errorCallback({ jobid: job.id, ro_number: job.ro_number, error });
}
};

View File

@@ -22,135 +22,133 @@ const ftpSetup = {
serverHostKey: ["ssh-rsa", "ssh-dss", "rsa-sha2-256", "rsa-sha2-512", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384"]
}
};
const allcsvsToUpload = [];
const allErrors = [];
exports.default = async (req, res) => {
// Only process if in production environment.
if (process.env.NODE_ENV !== "production") {
res.sendStatus(403);
return;
}
// Only process if the appropriate token is provided.
if (req.headers["x-imex-auth"] !== process.env.AUTOHOUSE_AUTH_TOKEN) {
res.sendStatus(401);
return;
}
//Query for the List of Bodyshop Clients.
logger.log("chatter-start", "DEBUG", "api", null, null);
const { bodyshops } = await client.request(queries.GET_CHATTER_SHOPS);
const specificShopIds = req.body.bodyshopIds; // ['uuid]
const { start, end, skipUpload } = req.body; //YYYY-MM-DD
const allcsvsToUpload = [];
const allErrors = [];
// Send immediate response and continue processing.
res.status(202).json({
success: true,
message: "Processing request ...",
timestamp: new Date().toISOString()
});
try {
for (const bodyshop of specificShopIds ? bodyshops.filter((b) => specificShopIds.includes(b.id)) : bodyshops) {
logger.log("chatter-start-shop-extract", "DEBUG", "api", bodyshop.id, {
shopname: bodyshop.shopname
});
try {
const { jobs, bodyshops_by_pk } = await client.request(queries.CHATTER_QUERY, {
bodyshopid: bodyshop.id,
start: start ? moment(start).startOf("day") : moment().subtract(1, "days").startOf("day"),
...(end && { end: moment(end).endOf("day") })
});
logger.log("chatter-start", "DEBUG", "api", null, null);
const { bodyshops } = await client.request(queries.GET_CHATTER_SHOPS); //Query for the List of Bodyshop Clients.
const specificShopIds = req.body.bodyshopIds; // ['uuid];
const { start, end, skipUpload } = req.body; //YYYY-MM-DD
const chatterObject = jobs.map((j) => {
return {
poc_trigger_code: bodyshops_by_pk.chatterid,
firstname: j.ownr_co_nm ? null : j.ownr_fn,
lastname: j.ownr_co_nm ? j.ownr_co_nm : j.ownr_ln,
transaction_id: j.ro_number,
email: j.ownr_ea,
phone_number: j.ownr_ph1
};
});
const batchSize = 10;
const ret = converter.json2csv(chatterObject, { emptyFieldValue: "" });
const shopsToProcess =
specificShopIds?.length > 0 ? bodyshops.filter((shop) => specificShopIds.includes(shop.id)) : bodyshops;
logger.log("chatter-shopsToProcess-generated", "DEBUG", "api", null, null);
allcsvsToUpload.push({
count: chatterObject.length,
csv: ret,
filename: `${bodyshop.shopname}_solicitation_${moment().format("YYYYMMDD")}.csv`
});
logger.log("chatter-end-shop-extract", "DEBUG", "api", bodyshop.id, {
shopname: bodyshop.shopname
});
} catch (error) {
//Error at the shop level.
logger.log("chatter-error-shop", "ERROR", "api", bodyshop.id, {
...error
});
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
shopname: bodyshop.shopname,
fatal: true,
errors: [error.toString()]
});
} finally {
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
shopname: bodyshop.shopname
});
}
}
if (skipUpload) {
for (const csvObj of allcsvsToUpload) {
fs.writeFile(`./logs/${csvObj.filename}`, csvObj.csv);
}
sendServerEmail({
subject: `Chatter Report ${moment().format("MM-DD-YY")}`,
text: `Errors: ${allErrors.map((e) => JSON.stringify(e, null, 2))}
Uploaded: ${JSON.stringify(
allcsvsToUpload.map((x) => ({ filename: x.filename, count: x.count })),
null,
2
)}
`
});
res.json(allcsvsToUpload);
if (shopsToProcess.length === 0) {
logger.log("chatter-shopsToProcess-empty", "DEBUG", "api", null, null);
return;
}
const sftp = new Client();
sftp.on("error", (errors) => logger.log("chatter-sftp-error", "ERROR", "api", null, { ...errors }));
try {
//Get the private key from AWS Secrets Manager.
ftpSetup.privateKey = await getPrivateKey();
//Connect to the FTP and upload all.
await sftp.connect(ftpSetup);
for (const csvObj of allcsvsToUpload) {
logger.log("chatter-sftp-upload", "DEBUG", "api", null, { filename: csvObj.filename });
const uploadResult = await sftp.put(Buffer.from(csvObj.xml), `/${csvObj.filename}`);
logger.log("chatter-sftp-upload-result", "DEBUG", "api", null, { uploadResult });
}
} catch (error) {
logger.log("chatter-sftp-error", "ERROR", "api", null, { ...error });
} finally {
sftp.end();
const batchPromises = [];
for (let i = 0; i < shopsToProcess.length; i += batchSize) {
const batch = shopsToProcess.slice(i, i + batchSize);
const batchPromise = (async () => {
await processBatch(batch, start, end);
if (skipUpload) {
for (const csvObj of allcsvsToUpload) {
await fs.promises.writeFile(`./logs/${csvObj.filename}`, csvObj.csv);
}
} else {
await uploadViaSFTP(allcsvsToUpload);
}
})();
batchPromises.push(batchPromise);
}
sendServerEmail({
await Promise.all(batchPromises);
await sendServerEmail({
subject: `Chatter Report ${moment().format("MM-DD-YY")}`,
text: `Errors: ${allErrors.map((e) => JSON.stringify(e, null, 2))}
Uploaded: ${JSON.stringify(
allcsvsToUpload.map((x) => ({ filename: x.filename, count: x.count })),
text: `Errors:\n${JSON.stringify(allErrors, null, 2)}\n\nUploaded:\n${JSON.stringify(
allcsvsToUpload.map((x) => ({ filename: x.filename, count: x.count, result: x.result })),
null,
2
)}`
});
res.sendStatus(200);
logger.log("chatter-end", "DEBUG", "api", null, null);
} catch (error) {
res.status(200).json(error);
logger.log("chatter-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
}
};
async function processBatch(batch, start, end) {
for (const bodyshop of batch) {
try {
logger.log("chatter-start-shop-extract", "DEBUG", "api", bodyshop.id, {
shopname: bodyshop.shopname
});
const { jobs, bodyshops_by_pk } = await client.request(queries.CHATTER_QUERY, {
bodyshopid: bodyshop.id,
start: start ? moment(start).startOf("day") : moment().subtract(1, "days").startOf("day"),
...(end && { end: moment(end).endOf("day") })
});
const chatterObject = jobs.map((j) => {
return {
poc_trigger_code: bodyshops_by_pk.chatterid,
firstname: j.ownr_co_nm ? null : j.ownr_fn,
lastname: j.ownr_co_nm ? j.ownr_co_nm : j.ownr_ln,
transaction_id: j.ro_number,
email: j.ownr_ea,
phone_number: j.ownr_ph1
};
});
const ret = converter.json2csv(chatterObject, { emptyFieldValue: "" });
allcsvsToUpload.push({
count: chatterObject.length,
csv: ret,
filename: `${bodyshop.shopname}_solicitation_${moment().format("YYYYMMDD")}.csv`
});
logger.log("chatter-end-shop-extract", "DEBUG", "api", bodyshop.id, {
shopname: bodyshop.shopname
});
} catch (error) {
//Error at the shop level.
logger.log("chatter-error-shop", "ERROR", "api", bodyshop.id, { error: error.message, stack: error.stack });
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
shopname: bodyshop.shopname,
fatal: true,
errors: [error.toString()]
});
} finally {
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
shopname: bodyshop.shopname
});
}
}
}
async function getPrivateKey() {
// Connect to AWS Secrets Manager
const client = new SecretsManagerClient({ region: "ca-central-1" });
@@ -160,9 +158,46 @@ async function getPrivateKey() {
try {
const { SecretString, SecretBinary } = await client.send(command);
if (SecretString || SecretBinary) logger.log("chatter-retrieved-private-key", "DEBUG", "api", null, null);
return SecretString || Buffer.from(SecretBinary, "base64").toString("ascii");
const chatterPrivateKey = SecretString ? SecretString : Buffer.from(SecretBinary, "base64").toString("ascii");
return chatterPrivateKey;
} catch (error) {
logger.log("chatter-get-private-key", "ERROR", "api", null, error);
throw err;
logger.log("chatter-get-private-key", "ERROR", "api", null, { error: error.message, stack: error.stack });
throw error;
}
}
async function uploadViaSFTP(allcsvsToUpload) {
const sftp = new Client();
sftp.on("error", (errors) =>
logger.log("chatter-sftp-connection-error", "ERROR", "api", null, { error: errors.message, stack: errors.stack })
);
try {
//Get the private key from AWS Secrets Manager.
const privateKey = await getPrivateKey();
//Connect to the FTP and upload all.
await sftp.connect({ ...ftpSetup, privateKey });
for (const csvObj of allcsvsToUpload) {
try {
csvObj.result = await sftp.put(Buffer.from(csvObj.csv), `${csvObj.filename}`);
logger.log("chatter-sftp-upload", "DEBUG", "api", null, {
filename: csvObj.filename,
result: csvObj.result
});
} catch (error) {
logger.log("chatter-sftp-upload-error", "ERROR", "api", null, {
filename: csvObj.filename,
error: error.message,
stack: error.stack
});
throw error;
}
}
} catch (error) {
logger.log("chatter-sftp-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
throw error;
} finally {
sftp.end();
}
}

View File

@@ -26,174 +26,184 @@ const ftpSetup = {
password: process.env.CLAIMSCORP_PASSWORD,
debug: (message, ...data) => logger.log(message, "DEBUG", "api", null, data),
algorithms: {
serverHostKey: ["ssh-rsa", "ssh-dss"]
serverHostKey: ["ssh-rsa", "ssh-dss", "rsa-sha2-256", "rsa-sha2-512", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384"]
}
};
const allxmlsToUpload = [];
const allErrors = [];
exports.default = async (req, res) => {
// Only process if in production environment.
if (process.env.NODE_ENV !== "production") {
res.sendStatus(403);
return;
}
//Query for the List of Bodyshop Clients.
logger.log("claimscorp-start", "DEBUG", "api", null, null);
const { bodyshops } = await client.request(queries.GET_CLAIMSCORP_SHOPS);
const specificShopIds = req.body.bodyshopIds; // ['uuid]
const { start, end, skipUpload } = req.body; //YYYY-MM-DD
// Only process if the appropriate token is provided.
if (req.headers["x-imex-auth"] !== process.env.AUTOHOUSE_AUTH_TOKEN) {
res.sendStatus(401);
return;
}
const allxmlsToUpload = [];
const allErrors = [];
// Send immediate response and continue processing.
res.status(202).json({
success: true,
message: "Processing request ...",
timestamp: new Date().toISOString()
});
try {
for (const bodyshop of specificShopIds ? bodyshops.filter((b) => specificShopIds.includes(b.id)) : bodyshops) {
logger.log("claimscorp-start", "DEBUG", "api", null, null);
const { bodyshops } = await client.request(queries.GET_CLAIMSCORP_SHOPS); //Query for the List of Bodyshop Clients.
const specificShopIds = req.body.bodyshopIds; // ['uuid];
const { start, end, skipUpload } = req.body; //YYYY-MM-DD
const batchSize = 10;
const shopsToProcess =
specificShopIds?.length > 0 ? bodyshops.filter((shop) => specificShopIds.includes(shop.id)) : bodyshops;
logger.log("claimscorp-shopsToProcess-generated", "DEBUG", "api", null, null);
if (shopsToProcess.length === 0) {
logger.log("claimscorp-shopsToProcess-empty", "DEBUG", "api", null, null);
return;
}
const batchPromises = [];
for (let i = 0; i < shopsToProcess.length; i += batchSize) {
const batch = shopsToProcess.slice(i, i + batchSize);
const batchPromise = (async () => {
await processBatch(batch, start, end);
if (skipUpload) {
for (const xmlObj of allxmlsToUpload) {
fs.writeFileSync(`./logs/${xmlObj.filename}`, xmlObj.xml);
}
} else {
await uploadViaSFTP(allxmlsToUpload);
}
})();
batchPromises.push(batchPromise);
}
await Promise.all(batchPromises);
await sendServerEmail({
subject: `ClaimsCorp Report ${moment().format("MM-DD-YY")}`,
text: `Errors:\n${JSON.stringify(allErrors, null, 2)}\n\nUploaded:\n${JSON.stringify(
allxmlsToUpload.map((x) => ({ filename: x.filename, count: x.count, result: x.result })),
null,
2
)}`
});
logger.log("claimscorp-end", "DEBUG", "api", null, null);
} catch (error) {
logger.log("claimscorp-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
}
};
async function processBatch(batch, start, end) {
for (const bodyshop of batch) {
const erroredJobs = [];
try {
logger.log("claimscorp-start-shop-extract", "DEBUG", "api", bodyshop.id, {
shopname: bodyshop.shopname
});
const erroredJobs = [];
try {
const { jobs, bodyshops_by_pk } = await client.request(queries.CLAIMSCORP_QUERY, {
bodyshopid: bodyshop.id,
start: start ? moment(start).startOf("day") : moment().subtract(5, "days").startOf("day"),
...(end && { end: moment(end).endOf("day") })
});
const claimsCorpObject = {
DataFeed: {
ShopInfo: {
ShopID: bodyshops_by_pk.claimscorpid,
ShopName: bodyshops_by_pk.shopname,
RO: jobs.map((j) =>
CreateRepairOrderTag({ ...j, bodyshop: bodyshops_by_pk }, function ({ job, error }) {
erroredJobs.push({ job: job, error: error.toString() });
})
)
}
}
};
if (erroredJobs.length > 0) {
logger.log("claimscorp-failed-jobs", "ERROR", "api", bodyshop.id, {
count: erroredJobs.length,
jobs: JSON.stringify(erroredJobs.map((j) => j.job.ro_number))
});
}
var ret = builder
.create(
{
// version: "1.0",
// encoding: "UTF-8",
//keepNullNodes: true,
},
claimsCorpObject
)
.end({ allowEmptyTags: true });
allxmlsToUpload.push({
count: claimsCorpObject.DataFeed.ShopInfo.RO.length,
xml: ret,
filename: `${bodyshop.claimscorpid}-${moment().format("YYYYMMDDTHHMMss")}.xml`
});
logger.log("claimscorp-end-shop-extract", "DEBUG", "api", bodyshop.id, {
shopname: bodyshop.shopname
});
} catch (error) {
//Error at the shop level.
logger.log("claimscorp-error-shop", "ERROR", "api", bodyshop.id, {
...error
});
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
claimscorpid: bodyshop.claimscorpid,
fatal: true,
errors: [error.toString()]
});
} finally {
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
claimscorpid: bodyshop.claimscorpid,
errors: erroredJobs.map((ej) => ({
ro_number: ej.job?.ro_number,
jobid: ej.job?.id,
error: ej.error
}))
});
}
}
if (skipUpload) {
for (const xmlObj of allxmlsToUpload) {
fs.writeFileSync(`./logs/${xmlObj.filename}`, xmlObj.xml);
}
res.json(allxmlsToUpload);
sendServerEmail({
subject: `ClaimsCorp Report ${moment().format("MM-DD-YY")}`,
text: `Errors: ${allErrors.map((e) => JSON.stringify(e, null, 2))}
Uploaded: ${JSON.stringify(
allxmlsToUpload.map((x) => ({ filename: x.filename, count: x.count })),
null,
2
)}
`
const { jobs, bodyshops_by_pk } = await client.request(queries.CLAIMSCORP_QUERY, {
bodyshopid: bodyshop.id,
start: start ? moment(start).startOf("day") : moment().subtract(5, "days").startOf("day"),
...(end && { end: moment(end).endOf("day") })
});
return;
}
let sftp = new Client();
sftp.on("error", (errors) =>
logger.log("claimscorp-sftp-error", "ERROR", "api", null, {
...errors
})
);
try {
//Connect to the FTP and upload all.
const claimsCorpObject = {
DataFeed: {
ShopInfo: {
ShopID: bodyshops_by_pk.claimscorpid,
ShopName: bodyshops_by_pk.shopname,
RO: jobs.map((j) =>
CreateRepairOrderTag({ ...j, bodyshop: bodyshops_by_pk }, function ({ job, error }) {
erroredJobs.push({ job: job, error: error.toString() });
})
)
}
}
};
await sftp.connect(ftpSetup);
for (const xmlObj of allxmlsToUpload) {
logger.log("claimscorp-sftp-upload", "DEBUG", "api", null, {
filename: xmlObj.filename
});
const uploadResult = await sftp.put(Buffer.from(xmlObj.xml), `/${xmlObj.filename}`);
logger.log("claimscorp-sftp-upload-result", "DEBUG", "api", null, {
uploadResult
if (erroredJobs.length > 0) {
logger.log("claimscorp-failed-jobs", "ERROR", "api", bodyshop.id, {
count: erroredJobs.length,
jobs: JSON.stringify(erroredJobs.map((j) => j.job.ro_number))
});
}
//***TODO Change filing naming when creating the cron job. IM_ShopInternalName_DDMMYYYY_HHMMSS.xml
const ret = builder.create({}, claimsCorpObject).end({ allowEmptyTags: true });
allxmlsToUpload.push({
count: claimsCorpObject.DataFeed.ShopInfo.RO.length,
xml: ret,
filename: `${bodyshop.claimscorpid}-${moment().format("YYYYMMDDTHHMMss")}.xml`
});
logger.log("claimscorp-end-shop-extract", "DEBUG", "api", bodyshop.id, {
shopname: bodyshop.shopname
});
} catch (error) {
logger.log("claimscorp-sftp-error", "ERROR", "api", null, {
...error
//Error at the shop level.
logger.log("claimscorp-error-shop", "ERROR", "api", bodyshop.id, { error: error.message, stack: error.stack });
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
claimscorpid: bodyshop.claimscorpid,
fatal: true,
errors: [error.toString()]
});
} finally {
sftp.end();
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
claimscorpid: bodyshop.claimscorpid,
errors: erroredJobs.map((ej) => ({
ro_number: ej.job?.ro_number,
jobid: ej.job?.id,
error: ej.error
}))
});
}
sendServerEmail({
subject: `ClaimsCorp Report ${moment().format("MM-DD-YY")}`,
text: `Errors: ${allErrors.map((e) => JSON.stringify(e, null, 2))}
Uploaded: ${JSON.stringify(
allxmlsToUpload.map((x) => ({ filename: x.filename, count: x.count })),
null,
2
)}
`
});
res.sendStatus(200);
} catch (error) {
res.status(200).json(error);
}
};
}
async function uploadViaSFTP(allxmlsToUpload) {
const sftp = new Client();
sftp.on("error", (errors) =>
logger.log("claimscorp-sftp-connection-error", "ERROR", "api", null, { error: errors.message, stack: errors.stack })
);
try {
//Connect to the FTP and upload all.
await sftp.connect(ftpSetup);
for (const xmlObj of allxmlsToUpload) {
try {
xmlObj.result = await sftp.put(Buffer.from(xmlObj.xml), `${xmlObj.filename}`);
logger.log("claimscorp-sftp-upload", "DEBUG", "api", null, {
filename: xmlObj.filename,
result: xmlObj.result
});
} catch (error) {
logger.log("claimscorp-sftp-upload-error", "ERROR", "api", null, {
filename: xmlObj.filename,
error: error.message,
stack: error.stack
});
throw error;
}
}
} catch (error) {
logger.log("claimscorp-sftp-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
throw error;
} finally {
sftp.end();
}
}
const CreateRepairOrderTag = (job, errorCallback) => {
//Level 2
@@ -445,10 +455,7 @@ const CreateRepairOrderTag = (job, errorCallback) => {
};
return ret;
} catch (error) {
logger.log("claimscorp-job-calculate-error", "ERROR", "api", null, {
error
});
logger.log("claimscorp-job-calculate-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
errorCallback({ jobid: job.id, ro_number: job.ro_number, error });
}
};

View File

@@ -16,8 +16,7 @@ const { sendServerEmail } = require("../email/sendemail");
const DineroFormat = "0,0.00";
const DateFormat = "MM/DD/YYYY";
const repairOpCodes = ["OP4", "OP9", "OP10"];
const replaceOpCodes = ["OP2", "OP5", "OP11", "OP12"];
const kaizenShopsIDs = ["SUMMIT", "STRATHMORE", "SUNRIDGE", "SHAW"];
const ftpSetup = {
host: process.env.KAIZEN_HOST,
@@ -30,173 +29,179 @@ const ftpSetup = {
}
};
const allxmlsToUpload = [];
const allErrors = [];
exports.default = async (req, res) => {
// Only process if in production environment.
if (process.env.NODE_ENV !== "production") {
res.sendStatus(403);
return;
}
//Query for the List of Bodyshop Clients.
logger.log("kaizen-start", "DEBUG", "api", null, null);
const kaizenShopsIDs = ["SUMMIT", "STRATHMORE", "SUNRIDGE", "SHAW"];
const { bodyshops } = await client.request(queries.GET_KAIZEN_SHOPS, {
imexshopid: kaizenShopsIDs
});
const specificShopIds = req.body.bodyshopIds; // ['uuid]
const { start, end, skipUpload } = req.body; //YYYY-MM-DD
// Only process if the appropriate token is provided.
if (req.headers["x-imex-auth"] !== process.env.AUTOHOUSE_AUTH_TOKEN) {
res.sendStatus(401);
return;
}
const allxmlsToUpload = [];
const allErrors = [];
// Send immediate response and continue processing.
res.status(202).json({
success: true,
message: "Processing request ...",
timestamp: new Date().toISOString()
});
try {
for (const bodyshop of specificShopIds ? bodyshops.filter((b) => specificShopIds.includes(b.id)) : bodyshops) {
logger.log("kaizen-start", "DEBUG", "api", null, null);
const { bodyshops } = await client.request(queries.GET_KAIZEN_SHOPS, { imexshopid: kaizenShopsIDs }); //Query for the List of Bodyshop Clients.
const specificShopIds = req.body.bodyshopIds; // ['uuid];
const { start, end, skipUpload } = req.body; //YYYY-MM-DD
const batchSize = 10;
const shopsToProcess =
specificShopIds?.length > 0 ? bodyshops.filter((shop) => specificShopIds.includes(shop.id)) : bodyshops;
logger.log("kaizen-shopsToProcess-generated", "DEBUG", "api", null, null);
if (shopsToProcess.length === 0) {
logger.log("kaizen-shopsToProcess-empty", "DEBUG", "api", null, null);
return;
}
const batchPromises = [];
for (let i = 0; i < shopsToProcess.length; i += batchSize) {
const batch = shopsToProcess.slice(i, i + batchSize);
const batchPromise = (async () => {
await processBatch(batch, start, end);
if (skipUpload) {
for (const xmlObj of allxmlsToUpload) {
fs.writeFileSync(`./logs/${xmlObj.filename}`, xmlObj.xml);
}
} else {
await uploadViaSFTP(allxmlsToUpload);
}
})();
batchPromises.push(batchPromise);
}
await Promise.all(batchPromises);
await sendServerEmail({
subject: `Kaizen Report ${moment().format("MM-DD-YY")}`,
text: `Errors:\n${JSON.stringify(allErrors, null, 2)}\n\nUploaded:\n${JSON.stringify(
allxmlsToUpload.map((x) => ({ filename: x.filename, count: x.count, result: x.result })),
null,
2
)}`
});
logger.log("kaizen-end", "DEBUG", "api", null, null);
} catch (error) {
logger.log("kaizen-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
}
};
async function processBatch(batch, start, end) {
for (const bodyshop of batch) {
const erroredJobs = [];
try {
logger.log("kaizen-start-shop-extract", "DEBUG", "api", bodyshop.id, {
shopname: bodyshop.shopname
});
const erroredJobs = [];
try {
const { jobs, bodyshops_by_pk } = await client.request(queries.KAIZEN_QUERY, {
bodyshopid: bodyshop.id,
start: start ? moment(start).startOf("day") : moment().subtract(5, "days").startOf("day"),
...(end && { end: moment(end).endOf("day") })
});
const kaizenObject = {
DataFeed: {
ShopInfo: {
ShopName: bodyshops_by_pk.shopname,
Jobs: jobs.map((j) =>
CreateRepairOrderTag({ ...j, bodyshop: bodyshops_by_pk }, function ({ job, error }) {
erroredJobs.push({ job: job, error: error.toString() });
})
)
}
}
};
if (erroredJobs.length > 0) {
logger.log("kaizen-failed-jobs", "ERROR", "api", bodyshop.id, {
count: erroredJobs.length,
jobs: JSON.stringify(erroredJobs.map((j) => j.job.ro_number))
});
}
var ret = builder
.create(
{
// version: "1.0",
// encoding: "UTF-8",
//keepNullNodes: true,
},
kaizenObject
)
.end({ allowEmptyTags: true });
allxmlsToUpload.push({
count: kaizenObject.DataFeed.ShopInfo.Jobs.length,
xml: ret,
filename: `${bodyshop.shopname}-${moment().format("YYYYMMDDTHHMMss")}.xml`
});
logger.log("kaizen-end-shop-extract", "DEBUG", "api", bodyshop.id, {
shopname: bodyshop.shopname
});
} catch (error) {
//Error at the shop level.
logger.log("kaizen-error-shop", "ERROR", "api", bodyshop.id, {
...error
});
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
shopname: bodyshop.shopname,
fatal: true,
errors: [error.toString()]
});
} finally {
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
shopname: bodyshop.shopname,
errors: erroredJobs.map((ej) => ({
ro_number: ej.job?.ro_number,
jobid: ej.job?.id,
error: ej.error
}))
});
}
}
if (skipUpload) {
for (const xmlObj of allxmlsToUpload) {
fs.writeFileSync(`./logs/${xmlObj.filename}`, xmlObj.xml);
}
res.json(allxmlsToUpload);
sendServerEmail({
subject: `Kaizen Report ${moment().format("MM-DD-YY")}`,
text: `Errors: ${allErrors.map((e) => JSON.stringify(e, null, 2))}
Uploaded: ${JSON.stringify(
allxmlsToUpload.map((x) => ({ filename: x.filename, count: x.count })),
null,
2
)}
`
const { jobs, bodyshops_by_pk } = await client.request(queries.KAIZEN_QUERY, {
bodyshopid: bodyshop.id,
start: start ? moment(start).startOf("day") : moment().subtract(5, "days").startOf("day"),
...(end && { end: moment(end).endOf("day") })
});
return;
}
let sftp = new Client();
sftp.on("error", (errors) =>
logger.log("kaizen-sftp-error", "ERROR", "api", null, {
...errors
})
);
try {
//Connect to the FTP and upload all.
const kaizenObject = {
DataFeed: {
ShopInfo: {
ShopName: bodyshops_by_pk.shopname,
Jobs: jobs.map((j) =>
CreateRepairOrderTag({ ...j, bodyshop: bodyshops_by_pk }, function ({ job, error }) {
erroredJobs.push({ job: job, error: error.toString() });
})
)
}
}
};
await sftp.connect(ftpSetup);
for (const xmlObj of allxmlsToUpload) {
logger.log("kaizen-sftp-upload", "DEBUG", "api", null, {
filename: xmlObj.filename
});
const uploadResult = await sftp.put(Buffer.from(xmlObj.xml), `/${xmlObj.filename}`);
logger.log("kaizen-sftp-upload-result", "DEBUG", "api", null, {
uploadResult
if (erroredJobs.length > 0) {
logger.log("kaizen-failed-jobs", "ERROR", "api", bodyshop.id, {
count: erroredJobs.length,
jobs: JSON.stringify(erroredJobs.map((j) => j.job.ro_number))
});
}
//***TODO Change filing naming when creating the cron job. IM_ShopInternalName_DDMMYYYY_HHMMSS.xml
const ret = builder.create({}, kaizenObject).end({ allowEmptyTags: true });
allxmlsToUpload.push({
count: kaizenObject.DataFeed.ShopInfo.Jobs.length,
xml: ret,
filename: `${bodyshop.shopname}-${moment().format("YYYYMMDDTHHMMss")}.xml`
});
logger.log("kaizen-end-shop-extract", "DEBUG", "api", bodyshop.id, {
shopname: bodyshop.shopname
});
} catch (error) {
logger.log("kaizen-sftp-error", "ERROR", "api", null, {
...error
//Error at the shop level.
logger.log("kaizen-error-shop", "ERROR", "api", bodyshop.id, { error: error.message, stack: error.stack });
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
shopname: bodyshop.shopname,
fatal: true,
errors: [error.toString()]
});
} finally {
sftp.end();
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: bodyshop.imexshopid,
shopname: bodyshop.shopname,
errors: erroredJobs.map((ej) => ({
ro_number: ej.job?.ro_number,
jobid: ej.job?.id,
error: ej.error
}))
});
}
sendServerEmail({
subject: `Kaizen Report ${moment().format("MM-DD-YY")}`,
text: `Errors: ${allErrors.map((e) => JSON.stringify(e, null, 2))}
Uploaded: ${JSON.stringify(
allxmlsToUpload.map((x) => ({ filename: x.filename, count: x.count })),
null,
2
)}
`
});
res.sendStatus(200);
} catch (error) {
res.status(200).json(error);
}
};
}
async function uploadViaSFTP(allxmlsToUpload) {
const sftp = new Client();
sftp.on("error", (errors) =>
logger.log("kaizen-sftp-connection-error", "ERROR", "api", null, { error: errors.message, stack: errors.stack })
);
try {
//Connect to the FTP and upload all.
await sftp.connect(ftpSetup);
for (const xmlObj of allxmlsToUpload) {
try {
xmlObj.result = await sftp.put(Buffer.from(xmlObj.xml), `${xmlObj.filename}`);
logger.log("kaizen-sftp-upload", "DEBUG", "api", null, {
filename: xmlObj.filename,
result: xmlObj.result
});
} catch (error) {
logger.log("kaizen-sftp-upload-error", "ERROR", "api", null, {
filename: xmlObj.filename,
error: error.message,
stack: error.stack
});
throw error;
}
}
} catch (error) {
logger.log("kaizen-sftp-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
throw error;
} finally {
sftp.end();
}
}
const CreateRepairOrderTag = (job, errorCallback) => {
//Level 2
@@ -420,10 +425,7 @@ const CreateRepairOrderTag = (job, errorCallback) => {
};
return ret;
} catch (error) {
logger.log("kaizen-job-calculate-error", "ERROR", "api", null, {
error
});
logger.log("kaizen-job-calculate-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
errorCallback({ jobid: job.id, ro_number: job.ro_number, error });
}
};

View File

@@ -1,6 +1,6 @@
const { isString, isEmpty } = require("lodash");
const { defaultProvider } = require("@aws-sdk/credential-provider-node");
const { default: InstanceManager } = require("../utils/instanceMgr");
const { InstanceRegion } = require("../utils/instanceMgr");
const aws = require("@aws-sdk/client-ses");
const nodemailer = require("nodemailer");
const logger = require("../utils/logger");
@@ -10,12 +10,7 @@ const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.e
const sesConfig = {
apiVersion: "latest",
credentials: defaultProvider(),
region: isLocal
? "ca-central-1"
: InstanceManager({
imex: "ca-central-1",
rome: "us-east-2"
})
region: InstanceRegion()
};
if (isLocal) {

View File

@@ -87,6 +87,21 @@ mutation RECEIVE_MESSAGE($msg: [messages_insert_input!]!) {
updated_at
unreadcnt
phone_num
label
job_conversations {
job {
id
ro_number
ownr_fn
ownr_ln
ownr_co_nm
}
}
messages_aggregate {
aggregate {
count
}
}
}
conversationid
created_at
@@ -2544,3 +2559,69 @@ exports.GET_JOBS_BY_PKS = `query GET_JOBS_BY_PKS($ids: [uuid!]!) {
}
}
`;
exports.GET_CONVERSATIONS = `query GET_CONVERSATIONS($bodyshopId: uuid!) {
conversations(
where: { bodyshopid: { _eq: $bodyshopId }, archived: { _eq: false } },
order_by: { updated_at: desc },
limit: 50
) {
phone_num
id
updated_at
unreadcnt
archived
label
messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) {
aggregate {
count
}
}
job_conversations {
job {
id
ro_number
ownr_fn
ownr_ln
ownr_co_nm
}
}
}
}
`;
exports.MARK_MESSAGES_AS_READ = `mutation MARK_MESSAGES_AS_READ($conversationId: uuid!) {
update_messages(where: { conversationid: { _eq: $conversationId } }, _set: { read: true }) {
affected_rows
}
}
`;
exports.GET_CONVERSATION_DETAILS = `
query GET_CONVERSATION_DETAILS($conversationId: uuid!) {
conversation: conversations_by_pk(id: $conversationId) {
id
phone_num
updated_at
label
job_conversations {
job {
id
ro_number
ownr_fn
ownr_ln
ownr_co_nm
}
}
}
messages: messages(where: { conversationid: { _eq: $conversationId } }, order_by: { created_at: asc }) {
id
text
created_at
read
isoutbound
userid
image_path
}
}
`;

View File

@@ -17,12 +17,10 @@ require("dotenv").config({
const domain = process.env.NODE_ENV ? "secure" : "test";
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
const { InstanceRegion } = require("../utils/instanceMgr");
const client = new SecretsManagerClient({
region: InstanceManager({
imex: "ca-central-1",
rome: "us-east-2"
})
region: InstanceRegion()
});
const gqlClient = require("../graphql-client/graphql-client").client;

View File

@@ -78,16 +78,20 @@ const jobLifecycle = async (req, res) => {
Object.keys(flatGroupedAllDurations).forEach((status) => {
const value = flatGroupedAllDurations[status].reduce((acc, curr) => acc + curr.value, 0);
const humanReadable = durationToHumanReadable(moment.duration(value));
const percentage = (value / finalTotal) * 100;
const percentage = finalTotal > 0 ? (value / finalTotal) * 100 : 0;
const color = getLifecycleStatusColor(status);
const roundedPercentage = `${Math.round(percentage)}%`;
const averageValue = _.size(jobIDs) > 0 ? value / jobIDs.length : 0;
const averageHumanReadable = durationToHumanReadable(moment.duration(averageValue));
finalSummations.push({
status,
value,
humanReadable,
percentage,
color,
roundedPercentage
roundedPercentage,
averageValue,
averageHumanReadable
});
});
@@ -100,7 +104,12 @@ const jobLifecycle = async (req, res) => {
totalStatuses: finalSummations.length,
total: finalTotal,
statusCounts: finalStatusCounts,
humanReadable: durationToHumanReadable(moment.duration(finalTotal))
humanReadable: durationToHumanReadable(moment.duration(finalTotal)),
averageValue: _.size(jobIDs) > 0 ? finalTotal / jobIDs.length : 0,
averageHumanReadable:
_.size(jobIDs) > 0
? durationToHumanReadable(moment.duration(finalTotal / jobIDs.length))
: durationToHumanReadable(moment.duration(0))
}
});
};

View File

@@ -2,8 +2,16 @@ const { isObject } = require("lodash");
const jobUpdated = async (req, res) => {
const { ioRedis, logger, ioHelpers } = req;
// Old Way
if (req?.body?.event?.data?.new || isObject(req?.body?.event?.data?.new)) {
const updatedJob = req.body.event.data.new;
const bodyshopID = updatedJob.shopid;
ioRedis.to(ioHelpers.getBodyshopRoom(bodyshopID)).emit("production-job-updated", updatedJob);
return res.json({ message: "Job updated and event emitted" });
}
if (!req?.body?.event?.data?.new || !isObject(req?.body?.event?.data?.new)) {
// New way
if (!req?.body?.data || !isObject(req.body.data)) {
logger.log("job-update-error", "ERROR", req.user?.email, null, {
message: `Malformed Job Update request sent from Hasura`,
body: req?.body
@@ -15,12 +23,14 @@ const jobUpdated = async (req, res) => {
});
}
logger.log("job-update", "DEBUG", req.user?.email, null, {
message: `Job updated event received from Hasura`,
jobid: req?.body?.event?.data?.new?.id
});
// Uncomment for further testing
// You can also test this using SocketIOAdmin
// logger.log("job-update", "DEBUG", req.user?.email, null, {
// message: `Job updated event received from Hasura`,
// jobid: req?.body?.event?.data?.new?.id
// });
const updatedJob = req.body.event.data.new;
const updatedJob = req.body.data;
const bodyshopID = updatedJob.shopid;
// Emit the job-updated event only to the room corresponding to the bodyshop

View File

@@ -59,7 +59,7 @@ exports.mixdataUpload = async (req, res) => {
res.status(500).json(error);
logger.log("job-mixdata-upload-error", "ERROR", null, null, {
error: error.message,
...error
stack: error.stack
});
}
};

View File

@@ -12,9 +12,10 @@ const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebas
const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLClientMiddleware");
const { taskAssignedEmail, tasksRemindEmail } = require("../email/tasksEmails");
const { canvastest } = require("../render/canvas-handler");
const { alertCheck } = require("../alerts/alertcheck");
//Test route to ensure Express is responding.
router.get("/test", async function (req, res) {
router.get("/test", eventAuthorizationMiddleware, async function (req, res) {
const commit = require("child_process").execSync("git rev-parse --short HEAD");
// console.log(app.get('trust proxy'));
// console.log("remoteAddress", req.socket.remoteAddress);
@@ -31,6 +32,32 @@ router.get("/test", async function (req, res) {
res.status(200).send(`OK - ${commit}`);
});
router.get("/test-logs", eventAuthorizationMiddleware, (req, res) => {
const { logger } = req;
// // Test 1: Log with a message that exceeds the size limit, triggering an upload to S3.
const largeMessage = "A".repeat(256 * 1024 + 1); // Message larger than the log size limit
logger.log(largeMessage, "error", "user123", null, { detail: "large log entry" });
// Test 2: Log with a message that is within the size limit, should log directly using winston.
const smallMessage = "A small log message";
logger.log(smallMessage, "info", "user123", null, { detail: "small log entry" });
// Test 3: Log with the `upload` flag set to `true`, forcing the log to be uploaded to S3.
logger.log(
"This log will be uploaded to S3 regardless of size",
"warning",
"user123",
null,
{ detail: "upload log" },
true
);
// Test 4: Log with a message that doesn't exceed the size limit and doesn't require an upload.
logger.log("Normal log entry", "debug", "user123", { id: 4 }, { detail: "normal log entry" });
return res.status(500).send("Logs tested.");
});
// Search
router.post("/search", validateFirebaseIdTokenMiddleware, withUserGraphQLClientMiddleware, os.search);
router.post("/opensearch", eventAuthorizationMiddleware, os.handler);
@@ -53,4 +80,7 @@ router.post("/taskHandler", validateFirebaseIdTokenMiddleware, taskHandler.taskH
// Canvas Test
router.post("/canvastest", validateFirebaseIdTokenMiddleware, canvastest);
// Alert Check
router.post("/alertcheck", eventAuthorizationMiddleware, alertCheck);
module.exports = router;

View File

@@ -11,7 +11,11 @@ const logger = require("../utils/logger");
const InstanceManager = require("../utils/instanceMgr").default;
exports.receive = async (req, res) => {
//Perform request validation
// Perform request validation
const {
ioRedis,
ioHelpers: { getBodyshopRoom }
} = req;
logger.log("sms-inbound", "DEBUG", "api", null, {
msid: req.body.SmsMessageSid,
@@ -20,7 +24,7 @@ exports.receive = async (req, res) => {
image_path: generateMediaArray(req.body)
});
if (!!!req.body || !!!req.body.MessagingServiceSid || !!!req.body.SmsMessageSid) {
if (!req.body || !req.body.MessagingServiceSid || !req.body.SmsMessageSid) {
logger.log("sms-inbound-error", "ERROR", "api", null, {
msid: req.body.SmsMessageSid,
text: req.body.Body,
@@ -28,170 +32,169 @@ exports.receive = async (req, res) => {
image_path: generateMediaArray(req.body),
type: "malformed-request"
});
res.status(400);
res.json({ success: false, error: "Malformed Request" });
} else {
try {
const response = await client.request(queries.FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, {
mssid: req.body.MessagingServiceSid,
phone: phone(req.body.From).phoneNumber
});
res.status(400).json({ success: false, error: "Malformed Request" });
return;
}
let newMessage = {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body)
};
if (response.bodyshops[0]) {
//Found a bodyshop - should always happen.
if (response.bodyshops[0].conversations.length === 0) {
//No conversation Found, create one.
//console.log("[SMS Receive] No conversation found. Creating one.");
newMessage.conversation = {
data: {
bodyshopid: response.bodyshops[0].id,
phone_num: phone(req.body.From).phoneNumber
}
};
} else if (response.bodyshops[0].conversations.length === 1) {
//Just add it to the conversation
//console.log("[SMS Receive] Conversation found. Added ID.");
newMessage.conversationid = response.bodyshops[0].conversations[0].id;
} else {
//We should never get here.
logger.log("sms-inbound-error", "ERROR", "api", null, {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body),
messagingServiceSid: req.body.MessagingServiceSid,
type: "duplicate-phone"
});
}
try {
let insertresp;
if (response.bodyshops[0].conversations[0]) {
insertresp = await client.request(queries.INSERT_MESSAGE, {
msg: newMessage,
conversationid: response.bodyshops[0].conversations[0] && response.bodyshops[0].conversations[0].id
});
} else {
insertresp = await client.request(queries.RECEIVE_MESSAGE, {
msg: newMessage
});
try {
const response = await client.request(queries.FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, {
mssid: req.body.MessagingServiceSid,
phone: phone(req.body.From).phoneNumber
});
let newMessage = {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body)
};
if (response.bodyshops[0]) {
if (response.bodyshops[0].conversations.length === 0) {
// No conversation found, create one
newMessage.conversation = {
data: {
bodyshopid: response.bodyshops[0].id,
phone_num: phone(req.body.From).phoneNumber
}
const message = insertresp.insert_messages.returning[0];
const data = {
type: "messaging-inbound",
conversationid: message.conversationid || "",
text: message.text || "",
messageid: message.id || "",
phone_num: message.conversation.phone_num || ""
};
try {
// Insert new conversation and message
const insertresp = await client.request(queries.RECEIVE_MESSAGE, { msg: newMessage });
// Safely access conversation and message
const createdConversation = insertresp?.insert_messages?.returning?.[0]?.conversation || null;
const message = insertresp?.insert_messages?.returning?.[0] || {
text: "",
image: false,
image_path: []
};
const fcmresp = await admin.messaging().send({
topic: `${message.conversation.bodyshop.imexshopid}-messaging`,
notification: {
title: InstanceManager({
imex: `ImEX Online Message - ${data.phone_num}`,
rome: `Rome Online Message - ${data.phone_num}`,
promanager: `ProManager Message - ${data.phone_num}`
}),
body: message.image_path ? `Image ${message.text}` : message.text
//imageUrl: "https://thinkimex.com/img/io-fcm.png", //TODO:AIO Resolve addresses for other instances
if (!createdConversation) {
throw new Error("Conversation data is missing from the response.");
}
ioRedis.to(getBodyshopRoom(response.bodyshops[0].id)).emit("new-conversation", {
conversation: {
...createdConversation,
messages_aggregate: {
aggregate: {
count: 1 // Adjust dynamically based on your logic or default to 0
}
}
},
data
message
});
logger.log("sms-inbound-success", "DEBUG", "api", null, {
newMessage,
fcmresp
createdConversation
});
res.status(200).send("");
} catch (e2) {
return;
} catch (e) {
logger.log("sms-inbound-error", "ERROR", "api", null, {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body),
messagingServiceSid: req.body.MessagingServiceSid,
error: e2
error: e
});
res.sendStatus(500).json(e2);
res.status(500).json(e);
return;
}
} else if (response.bodyshops[0].conversations.length === 1) {
// Add to the existing conversation
// conversation UPDATED
newMessage.conversationid = response.bodyshops[0].conversations[0].id;
} else {
// Duplicate phone error
logger.log("sms-inbound-error", "ERROR", "api", null, {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body),
messagingServiceSid: req.body.MessagingServiceSid,
type: "duplicate-phone"
});
res.status(400).json({ success: false, error: "Duplicate phone number" });
return;
}
try {
// Insert message into an existing conversation
const insertresp = await client.request(queries.INSERT_MESSAGE, {
msg: newMessage,
conversationid: newMessage.conversationid
});
const message = insertresp.insert_messages.returning[0];
const data = {
type: "messaging-inbound",
conversationid: message.conversationid || "",
text: message.text || "",
messageid: message.id || "",
phone_num: message.conversation.phone_num || ""
};
const fcmresp = await admin.messaging().send({
topic: `${message.conversation.bodyshop.imexshopid}-messaging`,
notification: {
title: InstanceManager({
imex: `ImEX Online Message - ${data.phone_num}`,
rome: `Rome Online Message - ${data.phone_num}`,
promanager: `ProManager Message - ${data.phone_num}`
}),
body: message.image_path ? `Image ${message.text}` : message.text
},
data
});
logger.log("sms-inbound-success", "DEBUG", "api", null, {
newMessage,
fcmresp
});
// Broadcast new message to the conversation room
const room = `conversation-${newMessage.conversationid}`;
ioRedis.to(room).emit("new-message", newMessage);
res.status(200).send("");
} catch (e) {
logger.log("sms-inbound-error", "ERROR", "api", null, {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body),
messagingServiceSid: req.body.MessagingServiceSid,
error: e
});
res.status(500).json(e);
}
} catch (e1) {
logger.log("sms-inbound-error", "ERROR", "api", null, {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body),
messagingServiceSid: req.body.MessagingServiceSid,
error: e1
});
res.sendStatus(500).json(e1);
}
} catch (e) {
logger.log("sms-inbound-error", "ERROR", "api", null, {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body),
messagingServiceSid: req.body.MessagingServiceSid,
error: e
});
res.status(500).json(e);
}
};
// const sampleMessage: {
// "ToCountry": "CA",
// "ToState": "BC",
// "SmsMessageSid": "SMad7bddaf3454c0904999d6018b1e8f49",
// "NumMedia": "0",
// "ToCity": "Vancouver",
// "FromZip": "",
// "SmsSid": "SMad7bddaf3454c0904999d6018b1e8f49",
// "FromState": "BC",
// "SmsStatus": "received",
// "FromCity": "VANCOUVER",
// "Body": "Hi",
// "FromCountry": "CA",
// "To": "+16043301606",
// "MessagingServiceSid": "MG6e259e2add04ffa0d0aa355038670ee1",
// "ToZip": "",
// "NumSegments": "1",
// "MessageSid": "SMad7bddaf3454c0904999d6018b1e8f49",
// "AccountSid": "AC6c09d337d6b9c68ab6488c2052bd457c",
// "From": "+16049992002",
// "ApiVersion": "2010-04-01"
// }
// ] req.body {
// [0] ToCountry: 'CA',
// [0] MediaContentType0: 'image/jpeg',
// [0] ToState: 'BC',
// [0] SmsMessageSid: 'MM14fa2851ba26e0dc2b62073f8e7cdf27',
// [0] NumMedia: '1',
// [0] ToCity: 'Vancouver',
// [0] FromZip: '',
// [0] SmsSid: 'MM14fa2851ba26e0dc2b62073f8e7cdf27',
// [0] FromState: 'BC',
// [0] SmsStatus: 'received',
// [0] FromCity: 'VANCOUVER',
// [0] Body: '',
// [0] FromCountry: 'CA',
// [0] To: '+16043301606',
// [0] MessagingServiceSid: 'MG6e259e2add04ffa0d0aa355038670ee1',
// [0] ToZip: '',
// [0] NumSegments: '1',
// [0] MessageSid: 'MM14fa2851ba26e0dc2b62073f8e7cdf27',
// [0] AccountSid: 'AC6c09d337d6b9c68ab6488c2052bd457c',
// [0] From: '+16049992002',
// [0] MediaUrl0: 'https://api.twilio.com/2010-04-01/Accounts/AC6c09d337d6b9c68ab6488c2052bd457c/Messages/MM14fa2851ba26e0dc2b62073f8e7cdf27/Media/MEf129dd37979852f395eb29ffb126e19e',
// [0] ApiVersion: '2010-04-01'
// [0] }
// [0] MediaContentType0: 'image/jpeg',
// MediaContentType0: 'video/3gpp',
const generateMediaArray = (body) => {
const { NumMedia } = body;
if (parseInt(NumMedia) > 0) {
//stuff
const ret = [];
for (var i = 0; i < parseInt(NumMedia); i++) {
for (let i = 0; i < parseInt(NumMedia); i++) {
ret.push(body[`MediaUrl${i}`]);
}
return ret;

View File

@@ -14,6 +14,7 @@ const gqlClient = require("../graphql-client/graphql-client").client;
exports.send = (req, res) => {
const { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid } = req.body;
const { ioRedis } = req;
logger.log("sms-outbound", "DEBUG", req.user.email, null, {
messagingServiceSid: messagingServiceSid,
@@ -59,6 +60,13 @@ exports.send = (req, res) => {
conversationid: newMessage.conversationid || ""
};
// TODO Verify
// const messageData = response.insert_messages.returning[0];
// Broadcast new message to conversation room
const room = `conversation-${conversationid}`;
ioRedis.to(room).emit("new-message", newMessage);
admin.messaging().send({
topic: `${imexshopid}-messaging`,
data

View File

@@ -11,6 +11,7 @@ const { admin } = require("../firebase/firebase-handler");
exports.status = (req, res) => {
const { SmsSid, SmsStatus } = req.body;
const { ioRedis } = req;
client
.request(queries.UPDATE_MESSAGE_STATUS, {
msid: SmsSid,
@@ -21,6 +22,12 @@ exports.status = (req, res) => {
msid: SmsSid,
fields: { status: SmsStatus }
});
// TODO Verify
const conversationId = response.update_messages.returning[0].conversationid;
ioRedis.to(`conversation-${conversationId}`).emit("message-status-updated", {
messageId: SmsSid,
status: SmsStatus
});
})
.catch((error) => {
logger.log("sms-status-update-error", "ERROR", "api", null, {

View File

@@ -44,4 +44,10 @@ function InstanceManager({ args, instance, debug, executeFunction, rome, promana
return propToReturn === undefined ? null : propToReturn;
}
exports.InstanceRegion = () =>
InstanceManager({
imex: "ca-central-1",
rome: "us-east-2"
});
exports.default = InstanceManager;

View File

@@ -9,6 +9,45 @@ const winston = require("winston");
const WinstonCloudWatch = require("winston-cloudwatch");
const { isString, isEmpty } = require("lodash");
const { networkInterfaces, hostname } = require("node:os");
const { uploadFileToS3 } = require("./s3");
const { v4 } = require("uuid");
const { InstanceRegion } = require("./instanceMgr");
const LOG_LEVELS = {
error: { level: 0, name: "error" },
warn: { level: 1, name: "warn" },
info: { level: 2, name: "info" },
http: { level: 3, name: "http" },
verbose: { level: 4, name: "verbose" },
debug: { level: 5, name: "debug" },
silly: { level: 6, name: "silly" }
};
const LOG_LENGTH_LIMIT = 256 * 1024; // 256KB
const S3_BUCKET_NAME = InstanceManager({
imex: "imex-large-log",
rome: "rome-large-log"
});
const region = InstanceRegion();
const estimateLogSize = (logEntry) => {
let estimatedSize = 0;
for (const key in logEntry) {
if (logEntry.hasOwnProperty(key)) {
const value = logEntry[key];
if (value === undefined || value === null) {
estimatedSize += key.length; // Only count the key length if value is undefined or null
} else {
estimatedSize += key.length + (typeof value === "string" ? value.length : JSON.stringify(value).length);
}
}
}
return estimatedSize;
};
const normalizeLevel = (level) => (level ? level.toLowerCase() : LOG_LEVELS.debug.name);
const createLogger = () => {
try {
@@ -18,10 +57,7 @@ const createLogger = () => {
const winstonCloudwatchTransportDefaults = {
logGroupName: logGroupName,
awsOptions: {
region: InstanceManager({
imex: "ca-central-1",
rome: "us-east-2"
})
region
},
jsonMessage: true
};
@@ -112,15 +148,66 @@ const createLogger = () => {
);
}
const log = (message, type, user, record, meta) => {
winstonLogger.log({
level: type.toLowerCase(),
const log = (message, type, user, record, meta, upload) => {
const logEntry = {
level: normalizeLevel(type),
message,
user,
record,
hostname: internalHostname,
meta
});
};
const uploadLogToS3 = (logEntry, message, type, user) => {
const uniqueId = v4();
const dateTimeString = new Date().toISOString().replace(/:/g, "-");
const envName = process.env?.NODE_ENV ? process.env.NODE_ENV : "";
const logStreamName = `${envName}-${internalHostname}-${dateTimeString}-${uniqueId}.json`;
const logString = JSON.stringify(logEntry);
const webPath = isLocal
? `https://${S3_BUCKET_NAME}.s3.localhost.localstack.cloud:4566/${logStreamName}`
: `https://${S3_BUCKET_NAME}.s3.${region}.amazonaws.com/${logStreamName}`;
uploadFileToS3({ bucketName: S3_BUCKET_NAME, key: logStreamName, content: logString })
.then(() => {
log("A log file has been uploaded to S3", "info", "S3", null, {
logStreamName,
webPath,
message: message?.slice(0, 200),
type,
user
});
})
.catch((err) => {
log("Error in S3 Upload", "error", "S3", null, {
logStreamName,
webPath,
message: message?.slice(0, 100),
type,
user,
errorMessage: err?.message?.slice(0, 100)
});
});
};
const checkAndUploadLog = () => {
const estimatedSize = estimateLogSize(logEntry);
if (estimatedSize > LOG_LENGTH_LIMIT * 0.9 || estimatedSize > LOG_LENGTH_LIMIT) {
uploadLogToS3(logEntry, message, type, user);
return true;
}
return false;
};
// Upload log immediately if upload is true, otherwise check the log size.
if (upload) {
uploadLogToS3(logEntry, message, type, user);
return;
}
if (checkAndUploadLog()) return;
winstonLogger.log(logEntry);
};
return {
@@ -131,7 +218,8 @@ const createLogger = () => {
console.error("Error setting up enhanced Logger, defaulting to console.: " + e?.message || "");
return {
log: console.log,
logger: console.log
logger: console.log,
LOG_LEVELS
};
}
};

109
server/utils/s3.js Normal file
View File

@@ -0,0 +1,109 @@
const {
S3Client,
PutObjectCommand,
GetObjectCommand,
ListObjectsV2Command,
DeleteObjectCommand,
CopyObjectCommand
} = require("@aws-sdk/client-s3");
const { defaultProvider } = require("@aws-sdk/credential-provider-node");
const { InstanceRegion } = require("./instanceMgr");
const { isString, isEmpty } = require("lodash");
const createS3Client = () => {
const S3Options = {
region: InstanceRegion(),
credentials: defaultProvider()
};
const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME);
if (isLocal) {
S3Options.endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:4566`;
S3Options.forcePathStyle = true; // Needed for LocalStack to avoid bucket name as hostname
}
const s3Client = new S3Client(S3Options);
/**
* Uploads a file to the specified S3 bucket and key.
*/
const uploadFileToS3 = async ({ bucketName, key, content, contentType }) => {
const params = {
Bucket: bucketName,
Key: key,
Body: content,
ContentType: contentType ?? "application/json"
};
const command = new PutObjectCommand(params);
return await s3Client.send(command);
};
/**
* Downloads a file from the specified S3 bucket and key.
*/
const downloadFileFromS3 = async ({ bucketName, key }) => {
const params = { Bucket: bucketName, Key: key };
const command = new GetObjectCommand(params);
const data = await s3Client.send(command);
return data.Body;
};
/**
* Lists objects in the specified S3 bucket.
*/
const listFilesInS3Bucket = async (bucketName, prefix = "") => {
const params = { Bucket: bucketName, Prefix: prefix };
const command = new ListObjectsV2Command(params);
const data = await s3Client.send(command);
return data.Contents || [];
};
/**
* Deletes a file from the specified S3 bucket and key.
*/
const deleteFileFromS3 = async ({ bucketName, key }) => {
const params = { Bucket: bucketName, Key: key };
const command = new DeleteObjectCommand(params);
return await s3Client.send(command);
};
/**
* Copies a file within S3 from a source bucket/key to a destination bucket/key.
*/
const copyFileInS3 = async ({ sourceBucket, sourceKey, destinationBucket, destinationKey }) => {
const params = {
CopySource: `/${sourceBucket}/${sourceKey}`,
Bucket: destinationBucket,
Key: destinationKey
};
const command = new CopyObjectCommand(params);
return await s3Client.send(command);
};
/**
* Checks if a file exists in the specified S3 bucket and key.
*/
const fileExistsInS3 = async ({ bucketName, key }) => {
try {
await downloadFileFromS3({ bucketName, key });
return true;
} catch (error) {
if (error.name === "NoSuchKey" || error.name === "NotFound") {
return false;
}
throw error;
}
};
return {
uploadFileToS3,
downloadFileFromS3,
listFilesInS3Bucket,
deleteFileFromS3,
copyFileInS3,
fileExistsInS3,
...s3Client
};
};
module.exports = createS3Client();

View File

@@ -1,4 +1,12 @@
const { admin } = require("../firebase/firebase-handler");
const { MARK_MESSAGES_AS_READ, GET_CONVERSATIONS, GET_CONVERSATION_DETAILS } = require("../graphql-client/queries");
const { phone } = require("phone");
const { client: gqlClient } = require("../graphql-client/graphql-client");
const queries = require("../graphql-client/queries");
const twilio = require("twilio");
const client = require("../graphql-client/graphql-client").client;
const twilioClient = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY);
const redisSocketEvents = ({
io,
@@ -113,6 +121,7 @@ const redisSocketEvents = ({
socket.on("leave-bodyshop-room", leaveBodyshopRoom);
socket.on("broadcast-to-bodyshop", broadcastToBodyshopRoom);
};
// Disconnect Events
const registerDisconnectEvents = (socket) => {
const disconnect = () => {
@@ -129,10 +138,168 @@ const redisSocketEvents = ({
socket.on("disconnect", disconnect);
};
// Messaging Events
const registerMessagingEvents = (socket) => {
const openMessaging = async (bodyshopUUID) => {
try {
const conversations = await client.request(GET_CONVERSATIONS, { bodyshopId: bodyshopUUID });
socket.emit("messaging-list", { conversations });
} catch (error) {
logger.log("error", "Failed to fetch conversations", error);
socket.emit("error", { message: "Failed to fetch conversations" });
}
};
const joinConversation = async (conversationId) => {
try {
const room = `conversation-${conversationId}`;
socket.join(room);
// Fetch conversation details and messages
const data = await client.request(GET_CONVERSATION_DETAILS, { conversationId });
socket.emit("conversation-details", data); // Send data to the client
} catch (error) {
logger.log("error", "Failed to join conversation", error);
socket.emit("error", { message: "Failed to join conversation" });
}
};
const markAsRead = async ({ conversationId, userId, imexshopid, bodyshopId }) => {
try {
await client.request(MARK_MESSAGES_AS_READ, { conversationId, userId });
// Fetch the updated unread count for this conversation
const conversations = await client.request(GET_CONVERSATIONS, { bodyshopId });
// Emit the updated unread count to all clients
const room = `conversation-${conversationId}`;
io.to(room).emit("messaging-list", { conversations });
admin.messaging().send({
topic: `${imexshopid}-messaging`,
data: {
type: "messaging-mark-conversation-read",
conversationid: conversationId || ""
}
});
} catch (error) {
logger.log("Failed to mark messages as read", "error", null, null, {
message: error.message,
stack: error.stack
});
socket.emit("error", { message: "Failed to mark messages as read" });
}
};
const sendMessage = (data) => {
const { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid, user } = data;
logger.log("sms-outbound", "DEBUG", user.email, null, {
messagingServiceSid: messagingServiceSid,
to: phone(to).phoneNumber,
mediaUrl: selectedMedia.map((i) => i.src),
text: body,
conversationid,
isoutbound: true,
userid: user.email,
image: selectedMedia?.length > 0,
image_path: selectedMedia?.length > 0 ? selectedMedia.map((i) => i.src) : []
});
if (!!to && !!messagingServiceSid && (!!body || !!selectedMedia?.length > 0) && !!conversationid) {
twilioClient.messages
.create({
body: body,
messagingServiceSid: messagingServiceSid,
to: phone(to).phoneNumber,
mediaUrl: selectedMedia.map((i) => i.src)
})
.then((message) => {
let newMessage = {
msid: message.sid,
text: body,
conversationid,
isoutbound: true,
userid: user.email,
image: selectedMedia?.length > 0,
image_path: selectedMedia?.length > 0 ? selectedMedia.map((i) => i.src) : []
};
gqlClient
.request(queries.INSERT_MESSAGE, { msg: newMessage, conversationid })
.then((r2) => {
logger.log("sms-outbound-success", "DEBUG", user.email, null, {
msid: message.sid,
conversationid
});
const data = {
type: "messaging-outbound",
conversationid: newMessage.conversationid || ""
};
// TODO Verify
// const messageData = response.insert_messages.returning[0];
// Broadcast new message to conversation room
const room = `conversation-${conversationid}`;
io.to(room).emit("new-message", newMessage);
admin.messaging().send({
topic: `${imexshopid}-messaging`,
data
});
})
.catch((e2) => {
logger.log("sms-outbound-error", "ERROR", user.email, null, {
msid: message.sid,
conversationid,
error: e2
});
});
})
.catch((e1) => {
logger.log("sms-outbound-error", "ERROR", user.email, null, {
conversationid,
error: e1
});
});
} else {
logger.log("sms-outbound-error", "ERROR", user.email, null, {
type: "missing-parameters",
messagingServiceSid: messagingServiceSid,
to: phone(to).phoneNumber,
text: body,
conversationid,
isoutbound: true,
userid: user.email,
image: selectedMedia?.length > 0,
image_path: selectedMedia?.length > 0 ? selectedMedia.map((i) => i.src) : []
});
}
};
const leaveConversation = (conversationId) => {
try {
const room = `conversation-${conversationId}`;
socket.leave(room);
// Optionally notify the client
socket.emit("conversation-left", { conversationId });
} catch (error) {
socket.emit("error", { message: "Failed to leave conversation" });
}
};
socket.on("leave-conversation", leaveConversation);
socket.on("send-message", sendMessage);
socket.on("mark-as-read", markAsRead);
socket.on("join-conversation", joinConversation);
socket.on("open-messaging", openMessaging);
};
// Call Handlers
registerRoomAndBroadcastEvents(socket);
registerUpdateEvents(socket);
registerMessagingEvents(socket);
registerDisconnectEvents(socket);
};

View File

@@ -155,10 +155,17 @@ function createJsonEvent(socket, level, message, json) {
message
});
}
logger.log("ws-log-event-json", level, socket.user.email, socket.recordid, {
wsmessage: message,
json
});
logger.log(
"ws-log-event-json",
level,
socket.user.email,
socket.recordid,
{
wsmessage: message,
json
},
true
);
if (socket.logEvents && isArray(socket.logEvents)) {
socket.logEvents.push({
@@ -189,7 +196,8 @@ function createXmlEvent(socket, xml, message, isError = false) {
{
wsmessage: message,
xml
}
},
true
);
if (socket.logEvents && isArray(socket.logEvents)) {
@@ -212,7 +220,7 @@ function LogLevelHierarchy(level) {
return 4;
case "INFO":
return 3;
case "WARNING":
case "WARN":
return 2;
case "ERROR":
return 1;

2
upload/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore