Compare commits

...

104 Commits

Author SHA1 Message Date
Patrick Fic
83a1952880 IO-3051 Replace inlince css with juice. 2024-12-05 15:30:37 -08:00
Allan Carr
a885bdec74 IO-3051 canvas-handler optimization
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-12-04 14:22:04 -08:00
Dave Richer
11b906103a Merged in release/2024-11-22 (pull request #1977)
Release/2024-11-22  /  2024-11-29 - into master-AIO - IO-2920, IO-2921, IO-2959, IO-3000, IO-3001, IO-3037, IO-3040
2024-11-30 05:02:46 +00:00
Patrick Fic
3f006f431e Merged in feature/IO-3001-us-est-scrubbing (pull request #1980)
IO-3001 Update job costing label for ttl_adjustment
2024-11-29 19:56:47 +00:00
Patrick Fic
6f2b5e4c55 IO-3001 Update job costing label for ttl_adjustment 2024-11-29 11:56:18 -08:00
Patrick Fic
50d7c5dace Merged in feature/IO-3001-us-est-scrubbing (pull request #1978)
IO-3001 Add in adjustments to subtotal scrubbing.
2024-11-29 19:34:01 +00:00
Patrick Fic
9ac27b6090 IO-3001 Add in adjustments to subtotal scrubbing. 2024-11-29 11:33:19 -08:00
Dave Richer
51a1b48da9 Merge remote-tracking branch 'origin/feature/IO-3000-messaging-sockets-migrationv2' into release/2024-11-22 2024-11-28 12:27:39 -08:00
Dave Richer
648a9b8f64 feature/IO-3000-messaging-sockets-migration2 -
- Small change

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-28 12:27:06 -08:00
Dave Richer
7402679091 Merged in feature/IO-3000-messaging-sockets-migrationv2 (pull request #1974)
Feature/IO-3000 messaging sockets migrationv2
2024-11-28 20:16:43 +00:00
Dave Richer
627174b7d3 feature/IO-3000-messaging-sockets-migration2 -
- Bring back subscription for fallback

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-28 12:14:35 -08:00
Patrick Fic
9fcc01aa9f IO-3000 FInal updates to firebase SW. 2024-11-28 12:11:30 -08:00
Patrick Fic
cb46ee5700 Merged in feature/IO-3000-messaging-sockets-migrationv2 (pull request #1973)
IO-3000 update firebase js version, and add back testing route.
2024-11-28 19:41:05 +00:00
Patrick Fic
43bf1fc8cf IO-3000 update firebase js version, and add back testing route. 2024-11-28 11:39:17 -08:00
Patrick Fic
73af18f287 Merged in feature/IO-3000-messaging-sockets-migrationv2 (pull request #1970)
IO-3000 Add back FCM notification subscribe
2024-11-28 19:06:17 +00:00
Patrick Fic
90f4977924 IO-3000 Add back FCM notification subscribe 2024-11-28 11:05:50 -08:00
Dave Richer
c3b184d17b Merged in feature/IO-3000-messaging-sockets-migrationv2 (pull request #1968)
Feature/IO-3000 messaging sockets migrationv2
2024-11-28 18:02:13 +00:00
Dave Richer
db5740d487 feature/IO-3000-messaging-sockets-migration2 -
- Various work

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-28 09:57:35 -08:00
Dave Richer
08c0da1bed feature/IO-3000-messaging-sockets-migration2 -
- Various work

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-28 09:10:23 -08:00
Allan Carr
4d35976241 Merged in feature/IO-3040-Report-Selector-Date-Range-Restriction (pull request #1965)
IO-3040 Report Selector Date Range Restriction for Prod

Approved-by: Patrick Fic
2024-11-28 15:56:25 +00:00
Allan Carr
5edbed3f0b Merged in feature/IO-3001-us-est-scrubbing (pull request #1966)
IO-3001 Correct Commenting of Button
2024-11-28 15:55:59 +00:00
Allan Carr
3d79be06de IO-3001 Correct Commenting of Button
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-27 18:02:28 -08:00
Allan Carr
fd9e7b4d4b IO-3040 Report Selector Date Range Restriction for Prod
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-27 16:24:10 -08:00
Dave Richer
2937a07379 Merged in feature/IO-3000-messaging-sockets-migrationv2 (pull request #1963)
feature/IO-3000-messaging-sockets-migration2 -
2024-11-27 22:09:27 +00:00
Dave Richer
ad1761096a feature/IO-3000-messaging-sockets-migration2 -
- found small thing

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-27 14:08:52 -08:00
Patrick Fic
6a7548d11b Merged in feature/IO-2920-cash-discounting (pull request #1962)
IO-2920 Rever test URL to correct value for intellipay.
2024-11-27 21:17:56 +00:00
Patrick Fic
affbb3f168 IO-2920 Rever test URL to correct value for intellipay. 2024-11-27 13:15:03 -08:00
Dave Richer
0522747b49 Merged in feature/IO-3000-messaging-sockets-migrationv2 (pull request #1960)
Feature/IO-3000 messaging sockets migrationv2
2024-11-27 19:36:29 +00:00
Dave Richer
aec7b40ae2 feature/IO-3000-messaging-sockets-migration2 -
-misc

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-27 11:35:28 -08:00
Dave Richer
54d319f1e8 Merge remote-tracking branch 'origin/release/2024-11-22' into feature/IO-3000-messaging-sockets-migrationv2 2024-11-27 11:30:39 -08:00
Dave Richer
8d6fba2b61 feature/IO-3000-messaging-sockets-migration2 -
-misc

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-27 11:27:34 -08:00
Dave Richer
70c31eae9e feature/IO-3000-messaging-sockets-migration2 -
- [EXISTING BUG?] The updated at timeframes do not automatically update as time passes. If you receive a message, and it is changed to a few seconds ago, if you wait a few minutes, it does not change unless you interact with it forcing a re-render. This can be solved by adding a tick state and periodically refreshing - unsure of the performance impact for many elements however.

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-27 10:21:53 -08:00
Dave Richer
5e871b024d feature/IO-3000-messaging-sockets-migration2 -
- Polling Mode indicator now pegged to socket.connected

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-27 10:13:38 -08:00
Dave Richer
eb1786d634 Merged in feature/IO-2959-crisp-status-page (pull request #1958)
IO-2959 Remove debug for crisp status and add sig term handler.
2024-11-27 18:03:02 +00:00
Patrick Fic
5e8d0fddbd IO-2959 Remove debug for crisp status and add sig term handler. 2024-11-27 09:44:42 -08:00
Allan Carr
5d690fd71f Merged in feature/IO-3040-Report-Selector-Date-Range-Restriction (pull request #1956)
Feature/IO-3040 Report Selector Date Range Restriction

Approved-by: Dave Richer
2024-11-27 16:48:07 +00:00
Allan Carr
79a2d902cd Merged in feature/IO-3037-Supplement-Existing-Lines (pull request #1957)
IO-3037 Supplement Existing Lines

Approved-by: Dave Richer
2024-11-27 16:47:52 +00:00
Allan Carr
77f340d08c IO-3037 Supplement Existing Lines
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-27 08:40:55 -08:00
Allan Carr
0770e7b50d IO-3040 Re-add Translations after Merge from Release
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-26 16:12:03 -08:00
Allan Carr
3147212b7b Merge branch 'release/2024-11-22' into feature/IO-3040-Report-Selector-Date-Range-Restriction
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>

# Conflicts:
#	client/src/translations/en_us/common.json
#	client/src/translations/es/common.json
#	client/src/translations/fr/common.json
2024-11-26 16:10:03 -08:00
Allan Carr
24cc9762b2 IO-3040 Report Selector Date Range Restriction
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-26 15:04:39 -08:00
Dave Richer
2d5153da5b Merged in feature/IO-3000-messaging-sockets-migrationv2 (pull request #1948)
Feature/IO-3000 messaging sockets migrationv2 - Enhanced Socket Migrations

Approved-by: Patrick Fic
2024-11-26 18:48:17 +00:00
Dave Richer
083534c3f3 Merge branch 'feature/IO-3000-messaging-sockets-migrationv2' of bitbucket.org:snaptsoft/bodyshop into feature/IO-3000-messaging-sockets-migrationv2 2024-11-26 10:30:16 -08:00
Dave Richer
63397769d2 feature/IO-3000-messaging-sockets-migration2 - Take last conversation if more than one exists.
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-26 10:29:57 -08:00
Dave Richer
b5b7957b2f Merged release/2024-11-22 into feature/IO-3000-messaging-sockets-migrationv2 2024-11-26 17:56:41 +00:00
Patrick Fic
d50c73c82f Merged in feature/IO-2959-crisp-status-page (pull request #1954)
Feature/IO-2959 crisp status page
2024-11-26 16:53:57 +00:00
Patrick Fic
a4bff1a548 IO-2959 Implement logger & best practices. 2024-11-26 08:52:58 -08:00
Dave Richer
5f1daffb3e Merge branch 'feature/IO-3000-messaging-sockets-migrationv2' of bitbucket.org:snaptsoft/bodyshop into feature/IO-3000-messaging-sockets-migrationv2 2024-11-26 08:39:41 -08:00
Dave Richer
e9dfba7d31 feature/IO-3000-messaging-sockets-migration2 - Extra checks on scroll to index to prevent console warns when no messages exist in the conversation.
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-26 08:39:21 -08:00
Dave Richer
2f0838b39c Merged release/2024-11-22 into feature/IO-3000-messaging-sockets-migrationv2 2024-11-26 15:30:41 +00:00
Dave Richer
d8311c5163 feature/IO-3000-messaging-sockets-migration2 - remove unused dep
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-26 07:00:54 -08:00
Patrick Fic
62e4843d5b Merged in master-AIO (pull request #1953)
Merge Master into release to update.
2024-11-25 23:46:46 +00:00
Patrick Fic
6058bb1b8f IO-2959 Add Crisp Status Reporter to API. 2024-11-25 15:44:15 -08:00
Patrick Fic
fa6c672583 Merge branch 'feature/IO-2920-cash-discounting' into release/2024-11-22 2024-11-25 14:15:37 -08:00
Patrick Fic
cb4d4e4c2c IO-2920 Remove shop config for intellipay amounts. 2024-11-25 14:09:09 -08:00
Patrick Fic
225b57fd58 IO-2920 Update checkfee method to get CC amount based on new information. 2024-11-25 14:08:38 -08:00
Dave Richer
e1ffcba32f Merge branch 'feature/IO-3000-messaging-sockets-migrationv2' of bitbucket.org:snaptsoft/bodyshop into feature/IO-3000-messaging-sockets-migrationv2 2024-11-25 13:45:25 -08:00
Dave Richer
5c30f33dac feature/IO-3000-messaging-sockets-migration2 - Additional logging
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-25 13:45:09 -08:00
Dave Richer
13908074c6 Merged release/2024-11-22 into feature/IO-3000-messaging-sockets-migrationv2 2024-11-25 21:33:59 +00:00
Dave Richer
c3c66f9646 feature/IO-3000-messaging-sockets-migration2 - Final Modifications
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-25 12:46:10 -08:00
Dave Richer
62dd3d7e8e feature/IO-3000-messaging-sockets-migration2 - Final fixes around sync / archive / receive
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-25 11:38:59 -08:00
Patrick Fic
268b1ba9c1 IO-2920 Refactor fee discounting to use API to check. 2024-11-25 10:51:48 -08:00
Dave Richer
239c1502f9 feature/IO-3000-messaging-sockets-migration2 - Fix console warn in archive/unarchive if one query is not existent
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-25 09:57:18 -08:00
Dave Richer
457a3b2d7a feature/IO-3000-messaging-sockets-migration2 - Handle some of the PR notes
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-25 08:42:02 -08:00
Dave Richer
5e2c0f9c4a feature/IO-3000-messaging-sockets-migration2 - Handle some of the PR notes
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-25 08:38:07 -08:00
Dave Richer
cbc8665636 feature/IO-3000-messaging-sockets-migration2 - Handle some of the PR notes
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-25 08:36:33 -08:00
Allan Carr
f6506d6073 Merged in feature/IO-2921-CARSTAR-Canada-Chatter-Integration (pull request #1949)
IO-2921 Chatter Final mods and Cron Trigger

Approved-by: Dave Richer
2024-11-22 19:50:27 +00:00
Allan Carr
91de311351 IO-2921 Chatter Final mods and Cron Trigger
Signed-off-by: Allan Carr <allan.carr@thinkimex.com>
2024-11-22 10:55:19 -08:00
Dave Richer
49044e5669 feature/IO-3000-messaging-sockets-migrations2 -
- Missed a check

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-22 10:03:41 -08:00
Dave Richer
8adaa12618 feature/IO-3000-messaging-sockets-migrations2 -
- Merge release / fix conflicts

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-22 10:02:05 -08:00
Patrick Fic
36aad0f140 Merged in feature/IO-3001-us-est-scrubbing (pull request #1942)
feature/IO-3001-us-est-scrubbing
2024-11-22 17:18:47 +00:00
Patrick Fic
11ab7cd67e IO-3001 Null Coalesce for some items for better handling. 2024-11-22 09:18:09 -08:00
Dave Richer
3ab471e629 feature/IO-3000-messaging-sockets-migrations2 -
- Final fix of unread messagages

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-22 08:23:24 -08:00
Dave Richer
6504b27eca feature/IO-3000-messaging-sockets-migrations2 -
- A lot of a lot of testing....

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-21 22:14:39 -08:00
Allan Carr
d40579694f Merged in hotfix/2024-11-21 (pull request #1947)
Hotfix/2024 11 21 IO-2921 IO-3027
2024-11-22 04:41:29 +00:00
Allan Carr
e15e92c112 Merged in feature/IO-2921-CARSTAR-Canada-Chatter-Integration (pull request #1944)
IO-2921 Chatter Datapump Adjustment
2024-11-22 04:39:27 +00:00
Allan Carr
fba8cab98a Merged in feature/IO-3027-Datapumps-Refactor (pull request #1943)
IO-3027 Datapump Refactor
2024-11-22 04:39:06 +00:00
Dave Richer
141deff41e feature/IO-3000-messaging-sockets-migrations2 -
- harden openMessageByPhone

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-21 19:37:25 -08:00
Dave Richer
12ed8d3830 feature/IO-3000-messaging-sockets-migrations2 -
- dumb down archive/unarchive

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-21 19:21:51 -08:00
Dave Richer
525f795ce0 feature/IO-3000-messaging-sockets-migrations2 -
- dumb down archive/unarchive

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-21 19:17:42 -08:00
Dave Richer
38f13346e5 feature/IO-3000-messaging-sockets-migrations2 -
- testing and edge cases

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-21 18:44:38 -08:00
Dave Richer
8229e3593c feature/IO-3000-messaging-sockets-migrations2 -
- remove unused query,

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-21 17:39:05 -08:00
Dave Richer
d2e1b32557 feature/IO-3000-messaging-sockets-migrations2 -
- Checkpoint,

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-21 17:35:04 -08:00
Dave Richer
cd592b671c feature/IO-3000-messaging-sockets-migrations2 -
- Checkpoint,

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-21 15:05:52 -08:00
Dave Richer
1cdd905037 feature/IO-3000-messaging-sockets-migrations2 -
- Checkpoint, archiving works, cannot unarchive yet

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-21 13:23:28 -08:00
Dave Richer
e734da7adc feature/IO-3000-messaging-sockets-migrations2 -
- Fix Bug in import

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-21 12:04:32 -08:00
Dave Richer
12aec3e3a0 feature/IO-3000-messaging-sockets-migrations2 -
- Fix Chat Icon logger error
- Fix Socket Robustness
- added additional wss status for error
- Installed ant-design icons

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-21 12:03:01 -08:00
Dave Richer
5392659db6 feature/IO-3000-messaging-sockets-migrations2 -
- Checkpoint

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-21 11:32:43 -08:00
Dave Richer
15151cb4ac feature/IO-3000-messaging-sockets-migrations2 -
- sync send
- fix status events

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-20 19:23:35 -08:00
Dave Richer
06afd6da5b feature/IO-3000-messaging-sockets-migrations2 -
- Conversation Labels Synced
- Job Tagging Synced

Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-20 18:22:27 -08:00
Dave Richer
250faa672f feature/IO-3000-messaging-sockets-migrations2 - Updated Polling Intervals is now socket based over FCMToken based
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-20 14:53:01 -08:00
Patrick Fic
ec5258a431 Merge branch 'master-AIO' into feature/IO-3001-us-est-scrubbing 2024-11-20 14:11:14 -08:00
Patrick Fic
fbc7168bde IO-3001 branch cleanup 2024-11-20 14:03:34 -08:00
Patrick Fic
f2d9626888 IO-3001 Reverse n_ttl and g_ttl logic for audatex estimates. 2024-11-20 12:43:03 -08:00
Dave Richer
e15384d0bf feature/IO-3000-messaging-sockets-migrations2 - Everything but tagging and labels works
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-20 12:23:50 -08:00
Dave Richer
261353b511 feature/IO-3000-messaging-sockets-migrations2 - Base cleanup
Signed-off-by: Dave Richer <dave@imexsystems.ca>
2024-11-20 11:35:30 -08:00
Patrick Fic
299a675a9c IO-3000 Adjusted first approach at messaging WS changes. 2024-11-19 15:52:57 -08:00
Patrick Fic
2304e0bf02 IO-3001 Resolve towing totals issue. 2024-11-19 10:59:59 -08:00
Patrick Fic
e363dca3f0 IO-3001 Additional tax changes. 2024-11-15 10:56:07 -08:00
Patrick Fic
cb80b79e1d IO-3001 WIP CDK Adjustments. 2024-11-12 11:58:51 -08:00
Patrick Fic
94641ae01d IO-3001 Add resp. centers and begin QB testing. 2024-11-05 15:31:35 -08:00
Patrick Fic
9bb36d2223 Merge branch 'master-AIO' into feature/IO-3001-us-est-scrubbing 2024-11-05 08:52:49 -08:00
Patrick Fic
1205e71ea6 IO-3001 Add UI adjustments. 2024-11-01 19:54:49 -07:00
Patrick Fic
f8e65ada76 IO-3001 Initial adjustments to totals. 2024-10-30 14:06:00 -07:00
75 changed files with 15151 additions and 14120 deletions

View File

@@ -1,4 +1,4 @@
<babeledit_project version="1.2" be_version="2.7.1">
<babeledit_project be_version="2.7.1" version="1.2">
<!--
BabelEdit project file
@@ -20702,6 +20702,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>time</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</name>
<definition_loaded>false</definition_loaded>
@@ -21874,6 +21895,48 @@
<folder_node>
<name>columns</name>
<children>
<concept_node>
<name>average_human_readable</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>average_value</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>duration</name>
<definition_loaded>false</definition_loaded>
@@ -31809,6 +31872,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>tlos_ind</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>towin</name>
<definition_loaded>false</definition_loaded>
@@ -36358,6 +36442,27 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>total_repairs_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_sales</name>
<definition_loaded>false</definition_loaded>

View File

@@ -85,6 +85,7 @@
"web-vitals": "^3.5.2"
},
"devDependencies": {
"@ant-design/icons": "^5.5.1",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.24.7",
"@dotenvx/dotenvx": "^1.14.1",

View File

@@ -132,6 +132,7 @@
"@rollup/rollup-linux-x64-gnu": "4.6.1"
},
"devDependencies": {
"@ant-design/icons": "^5.5.1",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.24.7",
"@dotenvx/dotenvx": "^1.14.1",

View File

@@ -1,6 +1,6 @@
// Scripts for firebase and firebase messaging
importScripts("https://www.gstatic.com/firebasejs/8.2.0/firebase-app.js");
importScripts("https://www.gstatic.com/firebasejs/8.2.0/firebase-messaging.js");
importScripts("https://www.gstatic.com/firebasejs/10.14.1/firebase-app-compat.js");
importScripts("https://www.gstatic.com/firebasejs/10.14.1/firebase-messaging-compat.js");
// Initialize the Firebase app in the service worker by passing the generated config
let firebaseConfig;
@@ -14,7 +14,7 @@ switch (this.location.hostname) {
storageBucket: "imex-dev.appspot.com",
messagingSenderId: "759548147434",
appId: "1:759548147434:web:e8239868a48ceb36700993",
measurementId: "G-K5XRBVVB4S",
measurementId: "G-K5XRBVVB4S"
};
break;
case "test.imex.online":
@@ -24,7 +24,7 @@ switch (this.location.hostname) {
projectId: "imex-test",
storageBucket: "imex-test.appspot.com",
messagingSenderId: "991923618608",
appId: "1:991923618608:web:633437569cdad78299bef5",
appId: "1:991923618608:web:633437569cdad78299bef5"
// measurementId: "${config.measurementId}",
};
break;
@@ -38,7 +38,7 @@ switch (this.location.hostname) {
storageBucket: "imex-prod.appspot.com",
messagingSenderId: "253497221485",
appId: "1:253497221485:web:3c81c483b94db84b227a64",
measurementId: "G-NTWBKG2L0M",
measurementId: "G-NTWBKG2L0M"
};
}
@@ -49,8 +49,6 @@ const messaging = firebase.messaging();
messaging.onBackgroundMessage(function (payload) {
// Customize notification here
const channel = new BroadcastChannel("imex-sw-messages");
channel.postMessage(payload);
//self.registration.showNotification(notificationTitle, notificationOptions);
console.log("[firebase-messaging-sw.js] Received background message ", payload);
self.registration.showNotification(notificationTitle, notificationOptions);
});

View File

@@ -1,89 +1,50 @@
import { useApolloClient } from "@apollo/client";
import { getToken, onMessage } from "@firebase/messaging";
import { Button, notification, Space } from "antd";
import { getToken } from "@firebase/messaging";
import axios from "axios";
import React, { useEffect } from "react";
import React, { useContext, useEffect } from "react";
import { useTranslation } from "react-i18next";
import SocketContext from "../../contexts/SocketIO/socketContext";
import { messaging, requestForToken } from "../../firebase/firebase.utils";
import FcmHandler from "../../utils/fcm-handler";
import ChatPopupComponent from "../chat-popup/chat-popup.component";
import "./chat-affix.styles.scss";
import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers";
export function ChatAffixContainer({ bodyshop, chatVisible }) {
const { t } = useTranslation();
const client = useApolloClient();
const { socket } = useContext(SocketContext);
useEffect(() => {
if (!bodyshop || !bodyshop.messagingservicesid) return;
async function SubscribeToTopic() {
async function SubscribeToTopicForFCMNotification() {
try {
const r = await axios.post("/notifications/subscribe", {
await requestForToken();
await axios.post("/notifications/subscribe", {
fcm_tokens: await getToken(messaging, {
vapidKey: import.meta.env.VITE_APP_FIREBASE_PUBLIC_VAPID_KEY
}),
type: "messaging",
imexshopid: bodyshop.imexshopid
});
console.log("FCM Topic Subscription", r.data);
} catch (error) {
console.log("Error attempting to subscribe to messaging topic: ", error);
notification.open({
key: "fcm",
type: "warning",
message: t("general.errors.fcm"),
btn: (
<Space>
<Button
onClick={async () => {
await requestForToken();
SubscribeToTopic();
}}
>
{t("general.actions.tryagain")}
</Button>
<Button
onClick={() => {
const win = window.open(
"https://help.imex.online/en/article/enabling-notifications-o978xi/",
"_blank"
);
win.focus();
}}
>
{t("general.labels.help")}
</Button>
</Space>
)
});
}
}
SubscribeToTopic();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bodyshop]);
SubscribeToTopicForFCMNotification();
useEffect(() => {
function handleMessage(payload) {
FcmHandler({
client,
payload: (payload && payload.data && payload.data.data) || payload.data
});
//Register WS handlers
if (socket && socket.connected) {
registerMessagingHandlers({ socket, client });
}
let stopMessageListener, channel;
try {
stopMessageListener = onMessage(messaging, handleMessage);
channel = new BroadcastChannel("imex-sw-messages");
channel.addEventListener("message", handleMessage);
} catch (error) {
console.log("Unable to set event listeners.");
}
return () => {
stopMessageListener && stopMessageListener();
channel && channel.removeEventListener("message", handleMessage);
if (socket && socket.connected) {
unregisterMessagingHandlers({ socket });
}
};
}, [client]);
}, [bodyshop, socket, t, client]);
if (!bodyshop || !bodyshop.messagingservicesid) return <></>;

View File

@@ -0,0 +1,388 @@
import { CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
import { gql } from "@apollo/client";
const logLocal = (message, ...args) => {
if (import.meta.env.VITE_APP_IS_TEST || !import.meta.env.PROD) {
console.log(`==================== ${message} ====================`);
console.dir({ ...args });
}
};
// Utility function to enrich conversation data
const enrichConversation = (conversation, isOutbound) => ({
...conversation,
updated_at: conversation.updated_at || new Date().toISOString(),
unreadcnt: conversation.unreadcnt || 0,
archived: conversation.archived || false,
label: conversation.label || null,
job_conversations: conversation.job_conversations || [],
messages_aggregate: conversation.messages_aggregate || {
__typename: "messages_aggregate",
aggregate: {
__typename: "messages_aggregate_fields",
count: isOutbound ? 0 : 1
}
},
__typename: "conversations"
});
export const registerMessagingHandlers = ({ socket, client }) => {
if (!(socket && client)) return;
const handleNewMessageSummary = async (message) => {
const { conversationId, newConversation, existingConversation, isoutbound } = message;
logLocal("handleNewMessageSummary - Start", { message, isNew: !existingConversation });
const queryVariables = { offset: 0 };
if (!existingConversation && conversationId) {
// Attempt to read from the cache to determine if this is actually a new conversation
try {
const cachedConversation = client.cache.readFragment({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fragment: gql`
fragment ExistingConversationCheck on conversations {
id
}
`
});
if (cachedConversation) {
logLocal("handleNewMessageSummary - Existing Conversation inferred from cache", {
conversationId
});
return handleNewMessageSummary({
...message,
existingConversation: true
});
}
} catch (error) {
logLocal("handleNewMessageSummary - Cache miss", { conversationId });
}
}
// Handle new conversation
if (!existingConversation && newConversation?.phone_num) {
logLocal("handleNewMessageSummary - New Conversation", newConversation);
try {
const queryResults = client.cache.readQuery({
query: CONVERSATION_LIST_QUERY,
variables: queryVariables
});
const existingConversations = queryResults?.conversations || [];
const enrichedConversation = enrichConversation(newConversation, isoutbound);
if (!existingConversations.some((conv) => conv.id === enrichedConversation.id)) {
client.cache.modify({
id: "ROOT_QUERY",
fields: {
conversations(existingConversations = []) {
return [enrichedConversation, ...existingConversations];
}
}
});
}
} catch (error) {
console.error("Error updating cache for new conversation:", error);
}
return;
}
// Handle existing conversation
if (existingConversation) {
try {
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
updated_at: () => new Date().toISOString(),
archived: () => false,
messages_aggregate(cached = { aggregate: { count: 0 } }) {
const currentCount = cached.aggregate?.count || 0;
if (!isoutbound) {
return {
__typename: "messages_aggregate",
aggregate: {
__typename: "messages_aggregate_fields",
count: currentCount + 1
}
};
}
return cached;
}
}
});
} catch (error) {
console.error("Error updating cache for existing conversation:", error);
}
return;
}
logLocal("New Conversation Summary finished without work", { message });
};
const handleNewMessageDetailed = (message) => {
const { conversationId, newMessage } = message;
logLocal("handleNewMessageDetailed - Start", message);
try {
// Check if the conversation exists in the cache
const queryResults = client.cache.readQuery({
query: GET_CONVERSATION_DETAILS,
variables: { conversationId }
});
if (!queryResults?.conversations_by_pk) {
console.warn("Conversation not found in cache:", { conversationId });
return;
}
// Append the new message to the conversation's message list using cache.modify
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
messages(existingMessages = []) {
return [...existingMessages, newMessage];
}
}
});
logLocal("handleNewMessageDetailed - Message appended successfully", {
conversationId,
newMessage
});
} catch (error) {
console.error("Error updating conversation messages in cache:", error);
}
};
const handleMessageChanged = (message) => {
if (!message) {
logLocal("handleMessageChanged - No message provided", message);
return;
}
logLocal("handleMessageChanged - Start", message);
try {
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: message.conversationid }),
fields: {
messages(existingMessages = [], { readField }) {
return existingMessages.map((messageRef) => {
// Check if this is the message to update
if (readField("id", messageRef) === message.id) {
const currentStatus = readField("status", messageRef);
// Handle known types of message changes
switch (message.type) {
case "status-changed":
// Prevent overwriting if the current status is already "delivered"
if (currentStatus === "delivered") {
logLocal("handleMessageChanged - Status already delivered, skipping update", {
messageId: message.id
});
return messageRef;
}
// Update the status field
return {
...messageRef,
status: message.status
};
case "text-updated":
// Handle changes to the message text
return {
...messageRef,
text: message.text
};
// Add cases for other known message types as needed
default:
// Log a warning for unhandled message types
logLocal("handleMessageChanged - Unhandled message type", { type: message.type });
return messageRef;
}
}
return messageRef; // Keep other messages unchanged
});
}
}
});
logLocal("handleMessageChanged - Message updated successfully", {
messageId: message.id,
type: message.type
});
} catch (error) {
console.error("handleMessageChanged - Error modifying cache:", error);
}
};
const handleConversationChanged = async (data) => {
if (!data) {
logLocal("handleConversationChanged - No data provided", data);
return;
}
const { conversationId, type, job_conversations, messageIds, ...fields } = data;
logLocal("handleConversationChanged - Start", data);
const updatedAt = new Date().toISOString();
const updateConversationList = (newConversation) => {
try {
const existingList = client.cache.readQuery({
query: CONVERSATION_LIST_QUERY,
variables: { offset: 0 }
});
const updatedList = existingList?.conversations
? [
newConversation,
...existingList.conversations.filter((conv) => conv.id !== newConversation.id) // Prevent duplicates
]
: [newConversation];
client.cache.writeQuery({
query: CONVERSATION_LIST_QUERY,
variables: { offset: 0 },
data: {
conversations: updatedList
}
});
logLocal("handleConversationChanged - Conversation list updated successfully", newConversation);
} catch (error) {
console.error("Error updating conversation list in the cache:", error);
}
};
// Handle specific types
try {
switch (type) {
case "conversation-marked-read":
if (conversationId && messageIds?.length > 0) {
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
messages(existingMessages = [], { readField }) {
return existingMessages.map((message) => {
if (messageIds.includes(readField("id", message))) {
return { ...message, read: true };
}
return message;
});
},
messages_aggregate: () => ({
__typename: "messages_aggregate",
aggregate: { __typename: "messages_aggregate_fields", count: 0 }
})
}
});
}
break;
case "conversation-created":
updateConversationList({ ...fields, job_conversations, updated_at: updatedAt });
break;
case "conversation-unarchived":
case "conversation-archived":
// Would like to someday figure out how to get this working without refetch queries,
// But I have but a solid 4 hours into it, and there are just too many weird occurrences
try {
const listQueryVariables = { offset: 0 };
const detailsQueryVariables = { conversationId };
// Check if conversation details exist in the cache
const detailsExist = !!client.cache.readQuery({
query: GET_CONVERSATION_DETAILS,
variables: detailsQueryVariables
});
// Refetch conversation list
await client.refetchQueries({
include: [CONVERSATION_LIST_QUERY, ...(detailsExist ? [GET_CONVERSATION_DETAILS] : [])],
variables: [
{ query: CONVERSATION_LIST_QUERY, variables: listQueryVariables },
...(detailsExist
? [
{
query: GET_CONVERSATION_DETAILS,
variables: detailsQueryVariables
}
]
: [])
]
});
logLocal("handleConversationChanged - Refetched queries after state change", {
conversationId,
type
});
} catch (error) {
console.error("Error refetching queries after conversation state change:", error);
}
break;
case "tag-added":
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
job_conversations: (existing = []) => [...existing, ...job_conversations]
}
});
break;
case "tag-removed":
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
job_conversations: (existing = [], { readField }) => {
return existing.filter((jobRef) => {
// Read the `jobid` field safely, even if the structure is normalized
const jobId = readField("jobid", jobRef);
return jobId !== fields.jobId;
});
}
}
});
break;
default:
logLocal("handleConversationChanged - Unhandled type", { type });
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: conversationId }),
fields: {
...Object.fromEntries(
Object.entries(fields).map(([key, value]) => [key, (cached) => (value !== undefined ? value : cached)])
)
}
});
}
} catch (error) {
console.error("Error handling conversation changes:", { type, error });
}
};
socket.on("new-message-summary", handleNewMessageSummary);
socket.on("new-message-detailed", handleNewMessageDetailed);
socket.on("message-changed", handleMessageChanged);
socket.on("conversation-changed", handleConversationChanged);
};
export const unregisterMessagingHandlers = ({ socket }) => {
if (!socket) return;
socket.off("new-message-summary");
socket.off("new-message-detailed");
socket.off("message-changed");
socket.off("conversation-changed");
};

View File

@@ -1,27 +1,49 @@
import { useMutation } from "@apollo/client";
import { Button } from "antd";
import React, { useState } from "react";
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { TOGGLE_CONVERSATION_ARCHIVE } from "../../graphql/conversations.queries";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
export default function ChatArchiveButton({ conversation }) {
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({});
export function ChatArchiveButton({ conversation, bodyshop }) {
const [loading, setLoading] = useState(false);
const { t } = useTranslation();
const [updateConversation] = useMutation(TOGGLE_CONVERSATION_ARCHIVE);
const { socket } = useContext(SocketContext);
const handleToggleArchive = async () => {
setLoading(true);
await updateConversation({
variables: { id: conversation.id, archived: !conversation.archived },
refetchQueries: ["CONVERSATION_LIST_QUERY"]
const updatedConversation = await updateConversation({
variables: { id: conversation.id, archived: !conversation.archived }
});
if (socket) {
socket.emit("conversation-modified", {
type: "conversation-archived",
conversationId: conversation.id,
bodyshopId: bodyshop.id,
archived: updatedConversation.data.update_conversations_by_pk.archived
});
}
setLoading(false);
};
return (
<Button onClick={handleToggleArchive} loading={loading} type="primary">
<Button onClick={handleToggleArchive} loading={loading} className="archive-button" type="primary">
{conversation.archived ? t("messaging.labels.unarchive") : t("messaging.labels.archive")}
</Button>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatArchiveButton);

View File

@@ -1,7 +1,7 @@
import { Badge, Card, List, Space, Tag } from "antd";
import React from "react";
import React, { useEffect, useState } 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";
@@ -19,19 +19,26 @@ const mapDispatchToProps = (dispatch) => ({
setSelectedConversation: (conversationId) => dispatch(setSelectedConversation(conversationId))
});
function ChatConversationListComponent({
conversationList,
selectedConversation,
setSelectedConversation,
loadMoreConversations
}) {
const cache = new CellMeasurerCache({
fixedWidth: true,
defaultHeight: 60
});
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) {
// That comma is there for a reason, do not remove it
const [, forceUpdate] = useState(false);
const rowRenderer = ({ index, key, style, parent }) => {
const item = conversationList[index];
// Re-render every minute
useEffect(() => {
const interval = setInterval(() => {
forceUpdate((prev) => !prev); // Toggle state to trigger re-render
}, 60000); // 1 minute in milliseconds
return () => clearInterval(interval); // Cleanup on unmount
}, []);
// Memoize the sorted conversation list
const sortedConversationList = React.useMemo(() => {
return _.orderBy(conversationList, ["updated_at"], ["desc"]);
}, [conversationList]);
const renderConversation = (index) => {
const item = sortedConversationList[index];
const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
const cardContentLeft =
item.job_conversations.length > 0
@@ -52,7 +59,8 @@ function ChatConversationListComponent({
)}
</>
);
const cardExtra = <Badge count={item.messages_aggregate.aggregate.count || 0} />;
const cardExtra = <Badge count={item.messages_aggregate.aggregate.count} />;
const getCardStyle = () =>
item.id === selectedConversation
@@ -60,40 +68,26 @@ 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={sortedConversationList}
itemContent={(index) => renderConversation(index)}
style={{ height: "100%", width: "100%" }}
/>
</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,18 +1,29 @@
import { useMutation } from "@apollo/client";
import { Tag } from "antd";
import React from "react";
import React, { useContext } from "react";
import { Link } from "react-router-dom";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { REMOVE_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
export default function ChatConversationTitleTags({ jobConversations }) {
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({});
export function ChatConversationTitleTags({ jobConversations, bodyshop }) {
const [removeJobConversation] = useMutation(REMOVE_CONVERSATION_TAG);
const { socket } = useContext(SocketContext);
const handleRemoveTag = (jobId) => {
const handleRemoveTag = async (jobId) => {
const convId = jobConversations[0].conversationid;
if (!!convId) {
removeJobConversation({
await removeJobConversation({
variables: {
conversationId: convId,
jobId: jobId
@@ -28,6 +39,17 @@ export default function ChatConversationTitleTags({ jobConversations }) {
});
}
});
if (socket) {
// Emit the `conversation-modified` event
socket.emit("conversation-modified", {
bodyshopId: bodyshop.id,
conversationId: convId,
type: "tag-removed",
jobId: jobId
});
}
logImEXEvent("messaging_remove_job_tag", {
conversationId: convId,
jobId: jobId
@@ -54,3 +76,5 @@ export default function ChatConversationTitleTags({ jobConversations }) {
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatConversationTitleTags);

View File

@@ -6,10 +6,16 @@ import ChatConversationTitleTags from "../chat-conversation-title-tags/chat-conv
import ChatLabelComponent from "../chat-label/chat-label.component";
import ChatPrintButton from "../chat-print-button/chat-print-button.component";
import ChatTagRoContainer from "../chat-tag-ro/chat-tag-ro.container";
import { createStructuredSelector } from "reselect";
import { connect } from "react-redux";
export default function ChatConversationTitle({ conversation }) {
const mapStateToProps = createStructuredSelector({});
const mapDispatchToProps = () => ({});
export function ChatConversationTitle({ conversation }) {
return (
<Space wrap>
<Space className="chat-title" wrap>
<PhoneNumberFormatter>{conversation && conversation.phone_num}</PhoneNumberFormatter>
<ChatLabelComponent conversation={conversation} />
<ChatPrintButton conversation={conversation} />
@@ -19,3 +25,5 @@ export default function ChatConversationTitle({ conversation }) {
</Space>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatConversationTitle);

View File

@@ -5,10 +5,26 @@ import ChatMessageListComponent from "../chat-messages-list/chat-message-list.co
import ChatSendMessage from "../chat-send-message/chat-send-message.component";
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component.jsx";
import "./chat-conversation.styles.scss";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
export default function ChatConversationComponent({ subState, conversation, messages, handleMarkConversationAsRead }) {
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({});
export function ChatConversationComponent({
subState,
conversation,
messages,
handleMarkConversationAsRead,
bodyshop
}) {
const [loading, error] = subState;
if (conversation?.archived) return null;
if (loading) return <LoadingSkeleton />;
if (error) return <AlertComponent message={error.message} type="error" />;
@@ -18,9 +34,11 @@ export default function ChatConversationComponent({ subState, conversation, mess
onMouseDown={handleMarkConversationAsRead}
onKeyDown={handleMarkConversationAsRead}
>
<ChatConversationTitle conversation={conversation} />
<ChatConversationTitle conversation={conversation} bodyshop={bodyshop} />
<ChatMessageListComponent messages={messages} />
<ChatSendMessage conversation={conversation} />
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatConversationComponent);

View File

@@ -1,22 +1,25 @@
import { useMutation, useQuery, useSubscription } from "@apollo/client";
import React, { useState } from "react";
import { gql, useApolloClient, useQuery, useSubscription } from "@apollo/client";
import axios from "axios";
import React, { useCallback, 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 SocketContext from "../../contexts/SocketIO/socketContext";
import { GET_CONVERSATION_DETAILS, CONVERSATION_SUBSCRIPTION_BY_PK } from "../../graphql/conversations.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";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,
bodyshop: selectBodyshop
});
export default connect(mapStateToProps, null)(ChatConversationContainer);
function ChatConversationContainer({ bodyshop, selectedConversation }) {
const client = useApolloClient();
const { socket } = useContext(SocketContext);
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
export function ChatConversationContainer({ bodyshop, selectedConversation }) {
// Fetch conversation details
const {
loading: convoLoading,
error: convoError,
@@ -27,55 +30,145 @@ export function ChatConversationContainer({ bodyshop, selectedConversation }) {
nextFetchPolicy: "network-only"
});
const { loading, error, data } = useSubscription(CONVERSATION_SUBSCRIPTION_BY_PK, {
variables: { conversationId: selectedConversation }
});
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
const [markConversationRead] = useMutation(MARK_MESSAGES_AS_READ_BY_CONVERSATION, {
// Subscription for conversation updates
useSubscription(CONVERSATION_SUBSCRIPTION_BY_PK, {
skip: socket?.connected,
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 } };
onData: ({ data: subscriptionResult, client }) => {
// Extract the messages array from the result
const messages = subscriptionResult?.data?.messages;
if (!messages || messages.length === 0) {
console.warn("No messages found in subscription result.");
return;
}
messages.forEach((message) => {
const messageRef = client.cache.identify(message);
// Write the new message to the cache
client.cache.writeFragment({
id: messageRef,
fragment: gql`
fragment NewMessage on messages {
id
status
text
isoutbound
image
image_path
userid
created_at
read
}
`,
data: message
});
// Update the conversation cache to include the new message
client.cache.modify({
id: client.cache.identify({ __typename: "conversations", id: selectedConversation }),
fields: {
messages(existingMessages = []) {
const alreadyExists = existingMessages.some((msg) => msg.__ref === messageRef);
if (alreadyExists) return existingMessages;
return [...existingMessages, { __ref: messageRef }];
},
updated_at() {
return message.created_at;
}
}
}
});
});
}
});
const unreadCount =
data &&
data.messages &&
data.messages.reduce((acc, val) => {
return !val.read && !val.isoutbound ? acc + 1 : acc;
}, 0);
const updateCacheWithReadMessages = useCallback(
(conversationId, messageIds) => {
if (!conversationId || !messageIds?.length) return;
const handleMarkConversationAsRead = async () => {
if (unreadCount > 0 && !!selectedConversation && !markingAsReadInProgress) {
setMarkingAsReadInProgress(true);
await markConversationRead({});
await axios.post("/sms/markConversationRead", {
conversationid: selectedConversation,
imexshopid: bodyshop.imexshopid
messageIds.forEach((messageId) => {
client.cache.modify({
id: client.cache.identify({ __typename: "messages", id: messageId }),
fields: {
read: () => true
}
});
});
setMarkingAsReadInProgress(false);
},
[client.cache]
);
// WebSocket event handlers
useEffect(() => {
if (!socket?.connected) return;
const handleConversationChange = (data) => {
if (data.type === "conversation-marked-read") {
const { conversationId, messageIds } = data;
updateCacheWithReadMessages(conversationId, messageIds);
}
};
socket.on("conversation-changed", handleConversationChange);
return () => {
socket.off("conversation-changed", handleConversationChange);
};
}, [socket, updateCacheWithReadMessages]);
// Join and leave conversation via WebSocket
useEffect(() => {
if (!socket?.connected || !selectedConversation || !bodyshop?.id) return;
socket.emit("join-bodyshop-conversation", {
bodyshopId: bodyshop.id,
conversationId: selectedConversation
});
return () => {
socket.emit("leave-bodyshop-conversation", {
bodyshopId: bodyshop.id,
conversationId: selectedConversation
});
};
}, [socket, bodyshop, selectedConversation]);
// Mark conversation as read
const handleMarkConversationAsRead = async () => {
if (!convoData || markingAsReadInProgress) return;
const conversation = convoData.conversations_by_pk;
if (!conversation) return;
const unreadMessageIds = conversation.messages
?.filter((message) => !message.read && !message.isoutbound)
.map((message) => message.id);
if (unreadMessageIds?.length > 0) {
setMarkingAsReadInProgress(true);
try {
await axios.post("/sms/markConversationRead", {
conversation,
imexshopid: bodyshop?.imexshopid,
bodyshopid: bodyshop?.id
});
updateCacheWithReadMessages(selectedConversation, unreadMessageIds);
} catch (error) {
console.error("Error marking conversation as read:", error.message);
} finally {
setMarkingAsReadInProgress(false);
}
}
};
return (
<ChatConversationComponent
subState={[loading || convoLoading, error || convoError]}
conversation={convoData ? convoData.conversations_by_pk : {}}
messages={data ? data.messages : []}
subState={[convoLoading, convoError]}
conversation={convoData?.conversations_by_pk || {}}
messages={convoData?.conversations_by_pk?.messages || []}
handleMarkConversationAsRead={handleMarkConversationAsRead}
/>
);
}
export default connect(mapStateToProps)(ChatConversationContainer);

View File

@@ -1,14 +1,25 @@
import { PlusOutlined } from "@ant-design/icons";
import { useMutation } from "@apollo/client";
import { Input, notification, Spin, Tag, Tooltip } from "antd";
import React, { useState } from "react";
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { UPDATE_CONVERSATION_LABEL } from "../../graphql/conversations.queries";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
export default function ChatLabel({ conversation }) {
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({});
export function ChatLabel({ conversation, bodyshop }) {
const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(conversation.label);
const { socket } = useContext(SocketContext);
const { t } = useTranslation();
const [updateLabel] = useMutation(UPDATE_CONVERSATION_LABEL);
@@ -26,6 +37,14 @@ export default function ChatLabel({ conversation }) {
})
});
} else {
if (socket) {
socket.emit("conversation-modified", {
type: "label-updated",
conversationId: conversation.id,
bodyshopId: bodyshop.id,
label: value
});
}
setEditing(false);
}
} catch (error) {
@@ -57,3 +76,5 @@ export default function ChatLabel({ conversation }) {
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatLabel);

View File

@@ -1,106 +1,87 @@
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 { MdDone, MdDoneAll } from "react-icons/md";
import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from "react-virtualized";
import { DateTimeFormatter } from "../../utils/DateFormatter";
import React, { useEffect, useRef, useState } from "react";
import { Virtuoso } from "react-virtuoso";
import { renderMessage } from "./renderMessage";
import "./chat-message-list.styles.scss";
export default function ChatMessageListComponent({ messages }) {
const virtualizedListRef = useRef(null);
const virtuosoRef = useRef(null);
const [atBottom, setAtBottom] = useState(true);
const loadedImagesRef = useRef(0);
const _cache = new CellMeasurerCache({
fixedWidth: true,
// minHeight: 50,
defaultHeight: 100
});
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.
const handleScrollStateChange = (isAtBottom) => {
setAtBottom(isAtBottom);
};
useEffect(scrollToBottom, [messages]);
const _rowRenderer = ({ index, key, parent, style }) => {
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>
{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>
)}
</div>
)}
</CellMeasurer>
);
const resetImageLoadState = () => {
loadedImagesRef.current = 0;
};
const preloadImages = (imagePaths, onComplete) => {
resetImageLoadState();
if (imagePaths.length === 0) {
onComplete();
return;
}
imagePaths.forEach((url) => {
const img = new Image();
img.src = url;
img.onload = img.onerror = () => {
loadedImagesRef.current += 1;
if (loadedImagesRef.current === imagePaths.length) {
onComplete();
}
};
});
};
// Ensure all images are loaded on initial render
useEffect(() => {
const imagePaths = messages
.filter((message) => message.image && message.image_path?.length > 0)
.flatMap((message) => message.image_path);
preloadImages(imagePaths, () => {
if (virtuosoRef.current) {
virtuosoRef.current.scrollToIndex({
index: messages.length - 1,
align: "end",
behavior: "auto"
});
}
});
}, [messages]);
// Handle scrolling when new messages are added
useEffect(() => {
if (!atBottom) return;
const latestMessage = messages[messages.length - 1];
const imagePaths = latestMessage?.image_path || [];
preloadImages(imagePaths, () => {
if (virtuosoRef.current) {
virtuosoRef.current.scrollToIndex({
index: messages.length - 1,
align: "end",
behavior: "smooth"
});
}
});
}, [messages, atBottom]);
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}
overscan={!!messages.reduce((acc, message) => acc + (message.image_path?.length || 0), 0) ? messages.length : 0}
itemContent={(index) => renderMessage(messages, index)}
followOutput={(isAtBottom) => handleScrollStateChange(isAtBottom)}
initialTopMostItemIndex={messages.length - 1}
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,119 +1,131 @@
.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;
height: 100%;
width: 100%;
}
.archive-button {
height: 20px;
border-radius: 4px;
}
.chat-title {
margin-bottom: 5px;
}
.messages {
//margin-top: 30px;
display: flex;
flex-direction: column;
padding: 0.5rem; // Prevent edge clipping
}
.message {
position: relative;
border-radius: 20px;
padding: 0.25rem 0.8rem;
//margin-top: 5px;
// margin-bottom: 5px;
//display: inline-block;
word-wrap: break-word;
.message-img {
&-img {
max-width: 10rem;
max-height: 10rem;
object-fit: contain;
margin: 0.2rem;
border-radius: 4px;
}
&-images {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
.yours {
align-items: flex-start;
.chat-send-message-button{
margin: 0.3rem;
padding-left: 0.5rem;
}
.message-icon {
position: absolute;
bottom: 0.1rem;
right: 0.3rem;
margin: 0 0.1rem;
color: whitesmoke;
z-index: 5;
}
.msgmargin {
margin-top: 0.1rem;
margin-bottom: 0.1rem;
margin: 0.1rem 0;
}
.yours .message {
margin-right: 20%;
background-color: #eee;
position: relative;
.yours,
.mine {
display: flex;
flex-direction: column;
.message {
position: relative;
&:last-child:before,
&:last-child:after {
content: "";
position: absolute;
bottom: 0;
height: 20px;
width: 20px;
z-index: 0;
}
&:last-child:after {
width: 10px;
background: white;
z-index: 1;
}
}
}
.yours .message.last:before {
content: "";
position: absolute;
z-index: 0;
bottom: 0;
left: -7px;
height: 20px;
width: 20px;
background: #eee;
border-bottom-right-radius: 15px;
}
.yours .message.last:after {
content: "";
position: absolute;
z-index: 1;
bottom: 0;
left: -10px;
width: 10px;
height: 20px;
background: white;
border-bottom-right-radius: 10px;
/* "Yours" (incoming) message styles */
.yours {
align-items: flex-start;
.message {
margin-right: 20%;
background-color: #eee;
&:last-child:before {
left: -7px;
background: #eee;
border-bottom-right-radius: 15px;
}
&:last-child:after {
left: -10px;
border-bottom-right-radius: 10px;
}
}
}
/* "Mine" (outgoing) message styles */
.mine {
align-items: flex-end;
.message {
color: white;
margin-left: 25%;
background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%);
padding-bottom: 0.6rem;
&:last-child:before {
right: -8px;
background: linear-gradient(to bottom, #00d0ea 0%, #0085d1 100%);
border-bottom-left-radius: 15px;
}
&:last-child:after {
right: -10px;
border-bottom-left-radius: 10px;
}
}
}
.mine .message {
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 {
content: "";
position: absolute;
z-index: 0;
bottom: 0;
right: -8px;
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 {
content: "";
position: absolute;
z-index: 1;
bottom: 0;
right: -10px;
width: 10px;
height: 20px;
background: white;
border-bottom-left-radius: 10px;
.virtuoso-container {
flex: 1;
overflow: auto;
}

View File

@@ -0,0 +1,52 @@
import Icon from "@ant-design/icons";
import { Tooltip } from "antd";
import i18n from "i18next";
import dayjs from "../../utils/day";
import { MdDone, MdDoneAll } from "react-icons/md";
import { DateTimeFormatter } from "../../utils/DateFormatter";
export const renderMessage = (messages, index) => {
const message = messages[index];
return (
<div key={index} className={`${message.isoutbound ? "mine messages" : "yours messages"}`}>
<div className="message msgmargin">
<Tooltip title={DateTimeFormatter({ children: message.created_at })}>
<div>
{/* Render images if available */}
{message.image && message.image_path?.length > 0 && (
<div className="message-images">
{message.image_path.map((url, idx) => (
<div key={idx} style={{ display: "flex", justifyContent: "center" }}>
<a href={url} target="_blank" rel="noopener noreferrer">
<img alt="Received" className="message-img" src={url} />
</a>
</div>
))}
</div>
)}
{/* Render text if available */}
{message.text && <div>{message.text}</div>}
</div>
</Tooltip>
{/* Message status icons */}
{message.status && (message.status === "sent" || message.status === "delivered") && (
<div className="message-status">
<Icon component={message.status === "sent" ? MdDone : MdDoneAll} className="message-icon" />
</div>
)}
</div>
{/* Outbound message metadata */}
{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>
)}
</div>
);
};

View File

@@ -1,11 +1,12 @@
import { PlusCircleFilled } from "@ant-design/icons";
import { Button, Form, Popover } from "antd";
import React from "react";
import React, { useContext } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
@@ -17,8 +18,10 @@ const mapDispatchToProps = (dispatch) => ({
export function ChatNewConversation({ openChatByPhone }) {
const { t } = useTranslation();
const [form] = Form.useForm();
const { socket } = useContext(SocketContext);
const handleFinish = (values) => {
openChatByPhone({ phone_num: values.phoneNumber });
openChatByPhone({ phone_num: values.phoneNumber, socket });
form.resetFields();
};

View File

@@ -1,6 +1,6 @@
import { notification } from "antd";
import parsePhoneNumber from "libphonenumber-js";
import React from "react";
import React, { useContext } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
@@ -9,6 +9,7 @@ import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { searchingForConversation } from "../../redux/messaging/messaging.selectors";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -21,6 +22,8 @@ const mapDispatchToProps = (dispatch) => ({
export function ChatOpenButton({ bodyshop, searchingForConversation, phone, jobid, openChatByPhone }) {
const { t } = useTranslation();
const { socket } = useContext(SocketContext);
if (!phone) return <></>;
if (!bodyshop.messagingservicesid) return <PhoneNumberFormatter>{phone}</PhoneNumberFormatter>;
@@ -33,7 +36,7 @@ export function ChatOpenButton({ bodyshop, searchingForConversation, phone, jobi
const p = parsePhoneNumber(phone, "CA");
if (searchingForConversation) return; //This is to prevent finding the same thing twice.
if (p && p.isValid()) {
openChatByPhone({ phone_num: p.formatInternational(), jobid: jobid });
openChatByPhone({ phone_num: p.formatInternational(), jobid: jobid, socket });
} else {
notification["error"]({ message: t("messaging.error.invalidphone") });
}

View File

@@ -1,11 +1,11 @@
import { InfoCircleOutlined, MessageOutlined, ShrinkOutlined, SyncOutlined } from "@ant-design/icons";
import { useLazyQuery, useQuery } from "@apollo/client";
import { useApolloClient, useLazyQuery } from "@apollo/client";
import { Badge, Card, Col, Row, Space, Tag, Tooltip, Typography } from "antd";
import React, { useCallback, useEffect, useState } from "react";
import React, { useContext, useEffect, useState } 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 { CONVERSATION_LIST_QUERY } from "../../graphql/conversations.queries";
import { toggleChatVisible } from "../../redux/messaging/messaging.actions";
import { selectChatVisible, selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import ChatConversationListComponent from "../chat-conversation-list/chat-conversation-list.component";
@@ -13,61 +13,88 @@ import ChatConversationContainer from "../chat-conversation/chat-conversation.co
import ChatNewConversation from "../chat-new-conversation/chat-new-conversation.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import "./chat-popup.styles.scss";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,
chatVisible: selectChatVisible
});
const mapDispatchToProps = (dispatch) => ({
toggleChatVisible: () => dispatch(toggleChatVisible())
});
export function ChatPopupComponent({ chatVisible, selectedConversation, toggleChatVisible }) {
const { t } = useTranslation();
const [pollInterval, setpollInterval] = useState(0);
const [pollInterval, setPollInterval] = useState(0);
const { socket } = useContext(SocketContext);
const client = useApolloClient(); // Apollo Client instance for cache operations
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, {
// Lazy query for conversations
const [getConversations, { loading, data, refetch }] = useLazyQuery(CONVERSATION_LIST_QUERY, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
skip: !chatVisible,
...(pollInterval > 0 ? { pollInterval } : {})
});
const fcmToken = sessionStorage.getItem("fcmtoken");
// Socket connection status
useEffect(() => {
if (fcmToken) {
setpollInterval(0);
} else {
setpollInterval(90000);
}
}, [fcmToken]);
const handleSocketStatus = () => {
if (socket?.connected) {
setPollInterval(15 * 60 * 1000); // 15 minutes
} else {
setPollInterval(60 * 1000); // 60 seconds
}
};
handleSocketStatus();
if (socket) {
socket.on("connect", handleSocketStatus);
socket.on("disconnect", handleSocketStatus);
}
return () => {
if (socket) {
socket.off("connect", handleSocketStatus);
socket.off("disconnect", handleSocketStatus);
}
};
}, [socket]);
// Fetch conversations when chat becomes visible
useEffect(() => {
if (chatVisible)
getConversations({
variables: {
offset: 0
}
}).catch((err) => {
console.error(`Error fetching conversations: ${(err, err.message || "")}`);
});
}, [chatVisible, getConversations]);
const loadMoreConversations = useCallback(() => {
if (data)
fetchMore({
variables: {
offset: data.conversations.length
}
// Get unread count from the cache
const unreadCount = (() => {
try {
const cachedData = client.readQuery({
query: CONVERSATION_LIST_QUERY,
variables: { offset: 0 }
});
}, [data, fetchMore]);
const unreadCount = unreadData?.messages_aggregate.aggregate.count || 0;
if (!cachedData?.conversations) return 0;
// Aggregate unread message count
return cachedData.conversations.reduce((total, conversation) => {
const unread = conversation.messages_aggregate?.aggregate?.count || 0;
return total + unread;
}, 0);
} catch (error) {
console.warn("Unread count not found in cache:", error);
return 0; // Fallback if not in cache
}
})();
return (
<Badge count={unreadCount}>
@@ -81,7 +108,7 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
<InfoCircleOutlined />
</Tooltip>
<SyncOutlined style={{ cursor: "pointer" }} onClick={() => refetch()} />
{pollInterval > 0 && <Tag color="yellow">{t("messaging.labels.nopush")}</Tag>}
{!socket?.connected && <Tag color="yellow">{t("messaging.labels.nopush")}</Tag>}
</Space>
<ShrinkOutlined
onClick={() => toggleChatVisible()}
@@ -93,10 +120,7 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
{loading ? (
<LoadingSpinner />
) : (
<ChatConversationListComponent
conversationList={data ? data.conversations : []}
loadMoreConversations={loadMoreConversations}
/>
<ChatConversationListComponent conversationList={data ? data.conversations : []} />
)}
</Col>
<Col span={16}>{selectedConversation ? <ChatConversationContainer /> : null}</Col>

View File

@@ -25,6 +25,7 @@ const mapDispatchToProps = (dispatch) => ({
function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSending, message, setMessage }) {
const inputArea = useRef(null);
const [selectedMedia, setSelectedMedia] = useState([]);
useEffect(() => {
inputArea.current.focus();
}, [isSending, setMessage]);
@@ -37,14 +38,15 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
logImEXEvent("messaging_send_message");
if (selectedImages.length < 11) {
sendMessage({
const newMessage = {
to: conversation.phone_num,
body: message || "",
messagingServiceSid: bodyshop.messagingservicesid,
conversationid: conversation.id,
selectedMedia: selectedImages,
imexshopid: bodyshop.imexshopid
});
};
sendMessage(newMessage);
setSelectedMedia(
selectedMedia.map((i) => {
return { ...i, isSelected: false };
@@ -79,7 +81,7 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
/>
</span>
<SendOutlined
className="imex-flex-row__margin"
className="chat-send-message-button"
// disabled={message === "" || !message}
onClick={handleEnter}
/>

View File

@@ -2,22 +2,33 @@ import { PlusOutlined } from "@ant-design/icons";
import { useLazyQuery, useMutation } from "@apollo/client";
import { Tag } from "antd";
import _ from "lodash";
import React, { useState } from "react";
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import { SEARCH_FOR_JOBS } from "../../graphql/jobs.queries";
import ChatTagRo from "./chat-tag-ro.component";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
export default function ChatTagRoContainer({ conversation }) {
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({});
export function ChatTagRoContainer({ conversation, bodyshop }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { socket } = useContext(SocketContext);
const [loadRo, { loading, data }] = useLazyQuery(SEARCH_FOR_JOBS);
const executeSearch = (v) => {
logImEXEvent("messaging_search_job_tag", { searchTerm: v });
loadRo(v);
loadRo(v).catch((e) => console.error("Error in ChatTagRoContainer executeSearch:", e));
};
const debouncedExecuteSearch = _.debounce(executeSearch, 500);
@@ -30,9 +41,34 @@ export default function ChatTagRoContainer({ conversation }) {
variables: { conversationId: conversation.id }
});
const handleInsertTag = (value, option) => {
const handleInsertTag = async (value, option) => {
logImEXEvent("messaging_add_job_tag");
insertTag({ variables: { jobId: option.key } });
await insertTag({
variables: { jobId: option.key }
});
if (socket) {
// Find the job details from the search data
const selectedJob = data?.search_jobs.find((job) => job.id === option.key);
if (!selectedJob) return;
const newJobConversation = {
__typename: "job_conversations",
jobid: selectedJob.id,
conversationid: conversation.id,
job: {
__typename: "jobs",
...selectedJob
}
};
socket.emit("conversation-modified", {
conversationId: conversation.id,
bodyshopId: bodyshop.id,
type: "tag-added",
job_conversations: [newJobConversation]
});
}
setOpen(false);
};
@@ -50,9 +86,10 @@ export default function ChatTagRoContainer({ conversation }) {
handleSearch={handleSearch}
handleInsertTag={handleInsertTag}
setOpen={setOpen}
style={{ cursor: "pointer" }}
/>
) : (
<Tag onClick={() => setOpen(true)}>
<Tag style={{ cursor: "pointer" }} onClick={() => setOpen(true)}>
<PlusOutlined />
{t("messaging.actions.link")}
</Tag>
@@ -60,3 +97,5 @@ export default function ChatTagRoContainer({ conversation }) {
</div>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(ChatTagRoContainer);

View File

@@ -3,7 +3,7 @@ import { Button, Divider, Dropdown, Form, Input, notification, Popover, Select,
import parsePhoneNumber from "libphonenumber-js";
import dayjs from "../../utils/day";
import queryString from "query-string";
import React, { useState } from "react";
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
@@ -24,6 +24,7 @@ import ScheduleEventNote from "./schedule-event.note.component";
import { useMutation } from "@apollo/client";
import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries";
import ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -49,6 +50,8 @@ export function ScheduleEventComponent({
const searchParams = queryString.parse(useLocation().search);
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
const [title, setTitle] = useState(event.title);
const { socket } = useContext(SocketContext);
const blockContent = (
<Space direction="vertical" wrap>
<Input
@@ -190,7 +193,8 @@ export function ScheduleEventComponent({
if (p && p.isValid()) {
openChatByPhone({
phone_num: p.formatInternational(),
jobid: event.job.id
jobid: event.job.id,
socket
});
setMessage(
t("appointments.labels.reminder", {

View File

@@ -1,5 +1,5 @@
import { useMutation } from "@apollo/client";
import { Button, Form, notification, Popover, Tooltip } from "antd";
import { Button, Checkbox, Form, notification, Popover, Tooltip } from "antd";
import axios from "axios";
import { t } from "i18next";
import React, { useState } from "react";
@@ -60,24 +60,26 @@ export function JobLinesPartPriceChange({ job, line, refetch, technician }) {
}
};
const popcontent = !technician && InstanceRenderManager({
imex: null,
rome: (
<Form layout="vertical" onFinish={handleFinish} initialValues={{ act_price: line.act_price }}>
<Form.Item name="act_price" label={t("jobs.labels.act_price_ppc")} rules={[{ required: true }]}>
<CurrencyFormItemComponent />
</Form.Item>
<Button
disabled={InstanceRenderManager({ imex: true, rome: false, promanager: true })}
loading={loading}
htmlType="primary"
>
{t("general.actions.save")}
</Button>
</Form>
),
promanager: null
});
const popcontent =
!technician &&
InstanceRenderManager({
imex: null,
rome: (
<Form layout="vertical" onFinish={handleFinish} initialValues={{ act_price: line.act_price }}>
<Form.Item name="act_price" label={t("jobs.labels.act_price_ppc")} rules={[{ required: true }]}>
<CurrencyFormItemComponent />
</Form.Item>
<Button
disabled={InstanceRenderManager({ imex: true, rome: false, promanager: true })}
loading={loading}
htmlType="primary"
>
{t("general.actions.save")}
</Button>
</Form>
),
promanager: null
});
return (
<JobLineConvertToLabor jobline={line} job={job}>

View File

@@ -216,7 +216,9 @@ export function JobLinesComponent({
{
title: t("joblines.fields.part_qty"),
dataIndex: "part_qty",
key: "part_qty"
key: "part_qty",
sorter: (a, b) => a.part_qty - b.part_qty,
sortOrder: state.sortedInfo.columnKey === "part_qty" && state.sortedInfo.order
},
// {
// title: t('joblines.fields.tax_part'),

View File

@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
@@ -26,21 +25,16 @@ const mapStateToProps = createStructuredSelector({
const mapDispatchToProps = (dispatch) => ({
setPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "payment" })),
setCardPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "cardPayment" })),
openChatByPhone: (phone) => dispatch(openChatByPhone(phone)),
setMessage: (text) => dispatch(setMessage(text))
setCardPaymentContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "cardPayment"
})
)
});
export function JobPayments({
job,
jobRO,
bodyshop,
setMessage,
openChatByPhone,
setPaymentContext,
setCardPaymentContext,
refetch
}) {
export function JobPayments({ job, jobRO, bodyshop, setPaymentContext, setCardPaymentContext, refetch }) {
const {
treatments: { ImEXPay }
} = useSplitTreatments({
@@ -133,7 +127,7 @@ export function JobPayments({
}
];
//Same as in RO guard. If changed, update in both.
//Same as in RO guard. If changed, update in both.
const total = useMemo(() => {
return (
job.payments &&

View File

@@ -7,6 +7,7 @@ import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import JobTotalsCashDiscount from "./jobs-totals.cash-discount-display.component";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
@@ -22,6 +23,14 @@ export function JobTotalsTableTotals({ bodyshop, job }) {
const data = useMemo(() => {
return [
...(job.job_totals?.totals?.ttl_adjustment
? [
{
key: `Subtotal Adj.`,
total: job.job_totals?.totals?.ttl_adjustment
}
]
: []),
{
key: t("jobs.labels.subtotal"),
total: job.job_totals.totals.subtotal,
@@ -102,7 +111,7 @@ export function JobTotalsTableTotals({ bodyshop, job }) {
total: job.job_totals.totals.us_sales_tax_breakdown.ty4Tax
},
{
key: `${bodyshop.md_responsibility_centers.taxes.tax_ty5?.tax_type5 || "TT"} - ${[
key: `${bodyshop.md_responsibility_centers.taxes.tax_ty5?.tax_type5 || "Adj."} - ${[
job.cieca_pft.ty5_rate1,
job.cieca_pft.ty5_rate2,
job.cieca_pft.ty5_rate3,
@@ -113,6 +122,14 @@ export function JobTotalsTableTotals({ bodyshop, job }) {
.join(", ")}%`,
total: job.job_totals.totals.us_sales_tax_breakdown.ty5Tax
},
...(job.job_totals?.totals?.ttl_tax_adjustment
? [
{
key: `Tax Adj.`,
total: job.job_totals?.totals?.ttl_tax_adjustment
}
]
: []),
{
key: t("jobs.labels.total_sales_tax"),
bold: true,
@@ -121,6 +138,7 @@ export function JobTotalsTableTotals({ bodyshop, job }) {
.add(Dinero(job.job_totals.totals.us_sales_tax_breakdown.ty3Tax))
.add(Dinero(job.job_totals.totals.us_sales_tax_breakdown.ty4Tax))
.add(Dinero(job.job_totals.totals.us_sales_tax_breakdown.ty5Tax))
.add(Dinero(job.job_totals.totals.ttl_tax_adjustment))
.toJSON()
}
].filter((item) => item.total.amount !== 0)
@@ -132,11 +150,27 @@ export function JobTotalsTableTotals({ bodyshop, job }) {
]
}),
{
key: t("jobs.labels.total_repairs"),
total: job.job_totals.totals.total_repairs,
bold: true
},
...(bodyshop.intellipay_config?.enable_cash_discount
? [
{
key: t("jobs.labels.total_repairs_cash_discount"),
total: job.job_totals.totals.total_repairs,
bold: true
},
{
key: t("jobs.labels.total_repairs"),
render: <JobTotalsCashDiscount amountDinero={job.job_totals.totals.total_repairs} />,
bold: true
}
]
: [
{
key: t("jobs.labels.total_repairs"),
total: job.job_totals.totals.total_repairs,
bold: true
}
]),
{
key: t("jobs.fields.ded_amt"),
total: job.job_totals.totals.custPayable.deductible
@@ -169,13 +203,7 @@ export function JobTotalsTableTotals({ bodyshop, job }) {
},
{
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(),
render: <JobTotalsCashDiscount amountDinero={job.job_totals.totals.custPayable.total} />,
bold: true
}
]
@@ -211,7 +239,7 @@ export function JobTotalsTableTotals({ bodyshop, job }) {
dataIndex: "total",
key: "total",
align: "right",
render: (text, record) => Dinero(record.total).toFormat(),
render: (text, record) => (record.render ? record.render : Dinero(record.total).toFormat()),
width: "20%",
onCell: (record, rowIndex) => {
return { style: { fontWeight: record.bold && "bold" } };

View File

@@ -0,0 +1,60 @@
import { notification, Spin } from "antd";
import axios from "axios";
import Dinero from "dinero.js";
import React, { useCallback, useEffect, useState } from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({});
export default connect(mapStateToProps, mapDispatchToProps)(JobTotalsCashDiscount);
export function JobTotalsCashDiscount({ bodyshop, amountDinero }) {
const [loading, setLoading] = useState(true);
const [fee, setFee] = useState(0);
const fetchData = useCallback(async () => {
if (amountDinero && bodyshop) {
setLoading(true);
let response;
try {
response = await axios.post("/intellipay/checkfee", {
bodyshop: { id: bodyshop.id, imexshopid: bodyshop.imexshopid, state: bodyshop.state },
amount: Dinero(amountDinero).toFormat("0.00")
});
if (response?.data?.error) {
notification.open({
type: "error",
message:
response.data?.error ||
"Error encountered when contacting IntelliPay service to determine cash discounted price."
});
} else {
setFee(response.data?.fee || 0);
}
} catch (error) {
notification.open({
type: "error",
message:
error.response?.data?.error ||
"Error encountered when contacting IntelliPay service to determine cash discounted price."
});
} finally {
setLoading(false);
}
}
}, [amountDinero, bodyshop]);
useEffect(() => {
fetchData();
}, [fetchData, bodyshop, amountDinero]);
if (loading) return <Spin size="small" />;
return Dinero(amountDinero)
.add(Dinero({ amount: Math.round(fee * 100) }))
.toFormat();
}

View File

@@ -1,6 +1,6 @@
import { gql, useApolloClient, useLazyQuery, useMutation, useQuery } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Col, Row, notification } from "antd";
import { Col, Row, notification } from "antd"; //import { Button, Col, Row, notification } from "antd";
import Axios from "axios";
import _ from "lodash";
import queryString from "query-string";
@@ -408,26 +408,25 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
updateSchComp={updateSchComp}
setSchComp={setSchComp}
/>
{
// currentUser.email.includes("@rome.") ||
// currentUser.email.includes("@imex.") ? (
// <Button
// onClick={async () => {
// for (const record of data.available_jobs) {
// //Query the data
// console.log("Start Job", record.id);
// const {data} = await loadEstData({
// variables: {id: record.id},
// });
// console.log("Query has been awaited and is complete");
// await onOwnerFindModalOk(data);
// }
// }}
// >
// Add all jobs as new.
// </Button>
// ) : null
}
{/* {
currentUser.email.includes("@rome.") || currentUser.email.includes("@imex.") ? (
<Button
onClick={async () => {
for (const record of data.available_jobs) {
//Query the data
console.log("Start Job", record.id);
const { data } = await loadEstData({
variables: { id: record.id }
});
console.log("Query has been awaited and is complete");
await onOwnerFindModalOk(data);
}
}}
>
Add all jobs as new.
</Button>
) : null
} */}
<Row gutter={[16, 16]}>
<Col span={24}>
<JobsAvailableTableComponent
@@ -617,6 +616,7 @@ function ResolveCCCLineIssues(estData, bodyshop) {
// ` | Act Price delete. (prev act price = ${estData.joblines.data[indexInEstData].act_price})`;
estData.joblines.data[indexInEstData].act_price = 0;
estData.joblines.data[indexInEstData].db_price = 0;
estData.joblines.data[indexInEstData].part_type = null;
});
});
}

View File

@@ -4,7 +4,7 @@ import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Card, Dropdown, Form, Input, Modal, notification, Popconfirm, Popover, Select, Space } from "antd";
import axios from "axios";
import parsePhoneNumber from "libphonenumber-js";
import React, { useMemo, useState } from "react";
import React, { useContext, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useNavigate } from "react-router-dom";
@@ -30,6 +30,7 @@ import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
import AddToProduction from "./jobs-detail-header-actions.addtoproduction.util";
import DuplicateJob from "./jobs-detail-header-actions.duplicate.util";
import JobsDetailHeaderActionsToggleProduction from "./jobs-detail-header-actions.toggle-production";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -126,6 +127,7 @@ export function JobsDetailHeaderActions({
const [updateJob] = useMutation(UPDATE_JOB);
const [voidJob] = useMutation(VOID_JOB);
const [cancelAllAppointments] = useMutation(CANCEL_APPOINTMENTS_BY_JOB_ID);
const { socket } = useContext(SocketContext);
const {
treatments: { ImEXPay }
@@ -299,7 +301,8 @@ export function JobsDetailHeaderActions({
if (p && p.isValid()) {
openChatByPhone({
phone_num: p.formatInternational(),
jobid: job.id
jobid: job.id,
socket
});
setMessage(
`${window.location.protocol}//${window.location.host}/csi/${result.data.insert_csi.returning[0].id}`
@@ -342,7 +345,8 @@ export function JobsDetailHeaderActions({
if (p && p.isValid()) {
openChatByPhone({
phone_num: p.formatInternational(),
jobid: job.id
jobid: job.id,
socket
});
setMessage(`${window.location.protocol}//${window.location.host}/csi/${job.csiinvites[0].id}`);
} else {

View File

@@ -3,13 +3,14 @@ import { Button, Form, message, Popover, Space } from "antd";
import axios from "axios";
import Dinero from "dinero.js";
import { parsePhoneNumber } from "libphonenumber-js";
import React, { useState } from "react";
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -28,6 +29,7 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [paymentLink, setPaymentLink] = useState(null);
const { socket } = useContext(SocketContext);
const handleFinish = async ({ amount }) => {
setLoading(true);
@@ -50,7 +52,8 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope
if (p) {
openChatByPhone({
phone_num: p.formatInternational(),
jobid: job.id
jobid: job.id,
socket
});
setMessage(
t("payments.labels.smspaymentreminder", {
@@ -106,7 +109,8 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope
const p = parsePhoneNumber(job.ownr_ph1, "CA");
openChatByPhone({
phone_num: p.formatInternational(),
jobid: job.id
jobid: job.id,
socket
});
setMessage(
t("payments.labels.smspaymentreminder", {

View File

@@ -110,7 +110,13 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
to: values.to,
subject: Templates[values.key]?.subject
},
values.sendbytext === "text" ? values.sendbytext : values.sendbyexcel === "excel" ? "x" : values.sendby === "email" ? "e" : "p",
values.sendbytext === "text"
? values.sendbytext
: values.sendbyexcel === "excel"
? "x"
: values.sendby === "email"
? "e"
: "p",
id
);
setLoading(false);
@@ -274,6 +280,22 @@ export function ReportCenterModalComponent({ reportCenterModal, bodyshop }) {
{
required: true
//message: t("general.validation.required"),
},
{
validator: (_, value) => {
if (
(!import.meta.env.VITE_APP_IS_TEST && import.meta.env.PROD) &&
value &&
value[0] &&
value[1]
) {
const diffInDays = (value[1] - value[0]) / (1000 * 3600 * 24);
if (diffInDays > 92) {
return Promise.reject(t("general.validation.dateRangeExceeded"));
}
}
return Promise.resolve();
}
}
]}
>

View File

@@ -4334,6 +4334,70 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
<Input />
</Form.Item>
</LayoutFormRow>
{InstanceRenderManager({
promanager: "USE_ROME",
rome: (
<LayoutFormRow header={<div>Adjustments</div>} id="refund">
{bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber ? (
<>
<Form.Item
label={t("bodyshop.labels.responsibilitycenters.ttl_adjustment")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "ttl_adjustment", "dms_acctnumber"]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.labels.responsibilitycenters.ttl_tax_adjustment")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "ttl_tax_adjustment", "dms_acctnumber"]}
>
<Input />
</Form.Item>
</>
) : (
<>
<Form.Item
label={t("bodyshop.labels.responsibilitycenters.ttl_adjustment")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "ttl_adjustment", "accountitem"]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.labels.responsibilitycenters.ttl_tax_adjustment")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["md_responsibility_centers", "ttl_tax_adjustment", "accountitem"]}
>
<Input />
</Form.Item>
</>
)}
</LayoutFormRow>
)
})}
{Qb_Multi_Ar.treatment === "on" && (
<LayoutFormRow header={<div>Multiple Payers Item</div>} id="accountitem">
<Form.Item

View File

@@ -37,16 +37,6 @@ export function ShopInfoIntellipay({ bodyshop, form }) {
>
<Switch />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.intellipay_config.cash_discount_percentage")}
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="%" />
</Form.Item>
</LayoutFormRow>
</>
);

View File

@@ -1,18 +1,33 @@
import { connect } from "react-redux";
import { GlobalOutlined } from "@ant-design/icons";
import { GlobalOutlined, WarningOutlined } from "@ant-design/icons";
import { createStructuredSelector } from "reselect";
import React from "react";
import { selectWssStatus } from "../../redux/application/application.selectors";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
wssStatus: selectWssStatus
});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(WssStatusDisplay);
const mapDispatchToProps = (dispatch) => ({});
export function WssStatusDisplay({ wssStatus }) {
console.log("🚀 ~ WssStatusDisplay ~ wssStatus:", wssStatus);
return <GlobalOutlined style={{ color: wssStatus === "connected" ? "green" : "red", marginRight: ".5rem" }} />;
let icon;
let color;
if (wssStatus === "connected") {
icon = <GlobalOutlined />;
color = "green";
} else if (wssStatus === "error") {
icon = <WarningOutlined />;
color = "red";
} else {
icon = <GlobalOutlined />;
color = "gray"; // Default for other statuses like "disconnected"
}
return <span style={{ color, marginRight: ".5rem" }}>{icon}</span>;
}
export default connect(mapStateToProps, mapDispatchToProps)(WssStatusDisplay);

View File

@@ -9,68 +9,96 @@ const useSocket = (bodyshop) => {
const [clientId, setClientId] = useState(null);
useEffect(() => {
const initializeSocket = async (token) => {
if (!bodyshop || !bodyshop.id) return;
const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "";
const socketInstance = SocketIO(endpoint, {
path: "/wss",
withCredentials: true,
auth: { token },
reconnectionAttempts: Infinity,
reconnectionDelay: 2000,
reconnectionDelayMax: 10000
});
socketRef.current = socketInstance;
// Handle socket events
const handleBodyshopMessage = (message) => {
if (!message || !message.type) return;
switch (message.type) {
case "alert-update":
store.dispatch(addAlerts(message.payload));
break;
default:
break;
}
if (!import.meta.env.DEV) return;
console.log(`Received message for bodyshop ${bodyshop.id}:`, message);
};
const handleConnect = () => {
socketInstance.emit("join-bodyshop-room", bodyshop.id);
setClientId(socketInstance.id);
store.dispatch(setWssStatus("connected"));
};
const handleReconnect = () => {
store.dispatch(setWssStatus("connected"));
};
const handleConnectionError = (err) => {
console.error("Socket connection error:", err);
// Handle token expiration
if (err.message.includes("auth/id-token-expired")) {
console.warn("Token expired, refreshing...");
auth.currentUser?.getIdToken(true).then((newToken) => {
socketInstance.auth = { token: newToken }; // Update socket auth
socketInstance.connect(); // Retry connection
});
} else {
store.dispatch(setWssStatus("error"));
}
};
const handleDisconnect = (reason) => {
console.warn("Socket disconnected:", reason);
store.dispatch(setWssStatus("disconnected"));
// Manually trigger reconnection if necessary
if (!socketInstance.connected && reason !== "io server disconnect") {
setTimeout(() => {
if (socketInstance.disconnected) {
console.log("Manually triggering reconnection...");
socketInstance.connect();
}
}, 2000); // Retry after 2 seconds
}
};
// Register event handlers
socketInstance.on("connect", handleConnect);
socketInstance.on("reconnect", handleReconnect);
socketInstance.on("connect_error", handleConnectionError);
socketInstance.on("disconnect", handleDisconnect);
socketInstance.on("bodyshop-message", handleBodyshopMessage);
};
const unsubscribe = auth.onIdTokenChanged(async (user) => {
if (user) {
const newToken = await user.getIdToken();
const token = await user.getIdToken();
if (socketRef.current) {
// Send new token to server
socketRef.current.emit("update-token", newToken);
} else if (bodyshop && bodyshop.id) {
// Initialize the socket
const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "";
const socketInstance = SocketIO(endpoint, {
path: "/wss",
withCredentials: true,
auth: { token: newToken },
reconnectionAttempts: Infinity,
reconnectionDelay: 2000,
reconnectionDelayMax: 10000
});
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"));
};
const handleReconnect = (attempt) => {
console.log(`Socket reconnected after ${attempt} attempts`);
store.dispatch(setWssStatus("connected"));
};
const handleConnectionError = (err) => {
console.error("Socket connection error:", err);
store.dispatch(setWssStatus("error"));
};
const handleDisconnect = () => {
console.log("Socket disconnected");
store.dispatch(setWssStatus("disconnected"));
};
socketInstance.on("connect", handleConnect);
socketInstance.on("reconnect", handleReconnect);
socketInstance.on("connect_error", handleConnectionError);
socketInstance.on("disconnect", handleDisconnect);
socketInstance.on("bodyshop-message", handleBodyshopMessage);
// Update token if socket exists
socketRef.current.emit("update-token", token);
} else {
// Initialize socket if not already connected
initializeSocket(token);
}
} else {
// User is not authenticated
@@ -81,7 +109,7 @@ const useSocket = (bodyshop) => {
}
});
// Clean up the listener on unmount
// Clean up on unmount
return () => {
unsubscribe();
if (socketRef.current) {

View File

@@ -4,7 +4,6 @@ import { getAuth, updatePassword, updateProfile } from "firebase/auth";
import { getFirestore } from "firebase/firestore";
import { getMessaging, getToken, onMessage } from "firebase/messaging";
import { store } from "../redux/store";
import axios from "axios";
const config = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG);
initializeApp(config);
@@ -66,13 +65,10 @@ export const requestForToken = () => {
});
};
export const onMessageListener = () =>
new Promise((resolve) => {
onMessage(messaging, (payload) => {
console.log("Inbound FCM Message", payload);
resolve(payload);
});
});
onMessage(messaging, (payload) => {
console.log("FCM Message received. ", payload);
// ...
});
export const logImEXEvent = (eventName, additionalParams, stateProp = null) => {
const state = stateProp || store.getState();

View File

@@ -59,6 +59,8 @@ export const GET_CONVERSATION_DETAILS = gql`
id
phone_num
archived
updated_at
unreadcnt
label
job_conversations {
jobid
@@ -71,6 +73,17 @@ export const GET_CONVERSATION_DETAILS = gql`
ro_number
}
}
messages(order_by: { created_at: asc_nulls_first }) {
id
status
text
isoutbound
image
image_path
userid
created_at
read
}
}
}
`;
@@ -79,9 +92,26 @@ export const CONVERSATION_ID_BY_PHONE = gql`
query CONVERSATION_ID_BY_PHONE($phone: String!) {
conversations(where: { phone_num: { _eq: $phone } }) {
id
phone_num
archived
label
unreadcnt
created_at
job_conversations {
jobid
id
conversationid
job {
id
ownr_fn
ownr_ln
ownr_co_nm
ro_number
}
}
messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) {
aggregate {
count
}
}
}
}
@@ -92,6 +122,26 @@ export const CREATE_CONVERSATION = gql`
insert_conversations(objects: $conversation) {
returning {
id
phone_num
archived
label
unreadcnt
job_conversations {
jobid
conversationid
job {
id
ownr_fn
ownr_ln
ownr_co_nm
ro_number
}
}
messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) {
aggregate {
count
}
}
}
}
}

View File

@@ -2,7 +2,7 @@ import { gql } from "@apollo/client";
export const GET_ALL_JOBLINES_BY_PK = gql`
query GET_ALL_JOBLINES_BY_PK($id: uuid!) {
joblines(where: { jobid: { _eq: $id } }, order_by: { line_no: asc }) {
joblines(where: { jobid: { _eq: $id }, removed: { _eq: false } }, order_by: { line_no: asc }) {
id
line_no
unq_seq

View File

@@ -145,7 +145,7 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
};
fetchAlerts();
}, []);
}, [setAlerts]);
// Use useEffect to watch for new alerts
useEffect(() => {
@@ -647,7 +647,7 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
return (
<>
{import.meta.env.PROD && <ChatAffixContainer bodyshop={bodyshop} chatVisible={chatVisible} />}
<ChatAffixContainer bodyshop={bodyshop} chatVisible={chatVisible} />
<Layout style={{ minHeight: "100vh" }} className="layout-container">
<UpdateAlert />
<HeaderContainer />

View File

@@ -2,7 +2,11 @@ import axios from "axios";
import parsePhoneNumber from "libphonenumber-js";
import { all, call, put, select, takeLatest } from "redux-saga/effects";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { CONVERSATION_ID_BY_PHONE, CREATE_CONVERSATION } from "../../graphql/conversations.queries";
import {
CONVERSATION_ID_BY_PHONE,
CREATE_CONVERSATION,
TOGGLE_CONVERSATION_ARCHIVE
} from "../../graphql/conversations.queries";
import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import client from "../../utils/GraphQLClient";
import { selectBodyshop } from "../user/user.selectors";
@@ -27,23 +31,73 @@ export function* onOpenChatByPhone() {
export function* openChatByPhone({ payload }) {
logImEXEvent("messaging_open_by_phone");
const { phone_num, jobid } = payload;
const { socket, phone_num, jobid } = payload;
if (!socket || !phone_num) return;
const p = parsePhoneNumber(phone_num, "CA");
const bodyshop = yield select(selectBodyshop);
try {
// Fetch conversations including archived ones
const {
data: { conversations }
} = yield client.query({
query: CONVERSATION_ID_BY_PHONE,
variables: { phone: p.number },
fetchPolicy: 'no-cache'
fetchPolicy: "no-cache" // Ensure the query always gets the latest data
});
if (conversations.length === 0) {
// Sort conversations by `updated_at` or `created_at` and pick the last one for the given phone number
const sortedConversations = conversations
?.filter((c) => c.phone_num === p.number) // Filter to match the phone number
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); // Sort by `updated_at`
const existingConversation = sortedConversations?.[sortedConversations.length - 1] || null;
if (existingConversation) {
let updatedConversation = existingConversation;
if (existingConversation.archived) {
// If the conversation is archived, unarchive it
const {
data: { update_conversations_by_pk: unarchivedConversation }
} = yield client.mutate({
mutation: TOGGLE_CONVERSATION_ARCHIVE,
variables: {
id: existingConversation.id,
archived: false
}
});
updatedConversation = unarchivedConversation;
// Emit an event indicating the conversation was unarchived
socket.emit("conversation-modified", {
type: "conversation-unarchived",
conversationId: unarchivedConversation.id,
bodyshopId: bodyshop.id,
archived: false
});
}
// Set the unarchived or already active conversation as selected
yield put(setSelectedConversation(updatedConversation.id));
// Add job tag if needed
if (jobid && !updatedConversation.job_conversations.find((jc) => jc.jobid === jobid)) {
yield client.mutate({
mutation: INSERT_CONVERSATION_TAG,
variables: {
conversationId: updatedConversation.id,
jobId: jobid
}
});
}
} else {
// No conversation exists, create a new one
const {
data: {
insert_conversations: { returning: newConversationsId }
insert_conversations: { returning: newConversations }
}
} = yield client.mutate({
mutation: CREATE_CONVERSATION,
@@ -57,26 +111,21 @@ export function* openChatByPhone({ payload }) {
]
}
});
yield put(setSelectedConversation(newConversationsId[0].id));
} else if (conversations.length === 1) {
//got the ID. Open it.
yield put(setSelectedConversation(conversations[0].id));
//Check to see if this job ID is already a child of it. If not add the tag.
if (jobid && !conversations[0].job_conversations.find((jc) => jc.jobid === jobid))
yield client.mutate({
mutation: INSERT_CONVERSATION_TAG,
variables: {
conversationId: conversations[0].id,
jobId: jobid
}
});
} else {
console.log("ERROR: Multiple conversations found. ");
yield put(setSelectedConversation(null));
const createdConversation = newConversations[0];
// Emit event for the new conversation with full details
socket.emit("conversation-modified", {
bodyshopId: bodyshop.id,
type: "conversation-created",
...createdConversation
});
// Set the newly created conversation as selected
yield put(setSelectedConversation(createdConversation.id));
}
} catch (error) {
console.log("Error in sendMessage saga.", error);
console.error("Error in openChatByPhone saga.", error);
}
}

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

@@ -145,14 +145,34 @@ middlewares.push(
const cache = new InMemoryCache({
typePolicies: {
Query: {
conversations: {
fields: {
conversations: offsetLimitPagination()
job_conversations: {
merge(existing = [], incoming = [], { readField }) {
const merged = new Map();
// Add existing data to the map
existing.forEach((jobConversation) => {
// Use `readField` to get the unique `jobid`, fallback to `__ref`
const jobId = readField("jobid", jobConversation) || jobConversation.__ref;
if (jobId) merged.set(jobId, jobConversation);
});
// Add or replace with incoming data
incoming.forEach((jobConversation) => {
// Use `readField` to get the unique `jobid`, fallback to `__ref`
const jobId = readField("jobid", jobConversation) || jobConversation.__ref;
if (jobId) merged.set(jobId, jobConversation);
});
// Return the merged data as an array
return Array.from(merged.values());
}
}
}
}
}
});
const client = new ApolloClient({
link: ApolloLink.from(middlewares),
cache,

View File

@@ -1,70 +0,0 @@
export default async function FcmHandler({ client, payload }) {
console.log("FCM", payload);
switch (payload.type) {
case "messaging-inbound":
client.cache.modify({
id: client.cache.identify({
__typename: "conversations",
id: payload.conversationid
}),
fields: {
messages_aggregate(cached) {
return { aggregate: { count: cached.aggregate.count + 1 } };
}
}
});
client.cache.modify({
fields: {
messages_aggregate(cached) {
return { aggregate: { count: cached.aggregate.count + 1 } };
}
}
});
break;
case "messaging-outbound":
client.cache.modify({
id: client.cache.identify({
__typename: "conversations",
id: payload.conversationid
}),
fields: {
updated_at(oldupdated0) {
return new Date();
}
// messages_aggregate(cached) {
// return { aggregate: { count: cached.aggregate.count + 1 } };
// },
}
});
break;
case "messaging-mark-conversation-read":
let previousUnreadCount = 0;
client.cache.modify({
id: client.cache.identify({
__typename: "conversations",
id: payload.conversationid
}),
fields: {
messages_aggregate(cached) {
previousUnreadCount = cached.aggregate.count;
return { aggregate: { count: 0 } };
}
}
});
client.cache.modify({
fields: {
messages_aggregate(cached) {
return {
aggregate: {
count: cached.aggregate.count - previousUnreadCount
}
};
}
}
});
break;
default:
console.log("No payload type set.");
break;
}
}

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

@@ -6,6 +6,15 @@
headers:
- name: x-imex-auth
value_from_env: DATAPUMP_AUTH
- name: Chatter Data Pump
webhook: '{{HASURA_API_URL}}/data/chatter'
schedule: 45 5 * * *
include_in_metadata: true
payload: {}
headers:
- name: x-imex-auth
value_from_env: DATAPUMP_AUTH
comment: ""
- name: Claimscorp Data Pump
webhook: '{{HASURA_API_URL}}/data/cc'
schedule: 30 6 * * *

View File

@@ -1,14 +1,12 @@
const path = require("path");
const fs = require("fs");
const Dinero = require("dinero.js");
const { gql } = require("graphql-request");
const queries = require("./server/graphql-client/queries");
const GraphQLClient = require("graphql-request").GraphQLClient;
const logger = require("./server/utils/logger");
const AxiosLib = require("axios").default;
const axios = AxiosLib.create();
const pLimit = require("p-limit");
const converter = require("json-2-csv");
// Dinero.defaultCurrency = "USD";
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";
const client = require("./server/graphql-client/graphql-client").client;
require("dotenv").config({
@@ -16,8 +14,9 @@ require("dotenv").config({
});
async function RunTheTest() {
const bodyshopids = ["b501bb82-22b2-493a-8a0f-152938194869"];
const bearerToken = `Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImJhNjI1OTZmNTJmNTJlZDQ0MDQ5Mzk2YmU3ZGYzNGQyYzY0ZjQ1M2UiLCJ0eXAiOiJKV1QifQ.eyJuYW1lIjoiUm9tZSBEZXZlbG9wbWVudCIsImh0dHBzOi8vaGFzdXJhLmlvL2p3dC9jbGFpbXMiOnsieC1oYXN1cmEtZGVmYXVsdC1yb2xlIjoidXNlciIsIngtaGFzdXJhLWFsbG93ZWQtcm9sZXMiOlsidXNlciJdLCJ4LWhhc3VyYS11c2VyLWlkIjoidDZZbTFORGxDRE9QWnIzRjliZ3VXSDRMaFNYMiJ9LCJpb2FkbWluIjp0cnVlLCJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vcm9tZS1wcm9kLTEiLCJhdWQiOiJyb21lLXByb2QtMSIsImF1dGhfdGltZSI6MTcxMDk1MTg1MCwidXNlcl9pZCI6InQ2WW0xTkRsQ0RPUFpyM0Y5Ymd1V0g0TGhTWDIiLCJzdWIiOiJ0NlltMU5EbENET1BacjNGOWJndVdINExoU1gyIiwiaWF0IjoxNzExNTczODI1LCJleHAiOjE3MTE1Nzc0MjUsImVtYWlsIjoicGF0cmlja0Byb21lLmRldiIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwiZmlyZWJhc2UiOnsiaWRlbnRpdGllcyI6eyJlbWFpbCI6WyJwYXRyaWNrQHJvbWUuZGV2Il19LCJzaWduX2luX3Byb3ZpZGVyIjoicGFzc3dvcmQifX0.0kBySA9tJznLYj8TtncHGVWJO0IcmLKP2G1UyyXwaj45kTa25bjT9RWjM-NslX_zjOvrvmQZzisFAb6M1Jf6geNjOMLIqb8bhihhzEZK4CcRfvjT6cpZxnOO2Dp_1Y5OePbvOBS_GlfdsovVWa84OLuhYC5G_3QwHT8_2Cttz4CbrC6M_vd7QsGODJYBbVKMhOdZhzpNq7AbOUh3749WRjLMMobpnZDrmQlsyg3PAqtX1FHO25WQS2rma9QahGDSY736JfbkuZJ2XbNn0axEGpK7RQLUcuRkFUlfKqYplNbR_e1Q3kEfRAZpxBPXZysrDcbDNhbkWCoTmJ3fle55OA`;
const bodyshopids = ["71f8494c-89f0-43e0-8eb2-820b52d723bc"];
const bearerToken = `Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImI4Y2FjOTViNGE1YWNkZTBiOTY1NzJkZWU4YzhjOTVlZWU0OGNjY2QiLCJ0eXAiOiJKV1QifQ.eyJuYW1lIjoiUGF0cmljayBGaWMgKERFVikiLCJodHRwczovL2hhc3VyYS5pby9qd3QvY2xhaW1zIjp7IngtaGFzdXJhLWRlZmF1bHQtcm9sZSI6InVzZXIiLCJ4LWhhc3VyYS1hbGxvd2VkLXJvbGVzIjpbInVzZXIiXSwieC1oYXN1cmEtdXNlci1pZCI6ImhOSjhBRHB0REhRQkRFcXNCOFFNWVRqaURuZjEifSwiaXNzIjoiaHR0cHM6Ly9zZWN1cmV0b2tlbi5nb29nbGUuY29tL2ltZXgtZGV2IiwiYXVkIjoiaW1leC1kZXYiLCJhdXRoX3RpbWUiOjE3MzAxMzIwMjksInVzZXJfaWQiOiJoTko4QURwdERIUUJERXFzQjhRTVlUamlEbmYxIiwic3ViIjoiaE5KOEFEcHRESFFCREVxc0I4UU1ZVGppRG5mMSIsImlhdCI6MTczMDg0MTc2NSwiZXhwIjoxNzMwODQ1MzY1LCJlbWFpbCI6InBhdHJpY2tAaW1leC5kZXYiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsImZpcmViYXNlIjp7ImlkZW50aXRpZXMiOnsiZW1haWwiOlsicGF0cmlja0BpbWV4LmRldiJdfSwic2lnbl9pbl9wcm92aWRlciI6InBhc3N3b3JkIn19.npQWkyB5cB4wmkaBsQiY3JbvBM9vKPqf3e22nVHnSydGcQi0p9M2mca9FcDtdcWvQlShUM63FF-6KkzpovC92sHauNmzCSXRInaaCPEussUUNSJEe2gEV03tYX447LkkSmFQbJ5V6qLTIDelm25fF0MoEDVnLTgythK_9927f8cxKZH1kEow0ymDeMaWey1sRyu7n15OJMcu692mfuQnBAArGTHGJ4YmReI7tMmdrV438MLxuVpH5CLb6uzlUdZoJ__7yh0kz0lkZEeHQAL8yq-0fISbPeZ5uXuMzYGrHuuKsIPRoeShVSVnF7ov8yTT3_YrCkhYbxl0eSTfBB5OdQ`;
const { jobs } = await client.request(
gql`
query GET_JOBS($bodyshopids: [uuid!]!) {
@@ -36,69 +35,98 @@ async function RunTheTest() {
const results = [];
for (const [index, job] of jobs.entries()) {
process.stdout.cursorTo(0);
process.stdout.write(
`Processing job ${index + 1} of ${jobs.length}. Failed jobs: ${results.filter((r) => r.result !== "PASS").length}`
);
const limit = pLimit(5); // Set concurrency limit to 3
try {
await axios.post(
`http://localhost:4000/job/totalsssu`,
{ id: job.id },
{ headers: { Authorization: bearerToken } }
const tasks = jobs.map((job, index) => {
return limit(async () => {
process.stdout.cursorTo(0);
process.stdout.write(
`Processing job ${index + 1} of ${jobs.length}. Failed jobs: ${results.filter((r) => r.overallTotalCorrect !== "PASS").length}. Correct jobs because of adjustment: ${results.filter((r) => r.correctJobsBecauseOfAdjustment).length}`
);
const { jobs_by_pk: newjob } = await client.request(
gql`
query GET_JOBS($id: uuid!) {
jobs_by_pk(id: $id) {
id
ro_number
cieca_ttl
job_totals
ownr_fn
ownr_ln
ownr_co_nm
ins_co_nm
comment
try {
await axios.post(
`http://localhost:4000/job/totalsssu`,
{ id: job.id },
{ headers: { Authorization: bearerToken } }
);
const { jobs_by_pk: newjob } = await client.request(
gql`
query GET_JOBS($id: uuid!) {
jobs_by_pk(id: $id) {
id
ro_number
cieca_ttl
job_totals
ownr_fn
ownr_ln
ownr_co_nm
ins_co_nm
comment
}
}
`,
{
id: job.id
}
`,
{
id: job.id
);
const result = {
id: newjob.id,
owner: `${newjob.ownr_fn} ${newjob.ownr_ln} ${job.ownr_co_nm || ""}`,
ins_co: newjob.ins_co_nm,
comment: newjob.comment,
imexsubtotal: Dinero(newjob.job_totals.totals.subtotal).toFormat("0.00"),
imextotalrepair: Dinero(newjob.job_totals.totals.total_repairs).toFormat("0.00"),
g_tax: newjob.cieca_ttl.data.g_tax,
n_ttl_amt: newjob.cieca_ttl.data.n_ttl_amt,
g_ttl_amt: newjob.cieca_ttl.data.g_ttl_amt
};
const calcTotal = newjob.job_totals.totals.total_repairs.amount;
const ttlTotal = newjob.cieca_ttl.data.g_ttl_amt * 100;
result.difference = (calcTotal - ttlTotal) / 100;
if (Math.abs(calcTotal - ttlTotal) > 3) {
result.overallTotalCorrect = "***FAIL***";
} else {
result.overallTotalCorrect = "PASS";
}
);
result.ttl_adjustment = Dinero(newjob.job_totals.totals.ttl_adjustment).toFormat();
result.ttl_tax_adjustment = Dinero(newjob.job_totals.totals.ttl_tax_adjustment).toFormat();
const result = {
id: newjob.id,
owner: `${newjob.ownr_fn} ${newjob.ownr_ln} ${job.ownr_co_nm || ""}`,
ins_co: newjob.ins_co_nm,
comment: newjob.comment
};
const calcTaxDinero = Dinero(newjob.job_totals.totals.us_sales_tax_breakdown.ty1Tax)
.add(Dinero(newjob.job_totals.totals.us_sales_tax_breakdown.ty2Tax))
.add(Dinero(newjob.job_totals.totals.us_sales_tax_breakdown.ty3Tax))
.add(Dinero(newjob.job_totals.totals.us_sales_tax_breakdown.ty4Tax))
.add(Dinero(newjob.job_totals.totals.us_sales_tax_breakdown.ty5Tax))
.add(Dinero(newjob.job_totals.totals.ttl_tax_adjustment));
result.calcTax = calcTaxDinero.toFormat("0.00");
const calcTax = calcTaxDinero.getAmount() / 100;
const emsTax = newjob.cieca_ttl.data.g_tax;
result.taxDifference = calcTax - emsTax;
const calcTotal = newjob.job_totals.totals.total_repairs.amount;
const ttlTotal = newjob.cieca_ttl.data.g_ttl_amt * 100;
result.difference = (calcTotal - ttlTotal) / 100;
if (Math.abs(calcTax - emsTax) > 3) {
result.taxCorrect = "***FAIL***";
} else {
result.taxCorrect = "PASS";
}
if (Math.abs(calcTotal - ttlTotal) > 3) {
//Diff is greater than 5 cents. Fail it.
result.result = "***FAIL***";
} else {
result.result = "PASS";
results.push(result);
} catch (error) {
results.push({
ro_number: job.ro_number,
id: job.id,
result: error.message
});
}
// console.log(`${result.result} => RO ${job.ro_number} - ${job.id} `);
});
});
results.push(result);
} catch (error) {
results.push({
ro_number: job.ro_number,
id: job.id,
result: "**503 FAILURE**"
});
}
}
await Promise.all(tasks);
console.table(results.filter((r) => r.result !== "PASS"));
console.table(results.filter((r) => r.overallTotalCorrect !== "PASS"));
console.log("=======================================");
const summary = results.reduce(
(acc, val) => {
if (val.result === "PASS") {
@@ -110,18 +138,12 @@ async function RunTheTest() {
{ pass: 0, fail: 0 }
);
console.log("Pass Rate: ", ((summary.pass / (summary.fail + summary.pass)) * 100).toFixed(1));
const ret = converter.json2csv(results, { emptyFieldValue: "" });
fs.writeFile(`./logs/totalstest-${Date.now()}.csv`, ret, (error) => console.log(error));
}
RunTheTest();
// mutation {
// delete_jobs(where: {shopid: {_eq: "a7ee1503-ee05-4a02-b80e-bdb11d1cc8ac"}}) {
// affected_rows
// }
// delete_owners(where: {shopid: {_eq: "a7ee1503-ee05-4a02-b80e-bdb11d1cc8ac"}}) {
// affected_rows
// }
// delete_vehicles(where: {shopid: {_eq: "a7ee1503-ee05-4a02-b80e-bdb11d1cc8ac"}}) {
// affected_rows
// }
// }
RunTheTest().catch((error) => {
console.log("Error in RunTheTest: ", error);
});

3553
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,13 +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-s3": "^3.689.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",
@@ -34,40 +34,42 @@
"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",
"crisp-status-reporter": "^1.2.2",
"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",
"juice": "^11.0.0",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"moment-timezone": "^0.5.46",
"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": "^11.0.0",
"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"
@@ -75,6 +77,7 @@
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"concurrently": "^8.2.2",
"p-limit": "^3.1.0",
"prettier": "^3.3.3",
"source-map-explorer": "^2.5.2"
}

View File

@@ -22,7 +22,10 @@ const { applyIOHelpers } = require("./server/utils/ioHelpers");
const { redisSocketEvents } = require("./server/web-sockets/redisSocketEvents");
const { ElastiCacheClient, DescribeCacheClustersCommand } = require("@aws-sdk/client-elasticache");
const { InstanceRegion } = require("./server/utils/instanceMgr");
const StartStatusReporter = require("./server/utils/statusReporter");
const cleanupTasks = [];
let isShuttingDown = false;
const CLUSTER_RETRY_BASE_DELAY = 100;
const CLUSTER_RETRY_MAX_DELAY = 5000;
const CLUSTER_RETRY_JITTER = 100;
@@ -297,6 +300,15 @@ const main = async () => {
applyRoutes({ app });
redisSocketEvents({ io: ioRedis, redisHelpers, ioHelpers, logger });
const StatusReporter = StartStatusReporter();
registerCleanupTask(async () => {
StatusReporter.end();
});
// Add SIGTERM signal handler
process.on("SIGTERM", handleSigterm);
process.on("SIGINT", handleSigterm); // Optional: Handle Ctrl+C
try {
await server.listen(port);
logger.log(`Server started on port ${port}`, "INFO", "api");
@@ -314,3 +326,33 @@ main().catch((error) => {
// Note: If we want the app to crash on all uncaught async operations, we would
// need to put a `process.exit(1);` here
});
// Register a cleanup task
function registerCleanupTask(task) {
cleanupTasks.push(task);
}
// SIGTERM handler
async function handleSigterm() {
if (isShuttingDown) {
logger.log("sigterm-api", "WARN", null, null, { message: "Shutdown already in progress, ignoring signal." });
return;
}
isShuttingDown = true;
logger.log("sigterm-api", "WARN", null, null, { message: "SIGTERM Received. Starting graceful shutdown." });
try {
for (const task of cleanupTasks) {
logger.log("sigterm-api", "WARN", null, null, { message: `Running cleanup task: ${task.name}` });
await task();
}
logger.log("sigterm-api", "WARN", null, null, { message: `All cleanup tasks completed.` });
} catch (error) {
logger.log("sigterm-api-error", "ERROR", null, null, { message: error.message, stack: error.stack });
}
process.exit(0);
}

View File

@@ -548,6 +548,61 @@ exports.default = function ({ bodyshop, jobs_by_pk, qbo = false, items, taxCodes
}
}
if (jobs_by_pk.job_totals.totals?.ttl_adjustment) {
// Do not need to check for ImEX or Rome because ImEX uses a different totals calculation that will never set this field.
if (qbo) {
const taxAccountCode = findTaxCode(
{
local: false,
federal: InstanceManager({ imex: true, rome: false }),
state: jobs_by_pk.tax_lbr_rt === 0 ? false : true
},
bodyshop.md_responsibility_centers.sales_tax_codes
);
const QboTaxId = InstanceManager({
imex: taxCodes[taxAccountCode],
rome: CheckQBOUSATaxID({
// jobline: jobline,
type: "adjustment",
job: jobs_by_pk
})
});
InvoiceLineAdd.push({
DetailType: "SalesItemLineDetail",
Amount: Dinero(jobs_by_pk.job_totals.totals?.ttl_adjustment).toFormat(DineroQbFormat),
SalesItemLineDetail: {
...(jobs_by_pk.class ? { ClassRef: { value: classes[jobs_by_pk.class] } } : {}),
ItemRef: {
value: items[responsibilityCenters.ttl_adjustment?.accountitem]
},
TaxCodeRef: {
value: QboTaxId
},
Qty: 1
}
});
} else {
InvoiceLineAdd.push({
ItemRef: {
FullName: responsibilityCenters.ttl_adjustment?.accountitem
},
Desc: "Adjustment",
Quantity: 1,
Amount: Dinero(jobs_by_pk.job_totals.totals?.ttl_adjustment).toFormat(DineroQbFormat),
SalesTaxCodeRef: InstanceManager({
imex: {
FullName: "E"
},
rome: {
FullName: bodyshop.md_responsibility_centers.taxes.itemexemptcode || "NON"
}
})
});
}
}
//Add tax lines
const job_totals = jobs_by_pk.job_totals;
const federal_tax = Dinero(job_totals.totals.federal_tax);
@@ -824,7 +879,60 @@ exports.default = function ({ bodyshop, jobs_by_pk, qbo = false, items, taxCodes
}
}
}
if (jobs_by_pk.job_totals.totals.ttl_tax_adjustment) {
// Do not need to check for ImEX or Rome because ImEX uses a different totals calculation that will never set this field.
if (qbo) {
const taxAccountCode = findTaxCode(
{
local: false,
federal: InstanceManager({ imex: true, rome: false }),
state: jobs_by_pk.tax_lbr_rt === 0 ? false : true
},
bodyshop.md_responsibility_centers.sales_tax_codes
);
const QboTaxId = InstanceManager({
imex: taxCodes[taxAccountCode],
rome: CheckQBOUSATaxID({
// jobline: jobline,
type: "adjustment",
job: jobs_by_pk
})
});
InvoiceLineAdd.push({
DetailType: "SalesItemLineDetail",
Amount: Dinero(jobs_by_pk.job_totals.totals?.ttl_tax_adjustment).toFormat(DineroQbFormat),
SalesItemLineDetail: {
...(jobs_by_pk.class ? { ClassRef: { value: classes[jobs_by_pk.class] } } : {}),
ItemRef: {
value: items[responsibilityCenters.ttl_tax_adjustment?.accountitem]
},
TaxCodeRef: {
value: QboTaxId
},
Qty: 1
}
});
} else {
InvoiceLineAdd.push({
ItemRef: {
FullName: responsibilityCenters.ttl_tax_adjustment?.accountitem
},
Desc: "Tax Adjustment",
Quantity: 1,
Amount: Dinero(jobs_by_pk.job_totals.totals?.ttl_tax_adjustment).toFormat(DineroQbFormat),
SalesTaxCodeRef: InstanceManager({
imex: {
FullName: "E"
},
rome: {
FullName: bodyshop.md_responsibility_centers.taxes.itemexemptcode || "NON"
}
})
});
}
}
if (!qbo && InvoiceLineAdd.length === 0) {
//Handle the scenario where there is a $0 sale invoice.
InvoiceLineAdd.push({

View File

@@ -352,6 +352,7 @@ function calculateAllocations(connectionData, job) {
// console.log("NO MASH ACCOUNT FOUND!!");
}
}
if (InstanceManager({ rome: true })) {
//profile level adjustments for parts
Object.keys(job.job_totals.parts.adjustments).forEach((key) => {
@@ -427,6 +428,41 @@ function calculateAllocations(connectionData, job) {
} else {
return { ...taxAllocations[key], tax: key };
}
})
}),
...(job.job_totals.totals.ttl_adjustment
? [
{
center: "SUB ADJ",
sale: Dinero(job.job_totals.totals.ttl_adjustment),
cost: Dinero(),
profitCenter: {
name: "SUB ADJ",
accountdesc: "SUB ADJ",
accountitem: "SUB ADJ",
accountname: "SUB ADJ",
dms_acctnumber: bodyshop.md_responsibility_centers.ttl_adjustment.dms_acctnumber
},
costCenter: {}
}
]
: []),
...(job.job_totals.totals.ttl_tax_adjustment
? [
{
center: "TAX ADJ",
sale: Dinero(job.job_totals.totals.ttl_tax_adjustment),
cost: Dinero(),
profitCenter: {
name: "TAX ADJ",
accountdesc: "TAX ADJ",
accountitem: "TAX ADJ",
accountname: "TAX ADJ",
dms_acctnumber: bodyshop.md_responsibility_centers.ttl_tax_adjustment.dms_acctnumber
},
costCenter: {}
}
]
: [])
];
}

View File

@@ -109,7 +109,8 @@ async function processBatch(shopsToProcess, start, end, allChatterObjects, allEr
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
phone_number: j.ownr_ph1,
transaction_time: (j.actual_delivery && moment(j.actual_delivery).tz(bodyshop.timezone).format("YYYYMMDD-HHmm")) || ""
};
});
allChatterObjects.push(...chatterObject);

View File

@@ -253,7 +253,7 @@ const sendNotification = async (req, res) => {
admin
.messaging()
.send({
topic: "PRD_PATRICK-messaging",
topic: req.body.topic,
notification: {
title: `ImEX Online Message - `,
body: "Test Noti."

View File

@@ -4,6 +4,7 @@ query FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID($mssid: String!, $phone: String!) {
id
conversations(where: { phone_num: { _eq: $phone } }) {
id
created_at
}
}
}`;
@@ -87,6 +88,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 (where: { read: { _eq: false }, isoutbound: { _eq: false } }){
aggregate {
count
}
}
}
conversationid
created_at
@@ -116,6 +132,7 @@ mutation INSERT_MESSAGE($msg: [messages_insert_input!]!, $conversationid: uuid!)
id
archived
bodyshop {
id
imexshopid
}
created_at
@@ -144,6 +161,11 @@ mutation UPDATE_MESSAGE($msid: String!, $fields: messages_set_input!) {
update_messages(where: { msid: { _eq: $msid } }, _set: $fields) {
returning {
id
status
conversationid
conversation{
bodyshopid
}
}
}
}`;
@@ -840,6 +862,7 @@ exports.CHATTER_QUERY = `query CHATTER_EXPORT($start: timestamptz, $bodyshopid:
timezone
}
jobs(where: {_and: [{converted: {_eq: true}}, {actual_delivery: {_gt: $start}}, {actual_delivery: {_lte: $end}}, {shopid: {_eq: $bodyshopid}}, {_or: [{ownr_ph1: {_is_null: false}}, {ownr_ea: {_is_null: false}}]}]}) {
actual_delivery
id
created_at
ro_number
@@ -1365,6 +1388,7 @@ exports.GET_JOB_BY_PK = `query GET_JOB_BY_PK($id: uuid!) {
cieca_pfl
cieca_pft
cieca_pfo
cieca_ttl
vehicle {
id
notes
@@ -1494,7 +1518,8 @@ exports.GET_JOB_BY_PK = `query GET_JOB_BY_PK($id: uuid!) {
}`;
//TODO:AIO The above query used to have parts order lines in it. Validate that this doesn't need it.
exports.QUERY_JOB_COSTING_DETAILS = ` query QUERY_JOB_COSTING_DETAILS($id: uuid!) {
exports.QUERY_JOB_COSTING_DETAILS = `
query QUERY_JOB_COSTING_DETAILS($id: uuid!) {
jobs_by_pk(id: $id) {
ro_number
clm_total
@@ -2544,3 +2569,44 @@ exports.GET_JOBS_BY_PKS = `query GET_JOBS_BY_PKS($ids: [uuid!]!) {
}
}
`;
exports.MARK_MESSAGES_AS_READ = `mutation MARK_MESSAGES_AS_READ($conversationId: uuid!) {
update_messages(where: { conversationid: { _eq: $conversationId } }, _set: { read: true }) {
returning {
id
}
affected_rows
}
}
`;
exports.CREATE_CONVERSATION = `mutation CREATE_CONVERSATION($conversation: [conversations_insert_input!]!) {
insert_conversations(objects: $conversation) {
returning {
id
phone_num
archived
label
unreadcnt
job_conversations {
jobid
conversationid
job {
id
ro_number
ownr_fn
ownr_ln
ownr_co_nm
}
}
messages_aggregate(where: { read: { _eq: false }, isoutbound: { _eq: false } }) {
aggregate {
count
}
}
created_at
updated_at
}
}
}
`;

View File

@@ -149,6 +149,58 @@ exports.generate_payment_url = async (req, res) => {
}
};
//Reference: https://intellipay.com/dist/webapi26.html#operation/fee
exports.checkfee = async (req, res) => {
// Requires amount, bodyshop.imexshopid, and state? to get data.
logger.log("intellipay-fee-check", "DEBUG", req.user?.email, null, null);
//If there's no amount, there can't be a fee. Skip the call.
if (!req.body.amount || req.body.amount <= 0) {
res.json({ fee: 0 });
return;
}
const shopCredentials = await getShopCredentials(req.body.bodyshop);
try {
const options = {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
//TODO: Move these to environment variables/database.
data: qs.stringify(
{
method: "fee",
...shopCredentials,
amount: req.body.amount,
paymenttype: `CC`,
cardnum: "4111111111111111", //Not needed per documentation, but incorrect values come back without it.
state:
req.body.bodyshop?.state && req.body.bodyshop.state?.length === 2
? req.body.bodyshop.state.toUpperCase()
: "ZZ" //Same as above
},
{ sort: false } //ColdFusion Query Strings depend on order. This preserves it.
),
url: `https://${domain}.cpteller.com/api/26/webapi.cfc`
};
const response = await axios(options);
if (response.data?.error) {
res.status(400).json({ error: response.data.error });
} else if (response.data < 0) {
res.json({ error: "Fee amount negative. Check API credentials & account configuration." });
} else {
res.json({ fee: response.data });
}
} catch (error) {
//console.log(error);
logger.log("intellipay-fee-check-error", "ERROR", req.user?.email, null, {
error: error.message
});
res.status(400).json({ error });
}
};
exports.postback = async (req, res) => {
try {
logger.log("intellipay-postback", "DEBUG", req.user?.email, null, req.body);

View File

@@ -849,6 +849,41 @@ function GenerateCostingData(job) {
gppercent: formatGpPercent(0)
});
}
//Push adjustments to bottom line.
if (job.job_totals?.totals?.ttl_adjustment) {
//Add to totals.
const Adjustment = Dinero(job.job_totals.totals.ttl_adjustment); //Need to invert, since this is being assigned as a cost.
summaryData.totalAdditionalSales = summaryData.totalAdditionalSales.add(Adjustment);
summaryData.totalSales = summaryData.totalSales.add(Adjustment);
//Add to lines.
costCenterData.push({
id: "AdjEst",
cost_center: "Adjustment (Est. Match)",
sale_labor: Dinero().toFormat(),
sale_labor_dinero: Dinero(),
sale_parts: Dinero().toFormat(),
sale_parts_dinero: Dinero(),
sale_additional: Adjustment.toFormat(),
sale_additional_dinero: Adjustment,
sale_sublet: Dinero(),
sale_sublet_dinero: Dinero(),
sales: Adjustment.toFormat(),
sales_dinero: Adjustment,
cost_parts: Dinero().toFormat(),
cost_parts_dinero: Dinero(),
cost_labor: Dinero().toFormat(), //Adjustment.toFormat(),
cost_labor_dinero: Dinero(), // Adjustment,
cost_additional: Dinero(),
cost_additional_dinero: Dinero(),
cost_sublet: Dinero(),
cost_sublet_dinero: Dinero(),
costs: Dinero().toFormat(),
costs_dinero: Dinero(),
gpdollars_dinero: Dinero(),
gpdollars: Dinero().toFormat(),
gppercent: formatGpPercent(0)
});
}
//Final summary data massaging.

View File

@@ -49,7 +49,7 @@ exports.totalsSsu = async function (req, res) {
} catch (error) {
logger.log("job-totals-ssu-USA-error", "ERROR", req?.user?.email, id, {
jobid: id,
error
error: error.message
});
res.status(503).send();
}
@@ -68,6 +68,54 @@ async function TotalsServerSide(req, res) {
ret.additional = CalculateAdditional(job);
ret.totals = CalculateTaxesTotals(job, ret);
// Sub total scrubbbing.
const emsTotal =
job.cieca_ttl.data.n_ttl_amt === job.cieca_ttl.data.g_ttl_amt //It looks like sometimes, gross and net are the same, but they shouldn't be.
? job.cieca_ttl.data.n_ttl_amt - job.cieca_ttl.data.g_tax
: job.cieca_ttl.data.g_ttl_amt - job.cieca_ttl.data.g_tax; //If they are, adjust the gross total down by the tax amount.
const ttlDifference =
emsTotal -
ret.totals.subtotal
.add(
Dinero({
amount: Math.round((job.adjustment_bottom_line || 0) * 100)
}).multiply(-1) //Add back in the adjustment to the subtotal. We don't want to scrub it twice.
)
.getAmount() /
100;
if (Math.abs(ttlDifference) > 0.0) {
//If difference is greater than a pennny, we need to adjust it.
ret.totals.ttl_adjustment = Dinero({ amount: Math.round(ttlDifference * 100) });
ret.totals.subtotal = ret.totals.subtotal.add(ret.totals.ttl_adjustment);
ret.totals.total_repairs = ret.totals.total_repairs.add(ret.totals.ttl_adjustment);
ret.totals.net_repairs = ret.totals.net_repairs.add(ret.totals.ttl_adjustment);
logger.log("job-totals-USA-ttl-adj", "DEBUG", null, job.id, {
adjAmount: ttlDifference
});
}
//Taxes Scrubbing
const emsTaxTotal = job.cieca_ttl.data.g_tax;
const totalUsTaxes =
(ret.totals.us_sales_tax_breakdown.ty1Tax.getAmount() +
ret.totals.us_sales_tax_breakdown.ty2Tax.getAmount() +
ret.totals.us_sales_tax_breakdown.ty3Tax.getAmount() +
ret.totals.us_sales_tax_breakdown.ty4Tax.getAmount() +
ret.totals.us_sales_tax_breakdown.ty5Tax.getAmount()) /
100;
const ttlTaxDifference = emsTaxTotal - totalUsTaxes;
if (Math.abs(ttlTaxDifference) > 0.0) {
//If difference is greater than a pennny, we need to adjust it.
ret.totals.ttl_tax_adjustment = Dinero({ amount: Math.round(ttlTaxDifference * 100) });
ret.totals.total_repairs = ret.totals.total_repairs.add(ret.totals.ttl_tax_adjustment);
ret.totals.net_repairs = ret.totals.net_repairs.add(ret.totals.ttl_tax_adjustment);
logger.log("job-totals-USA-ttl-tax-adj", "DEBUG", null, job.id, {
adjAmount: ttlTaxDifference
});
}
return ret;
} catch (error) {
logger.log("job-totals-ssu-USA-error", "ERROR", req.user?.email, job.id, {
@@ -842,17 +890,21 @@ function CalculateTaxesTotals(job, otherTotals) {
}
});
//Add towing and storage taxable amounts
const stlTowing = job.cieca_stl?.data.find((c) => c.ttl_typecd === "OTTW");
const stlStorage = job.cieca_stl?.data.find((c) => c.ttl_typecd === "OTST");
const stlTowing = job.cieca_stl?.data.find((c) => c.ttl_typecd === "OTTW" || c.ttl_type === "OTTW");
const stlStorage = job.cieca_stl?.data.find((c) => c.ttl_typecd === "OTST" || c.ttl_type === "OTST");
if (stlTowing)
taxableAmounts.TOW = Dinero({
amount: Math.round(stlTowing.t_amt * 100)
});
taxableAmounts.TOW = taxableAmounts.TOW.add(
Dinero({
amount: Math.round(stlTowing.t_amt * 100)
})
);
if (stlStorage)
taxableAmounts.TOW = Dinero({
amount: Math.round(stlStorage.t_amt * 100)
});
taxableAmounts.TOW = taxableAmounts.TOW.add(
(taxableAmounts.TOW = Dinero({
amount: Math.round(stlStorage.t_amt * 100)
}))
);
const pfp = job.parts_tax_rates;
@@ -959,7 +1011,7 @@ function CalculateTaxesTotals(job, otherTotals) {
}
}
} catch (error) {
logger.log("job-totals-USA Key with issue", "error", null, null, {
logger.log("job-totals-USA Key with issue", "error", null, job.id, {
key
});
}
@@ -989,7 +1041,7 @@ function CalculateTaxesTotals(job, otherTotals) {
for (let threshCounter = 1; threshCounter <= 5; threshCounter++) {
const thresholdAmount = parseFloat(job.cieca_pft[`ty${tyCounter}_thres${threshCounter}`]) || 0;
const thresholdTaxRate = parseFloat(job.cieca_pft[`ty${tyCounter}_rate${threshCounter}`]) || 0;
// console.log(taxTierKey, tyCounter, threshCounter, thresholdAmount, thresholdTaxRate);
let taxableAmountInThisThreshold;
if (
thresholdAmount === 9999.99 ||
@@ -1013,11 +1065,8 @@ function CalculateTaxesTotals(job, otherTotals) {
taxableAmountInThisThreshold = Dinero({
amount: Math.round(thresholdAmount * 100)
});
remainingTaxableAmounts[taxTierKey] = remainingTaxableAmounts[taxTierKey].subtract(
Dinero({
amount: Math.round(taxableAmountInThisThreshold * 100)
})
);
remainingTaxableAmounts[taxTierKey] =
remainingTaxableAmounts[taxTierKey].subtract(taxableAmountInThisThreshold);
}
}
@@ -1026,8 +1075,8 @@ function CalculateTaxesTotals(job, otherTotals) {
totalTaxByTier[taxTierKey] = totalTaxByTier[taxTierKey].add(taxAmountToAdd);
}
} catch (error) {
logger.log("job-totals-USA - PFP Calculation Error", "error", null, null, {
error
logger.log("job-totals-USA - PFP Calculation Error", "error", null, job.id, {
error: error.message
});
}
});

View File

@@ -0,0 +1,20 @@
const { isObject } = require("lodash");
const validateCanvasRequestMiddleware = (req, res, next) => {
const { w, h, values, keys, override } = req.body;
if (!values || !keys) {
return res.status(400).send("Missing required data");
}
if (override && !isObject(override)) {
return res.status(400).send("Override must be an object");
}
if (w && (!Number.isFinite(w) || w <= 0)) {
return res.status(400).send("Width must be a positive number");
}
if (h && (!Number.isFinite(h) || h <= 0)) {
return res.status(400).send("Height must be a positive number");
}
next();
};
module.exports = validateCanvasRequestMiddleware;

View File

@@ -5,89 +5,120 @@ const logger = require("../utils/logger");
const { backgroundColors, borderColors } = require("./canvas-colors");
const { isObject, defaultsDeep, isNumber } = require("lodash");
let isProcessing = false;
const requestQueue = [];
const processCanvasRequest = async (req, res) => {
try {
const { w, h, values, keys, override } = req.body;
logger.log("inbound-canvas-creation", "debug", "jsr", null, { w, h, values, keys, override });
// Set dimensions with defaults
const width = isNumber(w) ? w : 500;
const height = isNumber(h) ? h : 275;
const configuration = {
type: "doughnut",
data: {
labels: keys,
datasets: [
{
data: values,
backgroundColor: backgroundColors,
borderColor: borderColors,
borderWidth: 1
}
]
},
options: {
animation: false,
devicePixelRatio: 4,
responsive: false,
maintainAspectRatio: true,
circumference: 180,
rotation: -90,
plugins: {
legend: {
labels: {
boxWidth: 20,
font: {
family: "'Montserrat'",
size: 10,
style: "normal",
weight: "normal"
}
},
position: "left"
}
}
}
};
// If we have a valid override object, merge it with the default configuration object.
// This allows for you to override the default configuration with a custom one.
const defaults = () => {
if (!override || !isObject(override)) {
return configuration;
}
return defaultsDeep(override, configuration);
};
// Generate chart
let canvas = createCanvas(width, height);
let ctx = canvas.getContext("2d");
let chart = new Chart(ctx, defaults());
const result = canvas.toDataURL();
chart.destroy();
canvas.width = 0;
canvas.height = 0;
ctx = null;
canvas = null;
chart = null;
res.status(200).send(result);
} catch (error) {
if (chart) chart.destroy();
if (canvas) {
canvas.width = 0;
canvas.height = 0;
}
ctx = null;
canvas = null;
chart = null;
logger.log("inbound-canvas-creation", "error", "jsr", null, { error: error.message, stack: error.stack });
res.status(500).send("Error generating canvas");
}
};
const processNextInQueue = async () => {
if (requestQueue.length === 0) {
isProcessing = false;
return;
}
const { req, res } = requestQueue.shift();
await processCanvasRequest(req, res);
processNextInQueue();
};
exports.canvastest = function (req, res) {
//console.log("Incoming test request.", req);
res.status(200).send("OK");
};
exports.canvas = function (req, res) {
const { w, h, values, keys, override } = req.body;
//console.log("Incoming Canvas Request:", w, h, values, keys, override);
logger.log("inbound-canvas-creation", "debug", "jsr", null, { w, h, values, keys, override });
// Gate required values
if (!values || !keys) {
res.status(400).send("Missing required data");
exports.canvas = async function (req, res) {
if (isProcessing) {
if (requestQueue.length >= 100) {
// Set a maximum queue size
return res.status(503).send("Server is busy. Please try again later.");
}
requestQueue.push({ req, res });
logger.log("inbound-canvas-creation-queue", "debug", "jsr", null, { queue: requestQueue.length });
return;
}
// Override must be an object if it exists
if (override && !isObject(override)) {
res.status(400).send("Override must be an object");
return;
}
// Set the default Width and Height
let [width, height] = [500, 275];
// Allow for custom width and height
if (isNumber(w)) {
width = w;
}
if (isNumber(h)) {
height = h;
}
const configuration = {
type: "doughnut",
data: {
labels: keys,
datasets: [
{
data: values,
backgroundColor: backgroundColors,
borderColor: borderColors,
borderWidth: 1
}
]
},
options: {
devicePixelRatio: 4,
responsive: false,
maintainAspectRatio: true,
circumference: 180,
rotation: -90,
plugins: {
legend: {
labels: {
boxWidth: 20,
font: {
family: "'Montserrat'",
size: 10,
style: "normal",
weight: "normal"
}
},
position: "left"
}
}
}
};
// If we have a valid override object, merge it with the default configuration object.
// This allows for you to override the default configuration with a custom one.
const defaults = () => {
if (!override || !isObject(override)) {
return configuration;
}
return defaultsDeep(override, configuration);
};
res.status(200).send(
(() => {
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d");
new Chart(ctx, defaults());
return canvas.toDataURL();
})()
);
isProcessing = true;
await processCanvasRequest(req, res);
processNextInQueue();
};

View File

@@ -3,24 +3,36 @@ require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const logger = require("../utils/logger");
const inlineCssTool = require("inline-css");
//const inlineCssTool = require("inline-css");
const juice = require("juice");
exports.inlinecss = (req, res) => {
exports.inlinecss = async (req, res) => {
//Perform request validation
logger.log("email-inline-css", "DEBUG", req.user.email, null, null);
const { html, url } = req.body;
inlineCssTool(html, { url: url })
.then((inlinedHtml) => {
res.send(inlinedHtml);
})
.catch((error) => {
logger.log("email-inline-css-error", "ERROR", req.user.email, null, {
error
});
res.send(error);
try {
const inlinedHtml = juice(html, {
applyAttributesTableElements: false,
preserveMediaQueries: false,
applyWidthAttributes: false
});
res.send(inlinedHtml);
} catch (error) {
logger.log("email-inline-css-error", "ERROR", req.user.email, null, {
error
});
res.send(error.message);
}
// inlineCssTool(html, { url: url })
// .then((inlinedHtml) => {
// res.send(inlinedHtml);
// })
// .catch((error) => {
// logger.log("email-inline-css-error", "ERROR", req.user.email, null, {
// error
// });
// });
};

View File

@@ -1,11 +1,12 @@
const express = require("express");
const router = express.Router();
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
const { lightbox_credentials, payment_refund, generate_payment_url, postback } = require("../intellipay/intellipay");
const { lightbox_credentials, payment_refund, generate_payment_url, postback, checkfee } = require("../intellipay/intellipay");
router.post("/lightbox_credentials", validateFirebaseIdTokenMiddleware, lightbox_credentials);
router.post("/payment_refund", validateFirebaseIdTokenMiddleware, payment_refund);
router.post("/generate_payment_url", validateFirebaseIdTokenMiddleware, generate_payment_url);
router.post("/checkfee", validateFirebaseIdTokenMiddleware, checkfee);
router.post("/postback", postback);
module.exports = router;

View File

@@ -13,6 +13,7 @@ const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLCl
const { taskAssignedEmail, tasksRemindEmail } = require("../email/tasksEmails");
const { canvastest } = require("../render/canvas-handler");
const { alertCheck } = require("../alerts/alertcheck");
const uuid = require("uuid").v4;
//Test route to ensure Express is responding.
router.get("/test", eventAuthorizationMiddleware, async function (req, res) {
@@ -57,6 +58,59 @@ router.get("/test-logs", eventAuthorizationMiddleware, (req, res) => {
return res.status(500).send("Logs tested.");
});
router.get("/wstest", eventAuthorizationMiddleware, (req, res) => {
const { ioRedis } = req;
ioRedis.to(`bodyshop-broadcast-room:bfec8c8c-b7f1-49e0-be4c-524455f4e582`).emit("new-message-summary", {
isoutbound: true,
conversationId: "2b44d692-a9e4-4ed4-9c6b-7d8b0c44a0f6",
msid: "SM5d053957bc0da29399b768c23bffcc0f",
summary: true
});
// TODO: Do we need to add more content here?
ioRedis
.to(`bodyshop-conversation-room:bfec8c8c-b7f1-49e0-be4c-524455f4e582:2b44d692-a9e4-4ed4-9c6b-7d8b0c44a0f6`)
.emit("new-message-detailed", {
//
// msid: "SMbbd7703a898fef7f2c07c148ade8a6cd",
// text: "test2",
// conversationid: "2b44d692-a9e4-4ed4-9c6b-7d8b0c44a0f6",
// isoutbound: true,
// userid: "patrick@imex.dev",
// image: false,
// image_path: [],
newMessage: {
conversation: {
id: uuid(),
archived: false,
bodyshop: {
id: "bfec8c8c-b7f1-49e0-be4c-524455f4e582",
imexshopid: "APPLE"
},
created_at: "2024-11-19T19:46:38.984633+00:00",
updated_at: "2024-11-19T22:40:48.346875+00:00",
unreadcnt: 0,
phone_num: "+16138676684"
},
conversationid: "2b44d692-a9e4-4ed4-9c6b-7d8b0c44a0f6",
created_at: "2024-11-19T22:40:48.346875+00:00",
id: "68604ea9-c411-43ec-ab83-899868e58819",
image_path: [],
image: false,
isoutbound: true,
msid: "SMbbd7703a898fef7f2c07c148ade8a6cd",
read: false,
text: `This is a test ${Math.round(Math.random() * 100)}`,
updated_at: "2024-11-19T22:40:48.346875+00:00",
status: "posted",
userid: "patrick@imex.dev"
},
conversationId: "2b44d692-a9e4-4ed4-9c6b-7d8b0c44a0f6",
summary: false
}); // TODO: Do we need to add more content here?
return res.status(500).send("Logs tested.");
});
// Search
router.post("/search", validateFirebaseIdTokenMiddleware, withUserGraphQLClientMiddleware, os.search);

View File

@@ -1,11 +1,12 @@
const express = require("express");
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
const { subscribe, unsubscribe } = require("../firebase/firebase-handler");
const { subscribe, unsubscribe, sendNotification } = require("../firebase/firebase-handler");
const router = express.Router();
router.use(validateFirebaseIdTokenMiddleware);
router.post("/subscribe", subscribe);
router.post("/unsubscribe", unsubscribe);
router.post("/sendtestnotification", sendNotification);
module.exports = router;

View File

@@ -2,10 +2,11 @@ const express = require("express");
const router = express.Router();
const { inlinecss } = require("../render/inlinecss");
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
const validateCanvasRequestMiddleware = require("../middleware/validateCanvasRequestMiddleware");
const { canvas } = require("../render/canvas-handler");
// Define the route for inline CSS rendering
router.post("/inlinecss", validateFirebaseIdTokenMiddleware, inlinecss);
router.post("/canvas", validateFirebaseIdTokenMiddleware, canvas);
router.post("/canvas", [validateFirebaseIdTokenMiddleware, validateCanvasRequestMiddleware], canvas);
module.exports = router;

View File

@@ -11,187 +11,162 @@ const logger = require("../utils/logger");
const InstanceManager = require("../utils/instanceMgr").default;
exports.receive = async (req, res) => {
//Perform request validation
const {
ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
} = req;
logger.log("sms-inbound", "DEBUG", "api", null, {
const loggerData = {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
image_path: generateMediaArray(req.body)
});
};
if (!!!req.body || !!!req.body.MessagingServiceSid || !!!req.body.SmsMessageSid) {
logger.log("sms-inbound", "DEBUG", "api", null, loggerData);
if (!req.body || !req.body.MessagingServiceSid || !req.body.SmsMessageSid) {
logger.log("sms-inbound-error", "ERROR", "api", null, {
...loggerData,
type: "malformed-request"
});
return res.status(400).json({ success: false, error: "Malformed Request" });
}
try {
// Step 1: Find the bodyshop and existing conversation
const response = await client.request(queries.FIND_BODYSHOP_BY_MESSAGING_SERVICE_SID, {
mssid: req.body.MessagingServiceSid,
phone: phone(req.body.From).phoneNumber
});
if (!response.bodyshops[0]) {
return res.status(400).json({ success: false, error: "No matching bodyshop" });
}
const bodyshop = response.bodyshops[0];
// Sort conversations by `updated_at` (or `created_at`) and pick the last one
const sortedConversations = bodyshop.conversations.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
const existingConversation = sortedConversations.length
? sortedConversations[sortedConversations.length - 1]
: null;
let conversationid;
let newMessage = {
msid: req.body.SmsMessageSid,
text: req.body.Body,
image: !!req.body.MediaUrl0,
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
});
isoutbound: false,
userid: null // Add additional fields as necessary
};
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
});
}
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 || ""
};
if (existingConversation) {
// Use the existing conversation
conversationid = existingConversation.id;
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
},
data
});
logger.log("sms-inbound-success", "DEBUG", "api", null, {
newMessage,
fcmresp
});
res.status(200).send("");
} catch (e2) {
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
});
res.sendStatus(500).json(e2);
}
// Unarchive the conversation if necessary
if (existingConversation.archived) {
await client.request(queries.UNARCHIVE_CONVERSATION, {
id: conversationid,
archived: false
});
}
} 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
} else {
// Create a new conversation
const newConversationResponse = await client.request(queries.CREATE_CONVERSATION, {
conversation: {
bodyshopid: bodyshop.id,
phone_num: phone(req.body.From).phoneNumber,
archived: false
}
});
res.sendStatus(500).json(e1);
const createdConversation = newConversationResponse.insert_conversations.returning[0];
conversationid = createdConversation.id;
}
// Ensure `conversationid` is added to the message
newMessage.conversationid = conversationid;
// Step 3: Insert the message into the conversation
const insertresp = await client.request(queries.INSERT_MESSAGE, {
msg: newMessage,
conversationid: conversationid
});
const message = insertresp?.insert_messages?.returning?.[0];
const conversation = message?.conversation || null;
if (!conversation) {
throw new Error("Conversation data is missing from the response.");
}
// Step 4: Notify clients through Redis
const broadcastRoom = getBodyshopRoom(conversation.bodyshop.id);
const conversationRoom = getBodyshopConversationRoom({
bodyshopId: conversation.bodyshop.id,
conversationId: conversation.id
});
const commonPayload = {
isoutbound: false,
conversationId: conversation.id,
updated_at: message.updated_at,
msid: message.sid
};
ioRedis.to(broadcastRoom).emit("new-message-summary", {
...commonPayload,
existingConversation: !!existingConversation,
newConversation: !existingConversation ? conversation : null,
summary: true
});
ioRedis.to(conversationRoom).emit("new-message-detailed", {
newMessage: message,
...commonPayload,
newConversation: !existingConversation ? conversation : null,
existingConversation: !!existingConversation,
summary: false
});
// Step 5: Send FCM notification
const fcmresp = await admin.messaging().send({
topic: `${message.conversation.bodyshop.imexshopid}-messaging`,
notification: {
title: InstanceManager({
imex: `ImEX Online Message - ${message.conversation.phone_num}`,
rome: `Rome Online Message - ${message.conversation.phone_num}`,
promanager: `ProManager Message - ${message.conversation.phone_num}`
}),
body: message.image_path ? `Image ${message.text}` : message.text
},
data: {
type: "messaging-inbound",
conversationid: message.conversationid || "",
text: message.text || "",
messageid: message.id || "",
phone_num: message.conversation.phone_num || ""
}
});
logger.log("sms-inbound-success", "DEBUG", "api", null, {
newMessage,
fcmresp
});
res.status(200).send("");
} catch (e) {
handleError(req, e, res, "RECEIVE_MESSAGE");
}
};
// 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;
@@ -199,3 +174,17 @@ const generateMediaArray = (body) => {
return null;
}
};
const handleError = (req, error, res, context) => {
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,
context,
error
});
res.status(500).json({ error: error.message || "Internal Server Error" });
};

View File

@@ -8,15 +8,17 @@ const { phone } = require("phone");
const queries = require("../graphql-client/queries");
const logger = require("../utils/logger");
const client = twilio(process.env.TWILIO_AUTH_TOKEN, process.env.TWILIO_AUTH_KEY);
const { admin } = require("../firebase/firebase-handler");
const gqlClient = require("../graphql-client/graphql-client").client;
exports.send = (req, res) => {
exports.send = async (req, res) => {
const { to, messagingServiceSid, body, conversationid, selectedMedia, imexshopid } = req.body;
const {
ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
} = req;
logger.log("sms-outbound", "DEBUG", req.user.email, null, {
messagingServiceSid: messagingServiceSid,
messagingServiceSid,
to: phone(to).phoneNumber,
mediaUrl: selectedMedia.map((i) => i.src),
text: body,
@@ -27,66 +29,10 @@ exports.send = (req, res) => {
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
});
if (!!to && !!messagingServiceSid && (!!body || !!selectedMedia.length > 0) && !!conversationid) {
client.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: req.user.email,
image: req.body.selectedMedia.length > 0,
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
};
gqlClient
.request(queries.INSERT_MESSAGE, { msg: newMessage, conversationid })
.then((r2) => {
//console.log("Responding GQL Message ID", JSON.stringify(r2));
logger.log("sms-outbound-success", "DEBUG", req.user.email, null, {
msid: message.sid,
conversationid
});
const data = {
type: "messaging-outbound",
conversationid: newMessage.conversationid || ""
};
admin.messaging().send({
topic: `${imexshopid}-messaging`,
data
});
res.sendStatus(200);
})
.catch((e2) => {
logger.log("sms-outbound-error", "ERROR", req.user.email, null, {
msid: message.sid,
conversationid,
error: e2
});
//res.json({ success: false, message: e2 });
});
})
.catch((e1) => {
//res.json({ success: false, message: error });
logger.log("sms-outbound-error", "ERROR", req.user.email, null, {
conversationid,
error: e1
});
});
} else {
if (!to || !messagingServiceSid || (!body && selectedMedia.length === 0) || !conversationid) {
logger.log("sms-outbound-error", "ERROR", req.user.email, null, {
type: "missing-parameters",
messagingServiceSid: messagingServiceSid,
messagingServiceSid,
to: phone(to).phoneNumber,
text: body,
conversationid,
@@ -96,5 +42,72 @@ exports.send = (req, res) => {
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
});
res.status(400).json({ success: false, message: "Missing required parameter(s)." });
return;
}
try {
const message = await client.messages.create({
body,
messagingServiceSid,
to: phone(to).phoneNumber,
mediaUrl: selectedMedia.map((i) => i.src)
});
const newMessage = {
msid: message.sid,
text: body,
conversationid,
isoutbound: true,
userid: req.user.email,
image: req.body.selectedMedia.length > 0,
image_path: req.body.selectedMedia.length > 0 ? selectedMedia.map((i) => i.src) : []
};
try {
const gqlResponse = await gqlClient.request(queries.INSERT_MESSAGE, { msg: newMessage, conversationid });
logger.log("sms-outbound-success", "DEBUG", req.user.email, null, {
msid: message.sid,
conversationid
});
const insertedMessage = gqlResponse.insert_messages.returning[0];
const broadcastRoom = getBodyshopRoom(insertedMessage.conversation.bodyshop.id);
const conversationRoom = getBodyshopConversationRoom({
bodyshopId: insertedMessage.conversation.bodyshop.id,
conversationId: insertedMessage.conversation.id
});
ioRedis.to(broadcastRoom).emit("new-message-summary", {
isoutbound: true,
conversationId: conversationid,
updated_at: insertedMessage.updated_at,
msid: message.sid,
summary: true
});
ioRedis.to(conversationRoom).emit("new-message-detailed", {
newMessage: insertedMessage,
conversationId: conversationid,
summary: false
});
res.sendStatus(200);
} catch (gqlError) {
logger.log("sms-outbound-error", "ERROR", req.user.email, null, {
msid: message.sid,
conversationid,
error: gqlError.message,
stack: gqlError.stack
});
res.status(500).json({ success: false, message: "Failed to insert message into database." });
}
} catch (twilioError) {
logger.log("sms-outbound-error", "ERROR", req.user.email, null, {
conversationid,
error: twilioError.message,
stack: twilioError.stack
});
res.status(500).json({ success: false, message: "Failed to send message through Twilio." });
}
};

View File

@@ -5,59 +5,102 @@ require("dotenv").config({
const client = require("../graphql-client/graphql-client").client;
const queries = require("../graphql-client/queries");
const { phone } = require("phone");
const logger = require("../utils/logger");
const { admin } = require("../firebase/firebase-handler");
exports.status = (req, res) => {
exports.status = async (req, res) => {
const { SmsSid, SmsStatus } = req.body;
client
.request(queries.UPDATE_MESSAGE_STATUS, {
const {
ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
} = req;
try {
// Ignore status 'queued'
if (SmsStatus === "queued") {
return res.status(200).json({ message: "Status 'queued' disregarded." });
}
// Update message status in the database
const response = await client.request(queries.UPDATE_MESSAGE_STATUS, {
msid: SmsSid,
fields: { status: SmsStatus }
})
.then((response) => {
});
const message = response.update_messages.returning[0];
if (message) {
logger.log("sms-status-update", "DEBUG", "api", null, {
msid: SmsSid,
fields: { status: SmsStatus }
});
})
.catch((error) => {
logger.log("sms-status-update-error", "ERROR", "api", null, {
// Emit WebSocket event to notify the change in message status
const conversationRoom = getBodyshopConversationRoom({
bodyshopId: message.conversation.bodyshopid,
conversationId: message.conversationid
});
ioRedis.to(conversationRoom).emit("message-changed", {
...message,
status: SmsStatus,
type: "status-changed"
});
} else {
logger.log("sms-status-update-warning", "WARN", "api", null, {
msid: SmsSid,
fields: { status: SmsStatus },
error
warning: "No message returned from the database update."
});
}
res.sendStatus(200);
} catch (error) {
logger.log("sms-status-update-error", "ERROR", "api", null, {
msid: SmsSid,
fields: { status: SmsStatus },
stack: error.stack,
message: error.message
});
res.sendStatus(200);
res.status(500).json({ error: "Failed to update message status." });
}
};
exports.markConversationRead = async (req, res) => {
const { conversationid, imexshopid } = req.body;
admin.messaging().send({
topic: `${imexshopid}-messaging`,
// notification: {
// title: `ImEX Online Message - ${data.phone_num}`,
// body: message.image_path ? `Image ${message.text}` : message.text,
// imageUrl: "https://thinkimex.com/img/logo512.png",
// },
data: {
type: "messaging-mark-conversation-read",
conversationid: conversationid || ""
}
});
res.send(200);
};
const {
ioRedis,
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom }
} = req;
const { conversation, imexshopid, bodyshopid } = req.body;
// Inbound Sample
// {
// "SmsSid": "SM5205ea340e06437799d9345e7283457c",
// "SmsStatus": "queued",
// "MessageStatus": "queued",
// "To": "+16049992002",
// "MessagingServiceSid": "MG6e259e2add04ffa0d0aa355038670ee1",
// "MessageSid": "SM5205ea340e06437799d9345e7283457c",
// "AccountSid": "AC6c09d337d6b9c68ab6488c2052bd457c",
// "From": "+16043301606",
// "ApiVersion": "2010-04-01"
// }
// Alternatively, support both payload formats
const conversationId = conversation?.id || req.body.conversationId;
if (!conversationId || !imexshopid || !bodyshopid) {
return res.status(400).json({ error: "Invalid conversation data provided." });
}
try {
const response = await client.request(queries.MARK_MESSAGES_AS_READ, {
conversationId
});
const updatedMessageIds = response.update_messages.returning.map((message) => message.id);
const broadcastRoom = getBodyshopRoom(bodyshopid);
ioRedis.to(broadcastRoom).emit("conversation-changed", {
type: "conversation-marked-read",
conversationId,
affectedMessages: response.update_messages.affected_rows,
messageIds: updatedMessageIds
});
res.status(200).json({
success: true,
message: "Conversation marked as read."
});
} catch (error) {
console.error("Error marking conversation as read:", error);
res.status(500).json({ error: "Failed to mark conversation as read." });
}
};

View File

@@ -0,0 +1,26 @@
// Load environment variables THIS MUST BE AT THE TOP
const path = require("path");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const { networkInterfaces, hostname } = require("node:os");
const getHostNameOrIP = () => {
// Try to get the hostname first
const hostName = hostname();
if (hostName) return hostName;
const interfaces = networkInterfaces();
for (const name of Object.keys(interfaces)) {
for (const iface of interfaces[name]) {
if (iface.family === "IPv4" && !iface.internal) {
return iface.address;
}
}
}
return "127.0.0.1";
};
module.exports = getHostNameOrIP;

View File

@@ -1,8 +1,12 @@
const applyIOHelpers = ({ app, api, io, logger }) => {
const getBodyshopRoom = (bodyshopID) => `bodyshop-broadcast-room:${bodyshopID}`;
// Messaging - conversation specific room to handle detailed messages when the user has a conversation open.
const getBodyshopConversationRoom = ({bodyshopId, conversationId}) =>
`bodyshop-conversation-room:${bodyshopId}:${conversationId}`;
const ioHelpersAPI = {
getBodyshopRoom
getBodyshopRoom,
getBodyshopConversationRoom
};
// Helper middleware

View File

@@ -8,10 +8,10 @@ const InstanceManager = require("../utils/instanceMgr").default;
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 getHostNameOrIP = require("./getHostNameOrIP");
const LOG_LEVELS = {
error: { level: 0, name: "error" },
@@ -76,22 +76,6 @@ const createLogger = () => {
})();
};
const getHostNameOrIP = () => {
// Try to get the hostname first
const hostName = hostname();
if (hostName) return hostName;
const interfaces = networkInterfaces();
for (const name of Object.keys(interfaces)) {
for (const iface of interfaces[name]) {
if (iface.family === "IPv4" && !iface.internal) {
return iface.address;
}
}
}
return "127.0.0.1";
};
const createProductionTransport = (level, logStreamName, filters) => {
return new WinstonCloudWatch({
level,

View File

@@ -0,0 +1,52 @@
// Load environment variables THIS MUST BE AT THE TOP
const path = require("path");
const getHostNameOrIP = require("./getHostNameOrIP");
const logger = require("./logger");
require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const CrispStatusReporter = require("crisp-status-reporter").CrispStatusReporter;
const InstanceManager = require("../utils/instanceMgr").default;
function StartStatusReporter() {
//For ImEX Online.
return InstanceManager({
executeFunction: true,
args: [],
imex: () => {
if (
process.env.NODE_ENV === undefined ||
!process.env.CRISP_SECRET_TOKEN ||
!process.env.CRISP_SERVICE_IDENTIFIER ||
!process.env.CRISP_NODE_IDENTIFIER
) {
logger.log("crisp-status-update-error", "DEBUG", null, null, { message: "Environment Variables not set." });
return;
}
try {
const crispStatusReporter = new CrispStatusReporter({
token: process.env.CRISP_SECRET_TOKEN, // Your reporter token (given by Crisp)
service_id: process.env.CRISP_SERVICE_IDENTIFIER, // Service ID containing the parent Node for Replica (given by Crisp)
node_id: process.env.CRISP_NODE_IDENTIFIER, // Node ID containing Replica (given by Crisp)
replica_id: getHostNameOrIP(), // Unique Replica ID for instance (ie. your IP on the LAN)
interval: 30 // Reporting interval (in seconds; defaults to 30 seconds if not set)
// console: {
// debug: (log_message, data) => logger.log("crisp-status-update", "DEBUG", null, null, { log_message, data }),
// log: (log_message, data) => logger.log("crisp-status-update", "DEBUG", null, null, { log_message, data }),
// warn: (log_message, data) => logger.log("crisp-status-update", "WARN", null, null, { log_message, data }),
// error: (log_message, data) => logger.log("crisp-status-update", "ERROR", null, null, { log_message, data })
// } // Console instance if you need to debug issues,
});
return crispStatusReporter;
} catch (error) {
logger.log("crisp-status-update-error", "DEBUG", null, null, { error: error.message });
}
}
});
}
module.exports = StartStatusReporter;

View File

@@ -3,7 +3,7 @@ const { admin } = require("../firebase/firebase-handler");
const redisSocketEvents = ({
io,
redisHelpers: { setSessionData, clearSessionData }, // Note: Used if we persist user to Redis
ioHelpers: { getBodyshopRoom },
ioHelpers: { getBodyshopRoom, getBodyshopConversationRoom },
logger
}) => {
// Logging helper functions
@@ -46,18 +46,25 @@ const redisSocketEvents = ({
// Token Update Events
const registerUpdateEvents = (socket) => {
let latestTokenTimestamp = 0;
const updateToken = async (newToken) => {
const currentTimestamp = Date.now();
latestTokenTimestamp = currentTimestamp;
try {
// noinspection UnnecessaryLocalVariableJS
// Verify token with Firebase Admin SDK
const user = await admin.auth().verifyIdToken(newToken, true);
// Skip outdated token validations
if (currentTimestamp < latestTokenTimestamp) {
createLogEvent(socket, "warn", "Outdated token validation skipped.");
return;
}
socket.user = user;
// If We ever want to persist user Data across workers
// await setSessionData(socket.id, "user", user);
// Uncomment for further testing
// createLogEvent(socket, "debug", "Token updated successfully");
createLogEvent(socket, "debug", `Token updated successfully for socket ID: ${socket.id}`);
socket.emit("token-updated", { success: true });
} catch (error) {
if (error.code === "auth/id-token-expired") {
@@ -66,16 +73,20 @@ const redisSocketEvents = ({
success: false,
error: "Stale token."
});
} else {
createLogEvent(socket, "error", `Token update failed: ${error.message}`);
socket.emit("token-updated", { success: false, error: error.message });
// For any other errors, optionally disconnect the socket
socket.disconnect();
return; // Avoid disconnecting for expired tokens
}
createLogEvent(socket, "error", `Token update failed for socket ID: ${socket.id}, Error: ${error.message}`);
socket.emit("token-updated", { success: false, error: error.message });
// Optionally disconnect for invalid tokens or other errors
socket.disconnect();
}
};
socket.on("update-token", updateToken);
};
// Room Broadcast Events
const registerRoomAndBroadcastEvents = (socket) => {
const joinBodyshopRoom = (bodyshopUUID) => {
@@ -113,6 +124,7 @@ const redisSocketEvents = ({
socket.on("leave-bodyshop-room", leaveBodyshopRoom);
socket.on("broadcast-to-bodyshop", broadcastToBodyshopRoom);
};
// Disconnect Events
const registerDisconnectEvents = (socket) => {
const disconnect = () => {
@@ -130,9 +142,64 @@ const redisSocketEvents = ({
socket.on("disconnect", disconnect);
};
// Messaging Events
const registerMessagingEvents = (socket) => {
const joinConversationRoom = async ({ bodyshopId, conversationId }) => {
try {
const room = getBodyshopConversationRoom({ bodyshopId, conversationId });
socket.join(room);
} catch (error) {
logger.log("Failed to Join Conversation Room", "error", "io-redis", null, {
bodyshopId,
conversationId,
error: error.message,
stack: error.stack
});
}
};
const leaveConversationRoom = ({ bodyshopId, conversationId }) => {
try {
const room = getBodyshopConversationRoom({ bodyshopId, conversationId });
socket.leave(room);
} catch (error) {
logger.log("Failed to Leave Conversation Room", "error", "io-redis", null, {
bodyshopId,
conversationId,
error: error.message,
stack: error.stack
});
}
};
const conversationModified = ({ bodyshopId, conversationId, ...fields }) => {
try {
// Retrieve the room name for the conversation
const room = getBodyshopRoom(bodyshopId);
// Emit the updated data to all clients in the room
io.to(room).emit("conversation-changed", {
conversationId,
...fields
});
} catch (error) {
logger.log("Failed to handle conversation modification", "error", "io-redis", null, {
bodyshopId,
conversationId,
fields,
error: error.message,
stack: error.stack
});
}
};
socket.on("conversation-modified", conversationModified);
socket.on("join-bodyshop-conversation", joinConversationRoom);
socket.on("leave-bodyshop-conversation", leaveConversationRoom);
};
// Call Handlers
registerRoomAndBroadcastEvents(socket);
registerUpdateEvents(socket);
registerMessagingEvents(socket);
registerDisconnectEvents(socket);
};