Compare commits

...

492 Commits

Author SHA1 Message Date
Abhinav Raut
54e614422d refactor and fixes to convo continuity 2025-09-25 02:49:23 +05:30
Abhinav Raut
1deeaf6df3 remove unnecessary descriptions for fields and translations 2025-09-25 02:32:00 +05:30
Abhinav Raut
3a5990174b fixes to pre chat form 2025-09-25 02:20:00 +05:30
Abhinav Raut
c7291b1d1a fix layout for live chat inbox tabs for smaller devices
hide admin help text on smaller devices
2025-09-24 23:54:26 +05:30
Abhinav Raut
5de870c446 Config option to show or hide ‘Powered by Libredesk’ in the live chat widget.
Make the start conversation button a floating button and add a gradient fade overlay
2025-09-24 23:34:39 +05:30
Abhinav Raut
d7067bce7d feat: add email fallback / continutiy inbox feature for live chat inbox 2025-09-21 00:51:19 +05:30
Abhinav Raut
ed448055ed fix: newly created conversation not being added to the conversation list, simplify chat conversation SQL queries.
- add indexes to make conversation unread message count faster
2025-08-26 03:03:34 +05:30
Abhinav Raut
c721d19b81 fix migration 2025-08-24 02:27:17 +05:30
Abhinav Raut
77111835cc fix component import 2025-08-24 02:17:28 +05:30
Abhinav Raut
45a77b1422 fix build 2025-08-24 02:01:21 +05:30
Abhinav Raut
9a77c8953c Merge branch 'main' into feat/live-chat-channel 2025-08-24 01:52:12 +05:30
Abhinav Raut
18d4a8fe3b feat: auto-remove pending outgoing widget messages after 10 seconds if they have a temporary ID 2025-08-23 19:24:14 +05:30
Abhinav Raut
a2234e908f make widget expand to full viewport height
update shadows for iframe and widget
2025-08-22 02:24:23 +05:30
Abhinav Raut
d7fe6153bb Center pre chat form title 2025-08-22 02:00:53 +05:30
Abhinav Raut
68c2708464 feat: remove VisitorInfoForm component and integrate customizable pre-chat form.
- Deleted the VisitorInfoForm.vue component and its associated schema.
- Introduced a new preChatFormSchema.js to handle dynamic form validation.
- Updated ChatView.vue to conditionally display the PreChatForm based on user session and conversation state.
- Enhanced chat store to manage current conversation updates.
- Implemented WebSocket event handling for conversation updates.
- Updated localization files to include new terms related to the pre-chat form.
- Modified conversation management logic to support broadcasting updates to widget clients.
- Updated SQL queries to accommodate custom attributes for visitors.
2025-08-22 00:42:12 +05:30
Abhinav Raut
e0dc0285a4 fix: agents availability status changing to online after doing an email password login even after being Away or in Reassinging Replies status.
This was not affecting OIDC login just email password login
2025-08-19 16:34:15 +05:30
Abhinav Raut
4f9fc029c0 show uploading state when file is being uploaded from widget 2025-08-19 03:22:12 +05:30
Abhinav Raut
6cfa93838a fix: remove unnecessary filter from default icon styling in widget 2025-08-19 03:01:28 +05:30
Abhinav Raut
f72f158cf0 - show thumbnail image in widget thread instead of the entire image
- update file imports to use shared-ui utils and remove redundant file.js
- Implement SignedURLStore interface for fs store
2025-08-19 03:01:21 +05:30
Abhinav Raut
1962abdc16 feat: implement rate limiting for public widget endpoints with Redis support 2025-08-19 01:58:13 +05:30
Abhinav Raut
b971619ea6 use tabs for search results seperation also looks better now. 2025-08-14 16:12:29 +05:30
Abhinav Raut
69accaebef fix: conversations not reopening on reply from contacts (only when there's an attachment in the reply) else the convo would reopen, the conversation_uuid field was empty as it wasn't part of the get-messages query. 2025-08-14 16:11:27 +05:30
Abhinav Raut
081a5c615a fix: update main.js to import styles from shared-ui instead 2025-08-03 17:35:52 +05:30
Abhinav Raut
27de73536e Update confirmed-bug.md 2025-07-23 00:18:36 +05:30
Abhinav Raut
df108a3363 feat: add confirmed and possible bug report templates 2025-07-23 00:14:34 +05:30
Abhinav Raut
c35ab42b47 feat: configurable visitor information collection with a form before starting chat.
fix: Chat initialization failing due to the JWT authenticated user doesn't exist in the DB yet.

fix: Always upsert custom attribues instead of replacing.
2025-07-21 01:58:30 +05:30
Abhinav Raut
f05014f412 refactor: implement widget authentication middleware with standard HTTP headers
- Add widgetAuth middleware to handle JWT and inbox validation consistently
  - Move authentication logic from request body to standard HTTP headers:
    * JWT: Authorization: Bearer <token>
    * Inbox ID: X-Libredesk-Inbox-ID: <id>
  - Refactor all widget handlers to use middleware context instead of duplicate auth code
  - Frontend now sends auth headers via HTTP interceptor for all widget requests
2025-07-20 17:44:36 +05:30
Abhinav Raut
e2bba04669 Fix: Trusted domain validation for live chat widget, check the referrer header instead of origin.
- Removed the widgetOrigin middleware as it would have same origin as the iFrame URL, changed this to use `Referrer` header on initial iFrame load.
- Feat(agent-view): Added external_user_id display in the conversation sidebar.
2025-07-20 16:44:33 +05:30
Abhinav Raut
4beab72a11 feat: add external user ID support and secret field for inboxes.
Update user and inbox models, queries, and migrations
2025-07-20 16:42:03 +05:30
Abhinav Raut
26b3b30fca feat: add authenticated user support by passing JWT from parent to widget iframe.
feat: more methods to toggle wiget visibility
2025-07-20 16:40:44 +05:30
Abhinav Raut
11fd57adb0 update lucide-vue-next to version 0.525.0 2025-07-20 16:20:26 +05:30
Abhinav Raut
266c3dab72 Merge pull request #121 from abhinavxd/dependabot/go_modules/golang.org/x/oauth2-0.27.0
chore(deps): bump golang.org/x/oauth2 from 0.21.0 to 0.27.0
2025-07-20 14:42:29 +05:30
dependabot[bot]
bf2c1fff6f chore(deps): bump golang.org/x/oauth2 from 0.21.0 to 0.27.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.21.0 to 0.27.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.21.0...v0.27.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-version: 0.27.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-20 09:11:03 +00:00
Abhinav Raut
d4f644c531 feat translate widget app 2025-07-17 02:56:32 +05:30
Abhinav Raut
646bbc7efe wait for widget vue app to be ready before showing the widget icon
- show arrow down when when widget is open
2025-07-17 02:37:03 +05:30
Abhinav Raut
3c3709557e feat: Add loading indicators to chat components and improve spinner UI 2025-07-17 02:29:05 +05:30
Abhinav Raut
74732bfe91 feat: Add expand/collapse functionality to chat view 2025-07-17 01:49:22 +05:30
Abhinav Raut
8ee81c2d64 feat: Widget dark mode and chat reply expectation message in chat title.
feat: Add HTTP utility functions for trusted origin checks

feat: Implement typing status broadcasting for live chat clients and agents.

feat: Add support for signed URLs in media manager

fix: Update database migration to handle duplicate visitors with same email address.

feat: Add conversation subscription and typing message models for WebSocket communication

feat: Implement conversation subscription management in WebSocket hub this is used for broadcasting typing indicator.

feat: Revamp widget JavaScript to improve mobile responsiveness and show unread messages if any.
2025-07-17 01:06:54 +05:30
Abhinav Raut
2930af0c4f feat: add API getting started guide and update navigation 2025-07-07 01:06:28 +05:30
Abhinav Raut
389c4e3dd3 fix: allow configurable webhook request timeout from config.toml 2025-07-07 00:26:31 +05:30
Abhinav Raut
9a119e6dc3 change log level from Warn to Info for zero rules 2025-07-07 00:18:33 +05:30
Abhinav Raut
ee178d383d fix: remove hardcoded color for weekday in business hrs form
- change holiday form action label to `add`
2025-07-07 00:00:30 +05:30
Abhinav Raut
fc4db676d9 fix: correct capitalization for "Business hour" in English translation 2025-07-06 23:59:47 +05:30
Abhinav Raut
70cb3d0f80 fix: make code mirror editor fill remaining space 2025-07-06 23:59:35 +05:30
Abhinav Raut
c9920c3377 fix: set chunkSizeWarningLimit to 600 kb in build config as code mirror's ~550 kb 2025-07-06 21:44:26 +05:30
Abhinav Raut
6d62c3a4ba fix: update code mirror dark mode detection to use VueUse's useColorMode 2025-07-06 21:10:09 +05:30
Abhinav Raut
d9b5fb8f0f fix: adjust margin for URL display in webhook list data table 2025-07-06 20:39:57 +05:30
Abhinav Raut
3de320f1fb fix: correct capitalization in english translation 2025-07-06 20:34:06 +05:30
Abhinav Raut
be977dcff2 feat: show date and month below each message bubble
Use `created_at` timestamp instead of `updated_at` timestamp in message bubble.

Fixes #117
2025-07-06 20:14:18 +05:30
Abhinav Raut
5e19f13e18 fix: make vue-letter break all words for contact messages 2025-07-06 19:52:46 +05:30
Abhinav Raut
ccc5940dd9 return created message in message fetch API 2025-07-06 19:51:44 +05:30
Abhinav Raut
282dc83439 fix set correct var name 2025-07-06 18:47:19 +05:30
Abhinav Raut
61a70f6b52 clean up live chat
move last message details in the `meta` JSONB column of conversations
2025-07-06 18:46:54 +05:30
Abhinav Raut
5b6a58fba0 wip: intercom like live chat with chat widget
- new vue app for serving live chat widget, created subdirectories inside frontend dir `main` and `widget`
- vite changes for both main app and widget app.
- new backend live chat channel
- apis for live chat widget
2025-06-29 04:59:55 +05:30
Abhinav Raut
4203b82e90 Update README.md 2025-06-28 23:34:13 +05:30
Abhinav Raut
ba07e224c2 Update README.md 2025-06-21 22:11:44 +05:30
Abhinav Raut
3fff65150f Merge pull request #109 from ketan-10/migrate-codeflask-to-codemirror
Migrate codeflask to codemirror
2025-06-21 17:55:18 +05:30
ketan
c4fcf6bd91 feat: integrate CodeMirror for code editing and update styles. 2025-06-21 16:28:34 +05:30
Abhinav Raut
5ea1b9e84c fix: retain conversation view when converstion list type is changed 2025-06-21 14:44:36 +05:30
Abhinav Raut
5b522888bc Merge pull request #108 from abhinavxd/fix/post-put-handlers-return-objects
fix: Return created/updated objects in POST/PUT responses
2025-06-21 11:31:45 +05:30
Abhinav Raut
dc2250ce50 remove console log 2025-06-21 11:27:53 +05:30
Abhinav Raut
839a06f0d2 fixes to business hrs form 2025-06-20 19:35:09 +05:30
Abhinav Raut
d2e5d85e3a fix: return created/updated objects in POST/PUT responses with masked secrets
All POST/PUT handlers now return actual database objects instead of `true`
2025-06-20 19:35:09 +05:30
Abhinav Raut
0737d22374 Merge pull request #107 from ketan-10/fix/disable-crowdin-workflows-on-forks
stop crowdin workflow on forks
2025-06-19 11:52:06 +05:30
ketan
d6af9d10ea stop crowdin workflow on forks 2025-06-19 02:22:26 +05:30
Abhinav Raut
6381fc23c2 Merge pull request #105 from abhinavxd/feat/api-user
feat: API key management for agents
2025-06-19 02:05:00 +05:30
Abhinav Raut
6bb5728665 check csrf only for state-changing methods 2025-06-19 01:53:19 +05:30
Abhinav Raut
2322ec33b0 update admin role permissions to include webhooks:manage permission 2025-06-19 01:00:36 +05:30
Abhinav Raut
9132e11458 refactor return envelope errors from handlers 2025-06-19 00:14:35 +05:30
Abhinav Raut
e70f92d377 remove validation tags from loginRequest struct 2025-06-18 23:58:07 +05:30
Abhinav Raut
591108f094 fix: reset recipients when no latest message is found 2025-06-18 01:31:15 +05:30
Abhinav Raut
1b2a5e4f36 feat: standardize API requests to use JSON instead of form data 2025-06-18 01:30:38 +05:30
Abhinav Raut
f613cc237b fix schema file 2025-06-16 23:49:41 +05:30
Abhinav Raut
c37258fccb feat: API key management for agents, api keys can now be generated for any agent in libredesk allowing programmatic access.
- Added endpoints to generate and revoke API keys for agents.
- Updated user model to include API key fields.
- Update authentication middleware to support API key validation.
- Modified database schema to accommodate API key fields.
- Updated frontend to manage API keys, including generation and revocation.
- Added localization strings for API key related messages.
2025-06-16 23:45:00 +05:30
Abhinav Raut
1879d9d22b docs: add webhooks feature to README for external system integration 2025-06-15 13:56:48 +05:30
Abhinav Raut
b369e2f56a Merge pull request #104 from abhinavxd/feat/webhooks
Feat: Webhooks
2025-06-15 13:46:48 +05:30
Abhinav Raut
ef56f1a74e feat: add webhooks documentation and navigation entry 2025-06-15 13:42:50 +05:30
Abhinav Raut
d274adb19b lower case webhook data table event label 2025-06-15 13:27:56 +05:30
Abhinav Raut
d31fcb00b6 refactor: simplify checkbox event handling in webhook form 2025-06-15 13:22:27 +05:30
Abhinav Raut
88d719ec4f fix remove unncessary indexes on webhooks table 2025-06-15 13:10:19 +05:30
Abhinav Raut
147180a536 remove unused headers column from webhooks table 2025-06-15 13:05:14 +05:30
Abhinav Raut
faa195f0a6 fix typo in prepared query 2025-06-15 12:43:28 +05:30
Abhinav Raut
4b0422d904 Reduce duplicate database conversation fetches by adding a new method EvaluateConversationUpdateRulesByID that accepts only a conversation ID instead of a conversation object, allowing the caller to control the behavior since the caller might already have the conversation object or might want a fresh fetch.
- Return early if user / team is already assigned to a conversation

- rename query get-pending-messages to get-outgoing-pending-messages to reflect purpose.
2025-06-15 12:43:01 +05:30
Abhinav Raut
9303997cea feat: update sidebar title translation to support pluralization for integration nav item 2025-06-14 22:28:46 +05:30
Abhinav Raut
aba07b3096 fix transalations for webhooks 2025-06-14 22:24:41 +05:30
Abhinav Raut
27aac88f53 update webhook payloads, send reduced payloads showing what changed instead of entire objects 2025-06-14 21:51:11 +05:30
Abhinav Raut
cb6b0e420b adds missing colunms from select query were missing for webhook events 2025-06-14 21:49:31 +05:30
Abhinav Raut
e004afd7d1 fix: remove unused email fields from Conversation model 2025-06-14 21:49:01 +05:30
Abhinav Raut
6a77d346dc feat: add version package to hold application version for build time configuration 2025-06-14 21:48:55 +05:30
Abhinav Raut
60c89cb617 use http client with set timeout for webhook requests
remove db calls from `TriggerEvent`
2025-06-14 21:48:46 +05:30
Abhinav Raut
b7d4b187e8 fix: adds missing conversation update rule evaluation missing for event EventConversationTeamAssigned
refactor code
2025-06-14 19:55:24 +05:30
Abhinav Raut
2bf45f32de fix: remove headers field from webhook model and related queries 2025-06-14 15:03:11 +05:30
Abhinav Raut
981372ab86 wip webhooks 2025-06-13 02:17:00 +05:30
Abhinav Raut
803196985d fix: allow sending messages with just media files and no text in the editor 2025-06-08 16:42:35 +05:30
Abhinav Raut
ebf6a980e8 fix: update keyboard shortcuts label from cmd + k to ctrl + k 2025-06-08 16:35:59 +05:30
Abhinav Raut
813ef91964 fix: allow sending messages with ONLY attached media files and NO content in editor 2025-06-08 16:35:20 +05:30
Abhinav Raut
3b9fb7a08d fix: change 'id' prop requirement to optional with default value of null in CreateEditTemplate 2025-06-08 16:19:59 +05:30
Abhinav Raut
7fb86f140c fix: pass missing mediafiles prop to ReplyBoxContent for fullscreen and remove unused variables 2025-06-08 16:18:16 +05:30
Abhinav Raut
aa8d326fa1 fix: set default value of 'enabled' to true in EmailInboxForm 2025-06-08 16:17:32 +05:30
Abhinav Raut
ca9a0a5892 fix: update deletion confirmation messages for automation rules, SSO, status, tags, and templates 2025-06-08 16:17:26 +05:30
Abhinav Raut
73e2950174 fix: disable autoFocus on the Editor component in ActionBox 2025-06-08 16:14:32 +05:30
Abhinav Raut
e7b8e5c4bb fix(frontend): variable reference error 2025-06-08 15:41:24 +05:30
Abhinav Raut
582c906440 remove unncessary compiler macro imports 2025-06-08 15:25:04 +05:30
Abhinav Raut
f3881ee0aa fix(ci): update database and Redis host configurations in frontend CI workflow to localhost 2025-06-08 15:15:43 +05:30
Abhinav Raut
b557c2ca4b fix: update dialog title to use localized term for 'view' 2025-06-08 15:13:22 +05:30
Abhinav Raut
30884d3536 update(i18n): marathi translations 2025-06-08 15:13:22 +05:30
Abhinav Raut
bce0d1d12f Update README.md 2025-06-08 14:59:22 +05:30
Abhinav Raut
67a4f6a162 fix(docs): remove outdated instructions for editing config.toml in installation guide 2025-06-08 14:58:30 +05:30
Abhinav Raut
ec28ac8f3a Update README.md 2025-06-08 14:57:30 +05:30
Abhinav Raut
bc71fcfdc1 fix: add fallback for config typo in message outgoing scan interval key 2025-06-08 14:55:23 +05:30
Abhinav Raut
bc0bee8f6a update config.sample.toml with improved comments and configuration values 2025-06-08 14:55:15 +05:30
Abhinav Raut
499fc0dad1 fix: update SimpleTable component with loading state and skeleton rows
- remove existing loading table state from activity logs
2025-06-08 12:44:05 +05:30
Abhinav Raut
03b932c1c0 fix: handle empty inbox email address in fetchAndProcessMessages 2025-06-08 12:17:58 +05:30
Abhinav Raut
012de059e7 fix: remove conflicting constraints on NOT NULL columns in applied_slas and csat_responses tables, update constraints for csat_responses and applied_slas tables to cascade deletes instead 2025-06-08 11:50:22 +05:30
Abhinav Raut
6357faf6c8 fix csat page remove unncessary text and reference number
- fix form validations
2025-06-08 11:46:12 +05:30
Abhinav Raut
f7a12cffd3 fix: agent password regex validation
- adds vitest dev dep
- adds test cases for the agent form
- replace form loader svg with css spinner
2025-06-08 11:26:26 +05:30
Abhinav Raut
6487bf9a0a fix: validate and normalize email input in agent creation and update 2025-06-08 10:41:05 +05:30
Abhinav Raut
53d5715429 fix: implement loop prevention header in emails
#Ref 90
2025-06-08 02:22:36 +05:30
Abhinav Raut
b561e79440 fix: cypress test 2025-06-08 01:27:10 +05:30
Abhinav Raut
e567acbe59 fix(sql): overview report for days = 0 i.e. todays data 2025-06-08 01:24:01 +05:30
Abhinav Raut
57d0e90b5f refactors(i18n-keys): use correct keys in vue and go errors
- fix: invalidate agent cache on update / create
2025-06-08 00:59:15 +05:30
Abhinav Raut
5a0e3a8072 refactor: remove unncessary oidc test handler 2025-06-08 00:51:09 +05:30
Abhinav Raut
d95a5f40cf feat: add agent cache in user package
- use cached agent in all middlewares
- fix: race in casbin that gave permission denied error.
- stop loading permissions into casbin on every `Enforce` function call instead cache user permissions in authz package and when permissions change only the load permission as policies atomically.
- sort permissions in get-agents to make the permissions slice comparsion using slices.Equal work
2025-06-07 23:43:21 +05:30
Abhinav Raut
6981a0790d fix: update contacts route title to 'All contacts' 2025-06-07 22:38:43 +05:30
Abhinav Raut
55bc9bfc91 fix: safely access sender_id in handleNewMessage 2025-06-07 22:38:36 +05:30
Abhinav Raut
67db2e5ff2 fix: contact note text color in dark mode 2025-06-07 22:38:23 +05:30
Abhinav Raut
64304c2384 fix: reference err 2025-06-07 22:37:59 +05:30
Abhinav Raut
c5fe6aaadd open comobox on click of select tag component 2025-06-07 22:37:50 +05:30
Abhinav Raut
fea7eef658 dynamic translations in date filter component 2025-06-07 22:37:07 +05:30
Abhinav Raut
475e400810 fix: move isActive parent before watcher 2025-06-07 22:34:59 +05:30
Abhinav Raut
641ae0540e feat(sidebar): implement collapsible functionality for admin navigation based on active routes, this will make sure non active collsapsibles will close when another is opened 2025-06-07 22:32:41 +05:30
Abhinav Raut
dc6fede081 fix(inbox-layout): remove unnecessary keep-alive wrapper around router-view component preventing same conversation from being loaded again in different conversation list 2025-06-07 20:23:08 +05:30
Abhinav Raut
28dcd6cb2f fix(conversation-list): prevent error when setting unread count by checking item index 2025-06-07 19:58:45 +05:30
Abhinav Raut
ade833fb7b fix: waiting_since timestamp being set to null on a non-outgoing message.
- Set to null only after outgoing message is sent
- change imap log from debug to info
2025-06-07 19:57:28 +05:30
Abhinav Raut
5bcb0a2ad9 fix(dialog): add missing DialogDescription for a11y 2025-06-07 19:23:06 +05:30
Abhinav Raut
ad2f685fec add redis to dev setup pre-requisites 2025-06-07 19:00:20 +05:30
Abhinav Raut
26c7df538c refactor(i18n): consolidate duplicate keys into reusable globals
- Move form.field.* to globals.terms.*
- Replace select messages with globals.messages.select
- Use shared success/deletion confirmation messages
- Reduce ~50 duplicate keys
2025-06-07 18:58:17 +05:30
Abhinav Raut
625a08d0aa feat(conversation): add support for nested websocket property updates and broadcast changes for all contact and custom attributes 2025-06-07 18:58:17 +05:30
Abhinav Raut
bf1510b9c3 Merge pull request #101 from abhinavxd/dependabot/npm_and_yarn/frontend/vite-5.4.19
chore(deps-dev): bump vite from 5.4.18 to 5.4.19 in /frontend
2025-06-06 11:09:24 +05:30
dependabot[bot]
bae896d38d chore(deps-dev): bump vite from 5.4.18 to 5.4.19 in /frontend
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.18 to 5.4.19.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.19/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.19/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.19
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-05 22:46:23 +00:00
Abhinav Raut
37b7c05b30 update hero image 2025-06-06 04:06:20 +05:30
Abhinav Raut
eb05368f18 Update README.md 2025-06-06 04:03:13 +05:30
Abhinav Raut
7ef510894b feat(docs): add hero image and improve documentation formatting 2025-06-06 04:03:03 +05:30
Abhinav Raut
69268a3a84 Update README.md 2025-06-06 03:39:43 +05:30
Abhinav Raut
fcd3462d25 Update README.md 2025-06-06 03:39:17 +05:30
Abhinav Raut
fbf502451a fix(CommandBox): reorder CommandItems move macro on top 2025-06-06 03:23:43 +05:30
Abhinav Raut
dc909ceb4f feat(vite): Add manual chunking for optimized build output
refactor(router): Lazy load route components for improved performance
2025-06-06 03:20:32 +05:30
Abhinav Raut
cc1432b3e4 refactor(editor): Remove unncessary props and simplify code for tiptap editor, update all editors for the same 2025-06-06 03:02:11 +05:30
Abhinav Raut
d532a99771 feat: new package report, move exisiting report code from conversations pkg to report package
- new sla performance overview cards.
2025-06-06 02:07:19 +05:30
Abhinav Raut
50baa3f38e remove redundant comments for Manager struct across multiple files 2025-06-06 01:04:07 +05:30
Abhinav Raut
63a8f04408 fix: conversation list view filters, views do not need list status as views are already filtered. 2025-06-05 15:58:29 +05:30
Abhinav Raut
ea0b7d6d52 docs: update email templating docs with complete variable reference
- adds new `Author` template var and injects it into all templates
- make author fields empty for all automated system generated emails
2025-06-05 01:55:38 +05:30
Abhinav Raut
5d6897a960 fix: filter conversation list by status, this will immediately remove conversation from the list if status is different than the one applied to the list.
- remove redundant error title in toast notifications
2025-06-05 01:47:10 +05:30
Abhinav Raut
c4a95672fe update simples3 2025-06-04 17:12:58 +05:30
Abhinav Raut
2efd07b405 add error logs for casbin errors 2025-06-04 00:07:27 +05:30
Abhinav Raut
0b9cf38826 fix: update assignee last seen only if current conversation is open
- standardize timestamp function to uppercase NOW() in SQL queries
2025-06-04 00:07:13 +05:30
Abhinav Raut
b44c314299 update delete confirmation message for agent in alert dialog 2025-06-03 23:46:53 +05:30
Abhinav Raut
2e1188e443 Merge pull request #100 from abhinavxd/feat/allow-macro-in-new-conversations
Feat: Allow setting macro in new conversations along with attachments
2025-06-03 23:05:29 +05:30
Abhinav Raut
afeec39b59 make subject required, submit new conversation form on ctrl + enter 2025-06-03 23:03:06 +05:30
Abhinav Raut
fb2a08ec1a fix: SelectCombobox adjust item and selected templates for proper emoji rendering 2025-06-03 22:36:49 +05:30
Abhinav Raut
7f2df0082c fix: remove autofocus from text editor as create conversation form opens.
- Adds new prop autofocus on text editor
- rename ConversationTextEditor to TextEditor
2025-06-03 22:10:56 +05:30
Abhinav Raut
6c523ac447 fix: update labels and placeholders for user selection to agent in MacroForm 2025-06-03 05:19:13 +05:30
Abhinav Raut
02fc57c35a macro form fixes 2025-06-03 05:08:41 +05:30
Abhinav Raut
cd0a357695 fix: change order of macros selection to updated_at descending 2025-06-03 04:41:24 +05:30
Abhinav Raut
2dc751e602 fixes incorrect v-model binding 2025-06-03 04:29:28 +05:30
Abhinav Raut
8bc0cce993 fix: update default values for visible_when column in macros table 2025-06-03 04:08:29 +05:30
Abhinav Raut
f6e2fc1956 feat: allow sending attachments in new conversations
- replace existing combobox selects with common component selectcombobox.vue
2025-06-03 04:03:16 +05:30
Abhinav Raut
5fe5ac5882 fix: change order of macros selection to usage_count descending 2025-06-03 00:56:16 +05:30
Abhinav Raut
975577555d WIP: allow setting macro in new conversations along with attachments
- new composable useFileUpload.js
2025-06-02 03:56:04 +05:30
Abhinav Raut
f43acb77a1 use loader animation instead of dot loader in shadcn button 2025-05-31 23:46:08 +05:30
Abhinav Raut
331c84fa56 use dot loader to use tailwind animations 2025-05-31 23:40:42 +05:30
Abhinav Raut
9314efb9d9 refactor: clean up main.css move animation to tailwind config 2025-05-31 23:38:44 +05:30
Abhinav Raut
5c8481af97 feat: tooltips to icon side
refactor: remove unncessary extra i18n keys instead use reusable 'globals.terms.*' keys.
2025-05-31 20:11:47 +05:30
Abhinav Raut
d9bc4d1c0d fix: update conversation list item last message timestamp every 60 seconds 2025-05-31 18:54:08 +05:30
Abhinav Raut
087c8ad491 fix: incorrect label in macro form team select combobox 2025-05-31 18:35:48 +05:30
Abhinav Raut
65cac843cb fix: IMAP fetch blocking introduced by header fetching in this commit - 9a651702ce
Two separate fetches caused blocking when first fetch wasn't fully consumed.
Collect all envelope/header data first, then process to prevent deadlock.
2025-05-31 18:24:05 +05:30
Abhinav Raut
23b0481f24 update reference number format in conversation insert query, use - #number format instead of square bracket 2025-05-30 02:01:51 +05:30
Abhinav Raut
9a651702ce fix[imap]: skip auto reply email messages
Fixes #94
2025-05-30 02:00:18 +05:30
Abhinav Raut
a0203f882e fix: allow changing conversation status to resolved again & again as agent might change the snooze duration 2025-05-30 00:46:30 +05:30
Abhinav Raut
75425ca0dd Merge pull request #98 from abhinavxd/feat/dark-mode
feat: dark mode
2025-05-29 01:52:31 +05:30
Abhinav Raut
c2849fa63d fix views sidebar collapsible trigger 2025-05-29 01:48:29 +05:30
Abhinav Raut
b20c7845ac update sidebar icons for inbox navigation 2025-05-29 01:34:38 +05:30
Abhinav Raut
38a5b25b1f remove search icon from search header 2025-05-29 01:11:14 +05:30
Abhinav Raut
9dce155ebc fix: sidebar header spacing and ui improvements for search icon 2025-05-29 01:10:58 +05:30
Abhinav Raut
314341b40d fix: make both sections of macro preview 1. list and 2. preview scrollable separately.
- Update styles and colors for dark mode
2025-05-29 00:53:10 +05:30
Abhinav Raut
1f6e3322aa update sidebar background color and improve dark mode styles / colors
fix: email validation trigger in reply box
2025-05-29 00:52:16 +05:30
Abhinav Raut
102ba99b3c fix: toggle fullscreen state correctly in ReplyBox component 2025-05-29 00:39:14 +05:30
Abhinav Raut
8285575f1c update styles for convo list 2025-05-28 02:31:34 +05:30
Abhinav Raut
01d3b590a9 update sidebar foreground text color to improve contrast 2025-05-28 02:02:54 +05:30
Abhinav Raut
210e0de1ae feat: dark mode 2025-05-28 01:50:35 +05:30
Abhinav Raut
1f8fdf2ef6 Merge pull request #95 from abhinavxd/feat/sla-metric-next-response-time
Feature : Next response SLA metric
2025-05-27 03:19:52 +05:30
Abhinav Raut
696e4780ac refactor: reuse existing i18n keys for sla translations 2025-05-27 02:52:33 +05:30
Abhinav Raut
3998798e54 refactor: rename SQL query names and struct fields for clarity and consistency 2025-05-27 02:45:43 +05:30
Abhinav Raut
70b5da29e1 fix: change SLA deadline fields to use nullable types 2025-05-26 03:48:41 +05:30
Abhinav Raut
88ef5d26db fix: update sla timestamps to nullable types 2025-05-26 03:48:41 +05:30
Abhinav Raut
54bad59392 fix: getConversation to handle nullable UUID parameter 2025-05-26 03:48:41 +05:30
Abhinav Raut
506bb91e20 fix: make sla metric timestamps nullable 2025-05-26 03:48:41 +05:30
Abhinav Raut
d1478e1971 fix: clarify comment on SendNotification method regarding SLA linkage 2025-05-26 03:48:41 +05:30
Abhinav Raut
5583b472f7 fix: change debug logs to info level for scheduled SLA notifications 2025-05-26 03:48:41 +05:30
Abhinav Raut
b715483260 refactor(conversation): reduce DB I/O by using existing appliedSLAID from conversation
- Passes appliedSLAID directly to SLA logic instead of refetching
- Adds appliedSLAID field to conversation struct (already fetched in get-conversation query)
2025-05-26 03:48:41 +05:30
Abhinav Raut
8ce0464603 fix: simplify time_delay validation in SLA notification schema 2025-05-26 03:48:41 +05:30
Abhinav Raut
a84ed1ed32 Allow setting any value for SLA delay duration, replace select with input text
Validations to delay duration
2025-05-26 03:48:41 +05:30
Abhinav Raut
7426a09478 feat: allow setting metric per SLA notification, so admins can set SLA alert per metric or just set to all if they want a notification to be sent for all metrics
- Make sla time fields (first response, next response, resolution) optional, only 1 field is required.
2025-05-26 03:48:41 +05:30
Abhinav Raut
8ad2f078ac fix sql query 2025-05-26 03:48:41 +05:30
Abhinav Raut
9226063db3 fix: remove queries using conversation.applied_sla_id
as this column is removed
- fix sql query
2025-05-26 03:48:41 +05:30
Abhinav Raut
a9fd4fe2b6 fix: uise existing set next sla deadline sql query and remove duplicate query.
- remove previously added `applied_sla_id` column to conversations table as it was causing cyclic dep
2025-05-26 03:48:41 +05:30
Abhinav Raut
7e8c9962c3 Fixes for next response time sla metric 2025-05-26 03:48:41 +05:30
Abhinav Raut
cf20142e40 fix(sla-badge): emit SLA status on change so callers can react 2025-05-26 03:48:41 +05:30
Abhinav Raut
8654a04dcf fix: make sure sla badges re-render on timestamp changes, use a composite :key 2025-05-26 03:48:41 +05:30
Abhinav Raut
4c766d8ccb wip: next response metric for sla 2025-05-26 03:48:41 +05:30
Abhinav Raut
cb1ec7eb8e fix(availability-status): prevent 'away_and_reassigning' and away_manual agents from being set to 'offline' due to incorrect SQL 2025-05-24 19:42:41 +05:30
Abhinav Raut
a89c3dbe04 fix(agent-availability): skip activity log creation when agent returns online from inactivity as it can spam activity logs. 2025-05-20 00:53:28 +05:30
Abhinav Raut
e2319714ca fix(imap-email): lowercase all envelope email addresses for consistent matching and deduplication 2025-05-20 00:02:15 +05:30
Abhinav Raut
172f78262e docs: fix typo 2025-05-18 22:06:59 +05:30
Abhinav Raut
f53d5f188f docs: update Nginx configuration for client IP and set max body size to 100MB, remove bold styling from sso headings. 2025-05-18 22:04:13 +05:30
Abhinav Raut
55ec962003 fix(activity-log): replace RemoteIP with fast-realip pkg for accurate IP retrieval
- Update go version to 1.24.3
2025-05-18 21:48:15 +05:30
Abhinav Raut
d3b1955cb2 fix(activity-log): update activity type labels from 'User' to 'Agent' 2025-05-18 21:48:15 +05:30
Abhinav Raut
fac496fef2 Update README.md 2025-05-18 13:27:42 +05:30
Abhinav Raut
c36a425a1e Update README.md
Add activity log feature description to README
2025-05-18 13:27:07 +05:30
Abhinav Raut
f43ab5041e feat(auth): record login time and insert activity log for OIDC login 2025-05-18 12:06:09 +05:30
Abhinav Raut
cd0ff1b67d Revert: Add subject back to conversation sidebar as old conversations will not have subject in message meta, so the sidebar subject needs to be shown for now. 2025-05-17 21:54:39 +05:30
Abhinav Raut
5bc065469d Merge pull request #92 from abhinavxd/feat/activity/audit-log
Feature - Activity log / audit log
2025-05-17 21:23:18 +05:30
Abhinav Raut
77be86b1f4 chore: move features/filterbuilder.vue to components/filterbuilder.vue 2025-05-17 21:18:32 +05:30
Abhinav Raut
dde84c65b0 fix(activity-log): update header label from 'date' to 'timestamp' 2025-05-17 21:07:35 +05:30
Abhinav Raut
f2d4969733 fix(activity-log): remove unused Card import 2025-05-17 19:56:55 +05:30
Abhinav Raut
aeececd001 fix(activity-log): Improve loading state layout and set default items per page to 15 2025-05-17 19:56:28 +05:30
Abhinav Raut
fdeeda8bca fix(schema): Update admin role permissions to include activity logs manage permission 2025-05-17 19:31:31 +05:30
Abhinav Raut
45bae57183 remove unused import 2025-05-17 19:29:47 +05:30
Abhinav Raut
a345b2e322 fix(contact-list): use ArrowDownWideNarrow for consistent sort icon 2025-05-17 19:23:45 +05:30
Abhinav Raut
490aaedb48 fix: update activity log types to use agent prefixes for consistency 2025-05-16 23:11:22 +05:30
Abhinav Raut
87361e5cda fix: adjust padding in ActivityLog layout for consistent spacing 2025-05-16 23:01:24 +05:30
Abhinav Raut
c039d5a20f fix: refactor filter builder layout for improved responsiveness and do not clear state on unmount 2025-05-16 23:01:24 +05:30
Abhinav Raut
53f15a3a7e fix: set user availability status to online instead of offline when admin selects "active" in the user availability dropdown 2025-05-16 23:01:24 +05:30
Abhinav Raut
a397d3d3ea fix: lowercase empty message for simple table 2025-05-16 23:01:24 +05:30
Abhinav Raut
4ca123e6a1 fix: swapped target and actor, set them correctly 2025-05-16 23:01:24 +05:30
Abhinav Raut
7dd5abdda6 fix: swapped target and actor, set them correctly 2025-05-16 23:01:24 +05:30
Abhinav Raut
c16144a2bf fix: schema 2025-05-16 23:01:24 +05:30
Abhinav Raut
7f1c2c2f11 feat(wip): activity log / audit log
- single table stores acitivites against entities, actors, timestamps, ip addresses and activity description.
- admin page to view, sort and filter activity logs.
- new `activity_logs:manage` permission
2025-05-16 23:01:24 +05:30
Abhinav Raut
d8a681d17e reduce border radius from 0.75rem to 0.5rem 2025-05-16 23:01:07 +05:30
Abhinav Raut
f657a873bc Merge pull request #85 from abhinavxd/fix/email-channel-to-bcc-cc
Fix and Improve Email Recipients Handling in Conversations
2025-05-15 11:08:55 +05:30
Abhinav Raut
88e07c324d fix(useIdleDetection): debounce online status update to prevent duplicate calls 2025-05-12 21:59:06 +05:30
Abhinav Raut
6c9eca3d81 fix: do not computed bcc from latest message. 2025-05-11 20:26:33 +05:30
Abhinav Raut
07b185050e fix: empty recipients in automated replies
- Make recipients list from the latest message recipients for automated replies
2025-05-11 18:51:34 +05:30
Abhinav Raut
66886c34e5 hide conversation subject from sidebar as each message in thread shows the subject (envelope) 2025-05-11 14:40:47 +05:30
Abhinav Raut
0af7265178 refactor: remove unused GetToAddress function and related SQL query 2025-05-11 14:18:07 +05:30
Abhinav Raut
f722de2fe4 fix: handle empty to and from addresses in message meta,
- remove unncessary console log
2025-05-11 14:11:20 +05:30
Abhinav Raut
6b2be57049 fix: set correct recipients when a 3rd email is involved in conversation, link to thread discussing this - https://github.com/abhinavxd/libredesk/issues/74#issue-3021419913
refactor move recipient computation to /utils/email-recipients
2025-05-10 23:46:19 +05:30
Abhinav Raut
e1b2ec8a4b wip: fix to, bcc, cc handling
- allow agent to set the to address, adds a to address input in the reply box.
- show to, from, bcc and subject in each message
- always use email addresses from message meta instead of querying via get-to-address
- Reorder notification form fields.
- Refactors and adhoc fixes.
2025-05-09 04:30:30 +05:30
Abhinav Raut
8d47a7456d Merge pull request #76 from abhinavxd/dependabot/go_modules/github.com/go-jose/go-jose/v4-4.0.5
chore(deps): bump github.com/go-jose/go-jose/v4 from 4.0.2 to 4.0.5
2025-05-03 21:08:32 +05:30
dependabot[bot]
62023695a5 chore(deps): bump github.com/go-jose/go-jose/v4 from 4.0.2 to 4.0.5
Bumps [github.com/go-jose/go-jose/v4](https://github.com/go-jose/go-jose) from 4.0.2 to 4.0.5.
- [Release notes](https://github.com/go-jose/go-jose/releases)
- [Changelog](https://github.com/go-jose/go-jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/go-jose/go-jose/compare/v4.0.2...v4.0.5)

---
updated-dependencies:
- dependency-name: github.com/go-jose/go-jose/v4
  dependency-version: 4.0.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-03 15:34:59 +00:00
Abhinav Raut
a212ed4afb Merge pull request #77 from abhinavxd/dependabot/npm_and_yarn/frontend/vue-i18n-9.14.3
chore(deps): bump vue-i18n from 9.14.2 to 9.14.3 in /frontend
2025-05-03 21:04:19 +05:30
Abhinav Raut
8e6bea09fe Merge pull request #78 from abhinavxd/dependabot/npm_and_yarn/frontend/vite-5.4.18
chore(deps-dev): bump vite from 5.4.11 to 5.4.18 in /frontend
2025-05-03 21:04:09 +05:30
Abhinav Raut
71e2e3cd8a Merge pull request #80 from abhinavxd/dependabot/go_modules/github.com/redis/go-redis/v9-9.5.5
chore(deps): bump github.com/redis/go-redis/v9 from 9.5.4 to 9.5.5
2025-05-03 21:03:52 +05:30
dependabot[bot]
59f5084bec chore(deps): bump github.com/redis/go-redis/v9 from 9.5.4 to 9.5.5
Bumps [github.com/redis/go-redis/v9](https://github.com/redis/go-redis) from 9.5.4 to 9.5.5.
- [Release notes](https://github.com/redis/go-redis/releases)
- [Changelog](https://github.com/redis/go-redis/blob/master/CHANGELOG.md)
- [Commits](https://github.com/redis/go-redis/compare/v9.5.4...v9.5.5)

---
updated-dependencies:
- dependency-name: github.com/redis/go-redis/v9
  dependency-version: 9.5.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-30 06:58:01 +00:00
dependabot[bot]
87e1477811 chore(deps-dev): bump vite from 5.4.11 to 5.4.18 in /frontend
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.11 to 5.4.18.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.18/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.18/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.18
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-30 06:57:15 +00:00
dependabot[bot]
10d3da608c chore(deps): bump vue-i18n from 9.14.2 to 9.14.3 in /frontend
Bumps [vue-i18n](https://github.com/intlify/vue-i18n/tree/HEAD/packages/vue-i18n) from 9.14.2 to 9.14.3.
- [Release notes](https://github.com/intlify/vue-i18n/releases)
- [Changelog](https://github.com/intlify/vue-i18n/blob/v9.14.3/CHANGELOG.md)
- [Commits](https://github.com/intlify/vue-i18n/commits/v9.14.3/packages/vue-i18n)

---
updated-dependencies:
- dependency-name: vue-i18n
  dependency-version: 9.14.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-30 06:56:55 +00:00
Abhinav Raut
0de7c91641 Update README.md 2025-04-29 17:32:36 +05:30
Abhinav Raut
61ec075bd6 fix(editor): prevent sidebar collapse on Ctrl+B inside editor 2025-04-29 02:10:34 +05:30
Abhinav Raut
0b2c607cd3 fix(deps): update simples3 to v0.9.0 2025-04-28 23:48:24 +05:30
Abhinav Raut
0556318714 set sane defaults for email notification smtp settings 2025-04-28 01:49:12 +05:30
Abhinav Raut
7b35cf0abf fix: improve regex validation for go duration 2025-04-28 01:47:23 +05:30
Abhinav Raut
8619aa8e17 fix(conversation): reorder timestamps, the last reply timestamp in conversation info sidebar 2025-04-27 23:13:33 +05:30
Abhinav Raut
25db57805e feat(macros): Filter out macro actions from preview to which agent does not have permissions,
feat(permissions): Seperate our permissions into `src/constants/permissions.js`
2025-04-27 23:09:58 +05:30
Abhinav Raut
3b2d0d049f Merge pull request #69 from jleroy/feature/conversation-last-reply
Add new automations rules filters "Hours since first reply" and "Hours since last reply"
2025-04-17 02:02:00 +05:30
Jonathan Leroy
1c6d03a4c2 Merge branch 'main' into feature/conversation-last-reply 2025-04-16 21:47:54 +02:00
Abhinav Raut
062e0c39da fix[api]: allow all authenticated users to list business hours.
Replaced `perm(..., "business_hours:manage")` with `auth(...)` on
GET /api/v1/business-hours since listing hours isn’t a sensitive action and is required in the general settings tab
2025-04-17 01:16:38 +05:30
Abhinav Raut
67090fb052 fix(docker): restrict Redis port binding to local interface 2025-04-17 00:51:27 +05:30
Abhinav Raut
c434de130b Merge pull request #63 from MinecollYT/main
Update docker-compose.yml to enhance healthcheck
2025-04-17 00:30:23 +05:30
Abhinav Raut
4e4f07f2e8 Merge pull request #67 from jleroy/feature/improve-tags-actions
Add new actions "Add tags" and "Remove tags" for macros and automations
2025-04-16 23:29:13 +05:30
Abhinav Raut
19a507c88f fix: SLA badge showing hit status for overdue 2025-04-16 14:32:14 +05:30
Jonathan Leroy
ac61d43688 Add new automations rules filters "Hours since first reply" and "Hours
since last reply"
2025-04-15 20:40:40 +02:00
Abhinav Raut
7f8e3ccbbc fix[automation]: handle empty fieldType in the frontend breaking the automation view.
fix: Previously removed operators for Number type field.
Fixes #68
2025-04-15 21:31:19 +05:30
Jonathan Leroy
facce8bdad Add new actions "Add tags" and "Remove tags" for macros and automations 2025-04-15 13:55:58 +02:00
Abhinav Raut
8acad27b75 feat: display last name in notes list 2025-04-15 03:48:56 +05:30
Abhinav Raut
24fbe14804 fix: allow setting phone number and calling code to null in update contact query 2025-04-15 03:46:24 +05:30
Abhinav Raut
061677f2b0 fix: update phone number validation to allow shorter lengths 2025-04-15 03:45:54 +05:30
Abhinav Raut
450b609d47 fix: handle null string form value for update contact form 2025-04-15 03:45:45 +05:30
Abhinav Raut
971a433f3d fix: typo in contact permission 2025-04-15 03:32:53 +05:30
Abhinav Raut
220321bb8c Merge pull request #64 from abhinavxd/feat/custom-attributes-and-notes
feat: custom attributes and contact notes
2025-04-15 03:11:53 +05:30
Abhinav Raut
d5ba70667d feat: allow notes to be deleted only by the owner of note and any agent with role Admin 2025-04-15 02:47:52 +05:30
Abhinav Raut
a9f9d368b9 refactor: replace contacts:manage with with more granular permissions like contacts:read_all, contacts:read, contacts:block etc.
fix: filter out unknown permissions from role form while submitting
fix: correct strong password validation logic on reset
refactor: remove contact note update handler as it adds more complexity agents can simply delete note.
feat: new block contact api which required contacts:block perm
2025-04-15 02:23:47 +05:30
Abhinav Raut
2fc642c34e feat: allow using contact custom attributes in automation rules 2025-04-15 02:23:47 +05:30
Abhinav Raut
488f14e87c fix(migrations): create index for contact notes table if not exists 2025-04-15 02:23:47 +05:30
Abhinav Raut
3702a61d74 fix(migrations): create contact notes table query 2025-04-15 02:23:47 +05:30
Abhinav Raut
b01f6f812d fix[ui]: add border and padding to CardHeader in ContactNotes header 2025-04-15 02:23:47 +05:30
Abhinav Raut
a0c77bc12e feat: contact notes
refactor:  split code in internal/users/users.go into following files
internal/users/notes.go
internal/users/agent.go
internal/users/contact.go
2025-04-15 02:23:47 +05:30
Abhinav Raut
8bc511509c fix: passing conversation loading state to custom attributes component 2025-04-15 02:23:47 +05:30
Abhinav Raut
0254bab266 feat: regex hint 2025-04-15 02:23:47 +05:30
Abhinav Raut
91372f5339 fix: cypress test 2025-04-15 02:23:47 +05:30
Abhinav Raut
d69a8c58d1 feat: custom attributes for contacts and conversations 2025-04-15 02:23:47 +05:30
Abhinav Raut
4e893ef876 fix: refactor link styles in OIDC and Templates documentation to use a shared class 2025-04-14 23:50:37 +05:30
Abhinav Raut
5770188e4d fix: add "Learn more" link to OIDC and Templates documentation 2025-04-14 23:14:22 +05:30
Abhinav Raut
8bd7895ccf fix: update section headers for clarity in templating documentation 2025-04-14 23:06:32 +05:30
Abhinav Raut
e10bb45582 fix: improve clarity and formatting in SSO and templating documentation 2025-04-14 23:00:38 +05:30
Abhinav Raut
a397bc059b feat: add translations doc & improve sso and templating doc. 2025-04-14 22:44:07 +05:30
Abhinav Raut
4a305ff889 fix: adds missing automation operator less than, fixes #65 2025-04-14 00:09:34 +05:30
Abhinav Raut
616410c0a9 fix: add policy to SLA policy set activity message for clarity 2025-04-13 18:00:30 +05:30
Jonas Leiner
408e1fc142 Update docker-compose.yml
https://github.com/abhinavxd/libredesk/issues/62
2025-04-13 08:26:16 +02:00
Abhinav Raut
bc586fe775 feat: Use dynamic site name from app settings in document title and user login form. Fixes #49
Refactor: Use common AuthLayout for resetpassword, setpassword and use login view.
Fix: Accessibility fixes to all auth forms
2025-04-11 01:49:21 +05:30
Abhinav Raut
a49038f965 fix: editor layout in full screen mode 2025-04-11 00:45:28 +05:30
Abhinav Raut
4cfe0ccbd9 fix(crowdin): update branch watch comment to reflect correct branch 2025-04-11 00:20:30 +05:30
Abhinav Raut
acbb94447c refactor(contact): move ContactForm.vue and formSchema.js into features/contact/ for a better structure 2025-04-11 00:20:16 +05:30
Abhinav Raut
cd429b9751 fix: add frontend page handler for /contacts/ 2025-04-10 19:07:41 +05:30
Abhinav Raut
78d073c499 remove redundant error logging when user is not found 2025-04-10 19:07:26 +05:30
Abhinav Raut
8083ad93b4 Merge pull request #59 from abhinavxd/feat/manage-contacts
Feat - Manage contacts tab
2025-04-10 19:01:42 +05:30
Abhinav Raut
ad99dee544 bump frontend version 2025-04-10 18:57:25 +05:30
Abhinav Raut
a5eeb03f0d fix: standardize capitalization in english language translations 2025-04-10 18:51:26 +05:30
Abhinav Raut
c81f6496ea fix: center country emoji in phone number input field 2025-04-10 18:46:40 +05:30
Abhinav Raut
143a12e3c3 fix: send correct error msg when email is invalid
fix: trim space around email address
2025-04-10 18:29:04 +05:30
Abhinav Raut
e2d6a214c4 fix: console warnings for contact form 2025-04-10 18:25:47 +05:30
Abhinav Raut
4a3afc83a5 fix[shadcn]: change 'src' prop requirement to optional in AvatarImage component 2025-04-10 18:21:30 +05:30
Abhinav Raut
bb512d5ecd fix: Handle 'contact not found' error when checking if contact is blocked for IMAP email search. 2025-04-10 17:38:15 +05:30
Abhinav Raut
7957dbbd4a Merge pull request #58 from MinecollYT/main
Documentation enhacement (SSO, Variable-List)
2025-04-10 17:20:53 +05:30
Abhinav Raut
199778e771 Merge pull request #48 from jleroy/feature/crowdin-source-upload
Add Crowdin GitHub action
2025-04-10 17:19:59 +05:30
Abhinav Raut
b2a53b18d5 feat: manage contacts
- New permission `contacts:manage`
- Views for contact list and single contact view.
- Ability to block contacts that in inturn stops new messages from the contact.
- Make all DB transactions that run dynamically generated SQL query `readonly`
- Rename `/admin/teams/users` to `/admin/users/agents/` for consistency. (ref #50)
- Fix UI glitches for long emails (refs #54)
- Fix empty created_at date for agents admin table (refs #51)
- Migrations for v0.6.0
2025-04-10 04:13:11 +05:30
Abhinav Raut
576c678403 fix: validate OrderBy field in query builder, adds stricter check to only allow order by for the whitelisted fields 2025-04-10 03:40:24 +05:30
Abhinav Raut
9bfe014d1e WIP: manage contacts page 2025-04-09 16:52:25 +05:30
Jonas Leiner
1b536bdc69 Update variables.md
fixed wrong variable list, removed non-existing ones
2025-04-07 23:19:05 +02:00
Jonas Leiner
c02339f311 Update sso.md
finished keycloak guide
2025-04-07 23:03:53 +02:00
Jonas Leiner
1e7ab144b6 Create variables.md
list of some variables
2025-04-07 22:49:10 +02:00
Jonas Leiner
e998529827 Update mkdocs.yml
added variable site
2025-04-07 22:43:04 +02:00
Jonas Leiner
0a57a2724e Update upgrade.md
changed admonitions type to warning
2025-04-07 12:25:05 +02:00
Jonas Leiner
d2248d34c5 Update mkdocs.yml
adding "advanced configuration" tab and using it as a parent for "sso.md"
2025-04-07 11:58:05 +02:00
Jonas Leiner
33f2f67ba8 Create sso.md 2025-04-07 11:55:51 +02:00
Jonathan Leroy
7075ca214c Add a section to README regading project translation 2025-04-06 20:07:44 +02:00
Jonathan Leroy
e68325d609 Merge branch 'abhinavxd:main' into feature/crowdin-source-upload 2025-04-06 19:50:19 +02:00
Jonathan Leroy
2499df866f Add Crowdin GitHub action 2025-04-06 02:35:35 +02:00
Abhinav Raut
be5779e201 Merge pull request #45 from abhinavxd/feat/agent-vacation-mode
feat: Toggle button to reassign replies to conversations aka vacation mode
2025-04-05 19:36:00 +05:30
Abhinav Raut
2d868b7df1 fix: keep translation key camelcase 2025-04-05 18:57:09 +05:30
Abhinav Raut
374aabcb10 fix: remove missing query from queries struct 2025-04-05 18:50:19 +05:30
Abhinav Raut
e69b1c3e6d fix: remove missing column reassign_replies from queries 2025-04-05 18:45:31 +05:30
Abhinav Raut
1821647695 feat: allow admins to set availability status
remove unnecessary column `reassign_replies` instead add a new enum `away_and_reassigning` to enum user availability status
2025-04-05 18:12:42 +05:30
Abhinav Raut
b4f2186150 fix[shadcn]: make avatar image src default to empty string 2025-04-05 17:52:11 +05:30
Abhinav Raut
6d588f7a4e feat: show user summary on /admin/users
- Displays metrics like last active at, last login along with the avatar.
- Adds new column `last_login_at` to users table.
- Updates translations for the same
2025-04-04 22:00:26 +05:30
Abhinav Raut
2a382d6036 feat: Toggle button for user to reassign replies to conversations if they are away, user status now actually affects the conversation workflow.
Online: Conversations are auto-assigned.
Auto-away (inactivity in browser): Marks agent as away without stopping assignment (nothing changes for agent).
Manual away: Prevents new conversations from being assigned. (option available in the sidebar)
Reassign replies: Customer replies unassigns the conversation, returning it to the team inbox / unassigned inbox.
2025-04-04 03:29:16 +05:30
Abhinav Raut
c639bfba40 update welcome email subject 2025-04-03 03:15:36 +05:30
Abhinav Raut
82aac02a97 Merge pull request #42 from abhinavxd/feat/cypress-tests
feat:  Cypress test for user login and workflow for E2E testing
2025-04-03 03:09:03 +05:30
Abhinav Raut
c348a5c9b7 rename go test workflow file 2025-04-03 03:05:43 +05:30
Abhinav Raut
008f71d7b4 fix: update cypress dependencies to include libasound2t64 2025-04-03 03:04:51 +05:30
Abhinav Raut
9b41aa0e9a chore: Cypress test for user login and workflow for E2E testing 2025-04-03 03:04:51 +05:30
Abhinav Raut
c60a0788d9 Merge pull request #43 from abhinavxd/feat/translate-app
Translate app
2025-04-03 03:03:46 +05:30
Abhinav Raut
013b5bf37e fix: add sane default values to email inbox form 2025-04-03 03:02:48 +05:30
Abhinav Raut
df0dfb480f fix: enforce stronger password validation rules 2025-04-03 03:02:32 +05:30
Abhinav Raut
2daefccd79 fix: translation keys 2025-04-03 03:00:11 +05:30
Abhinav Raut
f69e8dd4f8 fix: update translation for business hours empty name error 2025-04-03 02:16:51 +05:30
Abhinav Raut
d171958223 fix: translation key param 2025-04-03 02:14:57 +05:30
Abhinav Raut
3b7550fcf3 fix: update incorrect translation key 2025-04-03 02:11:05 +05:30
Abhinav Raut
0de712762c fix: auth initialization with i18n 2025-04-03 01:21:12 +05:30
Abhinav Raut
6b6549cb03 standardize i18n keys for consistency rename keys
fix: avatar url `null` console warnings.
2025-04-03 01:21:12 +05:30
Abhinav Raut
cd4b9a9c23 feat: translate conversations, reply box and message components 2025-04-03 01:21:12 +05:30
Abhinav Raut
e19f817c5f feat: translate account section 2025-04-03 01:21:12 +05:30
Abhinav Raut
5ce8ed72ba feat: translate reports, sla badge, view builder and search 2025-04-03 01:21:12 +05:30
Abhinav Raut
4ec564ee2e feat: translate commandbox, app update and simple table 2025-04-03 01:21:12 +05:30
Abhinav Raut
19f08ec76a feat: translate sso 2025-04-03 01:21:12 +05:30
Abhinav Raut
dd8053b2bb feat: translate templates 2025-04-03 01:21:12 +05:30
Abhinav Raut
72b92d6c66 feat: translate email notifications 2025-04-03 01:21:12 +05:30
Abhinav Raut
497b54fc49 feat: translate automations 2025-04-03 01:21:12 +05:30
Abhinav Raut
9d18d3d08d feat: translations for admin roles 2025-04-03 01:21:12 +05:30
Abhinav Raut
6bea14e7a9 feat: translate /admin/users 2025-04-03 01:21:12 +05:30
Abhinav Raut
25f23735d5 feat: translate admin email inbox forms 2025-04-03 01:21:12 +05:30
Abhinav Raut
3888793450 feat: translate admin conversation status 2025-04-03 01:21:12 +05:30
Abhinav Raut
88e4a55952 feat: translate macros 2025-04-03 01:21:12 +05:30
Abhinav Raut
9aa9a5e1b2 feat: translate /admin/tags 2025-04-03 01:21:12 +05:30
Abhinav Raut
a3098a1dbd feat translate general, business hours, sla.
translate user login , forgot password & set password.
2025-04-03 01:21:12 +05:30
Abhinav Raut
76a24467e7 fix: spanish translation file 2025-04-03 01:21:12 +05:30
Abhinav Raut
4361250c73 feat: backend api response translations 2025-04-03 01:21:12 +05:30
Abhinav Raut
7d9650be2e fix: lock radix-vue and tailwindcss versions to prevent unexpected updates 2025-04-02 13:18:54 +05:30
Abhinav Raut
eb707fd8de Merge pull request #38 from jleroy/fix-unsecure-cookies
Allow to disable cookies secure flag when needed
2025-03-31 01:34:52 +05:30
Jonathan Leroy
36077b1837 Update Config struct description 2025-03-30 19:56:15 +02:00
Abhinav Raut
d5499229b5 Merge pull request #41 from abhinavxd/dependabot/npm_and_yarn/frontend/axios-1.8.2
chore(deps): bump axios from 1.7.9 to 1.8.2 in /frontend
2025-03-27 02:46:48 +05:30
dependabot[bot]
5e90dfee5a chore(deps): bump axios from 1.7.9 to 1.8.2 in /frontend
Bumps [axios](https://github.com/axios/axios) from 1.7.9 to 1.8.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.9...v1.8.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-26 18:41:39 +00:00
Abhinav Raut
1875a62e00 feat: test cases for SLA calculator, string utilities and attachments 2025-03-26 23:57:56 +05:30
Abhinav Raut
f60c4e8cb6 fix: force tests to run freshly, even if nothing's changed 2025-03-26 23:57:19 +05:30
Abhinav Raut
495ff02067 fix[sla]: incorrect calculated deadline when SLA minutes ends exact at a working day's closing time.
Example test case for it.
```go
		{
			name:      "Monday to Friday 10:00 to 18:00",
			startTime: time.Date(2025, 03, 22, 18, 1, 43, 0, locIST), // Sat
			// 24 hours.
			slaMinutes: 1440,
			businessHours: models.BusinessHours{
				Hours: mustMarshalJSON(map[string]models.WorkingHours{
					"Monday":    {Open: "10:00", Close: "18:00"},
					"Tuesday":   {Open: "10:00", Close: "18:00"},
					"Wednesday": {Open: "10:00", Close: "18:00"},
					"Thursday":  {Open: "10:00", Close: "18:00"},
					"Friday":    {Open: "10:00", Close: "18:00"},
					"Saturday":  {Open: "10:00", Close: "14:00"},
				}),
			},
			timeZone:       "Asia/Kolkata",
			expectedResult: time.Date(2025, 03, 26, 18, 0, 0, 0, locIST),
		},
```
fix: more validations for working hours.
chore: remove unused struct fields in working hours
2025-03-26 23:56:13 +05:30
Abhinav Raut
5afec04c07 feat: add GitHub Actions workflow to run Go tests 2025-03-25 23:43:43 +05:30
Abhinav Raut
56f00e791e feat: add test target to Makefile for running Go tests 2025-03-25 23:43:12 +05:30
Abhinav Raut
dcede8a461 fix: path matching for /admin route when no sidebar item is selected 2025-03-25 02:12:00 +05:30
Abhinav Raut
39fd5c9165 fix: update redirect path for unmatched routes to assigned inboxes 2025-03-25 02:11:34 +05:30
Abhinav Raut
4b8a954043 fix: update error message for invalid session to suggest clearing cookies 2025-03-25 02:01:08 +05:30
Abhinav Raut
6ac9f28a32 fix: upsert user teams only when newly created user has a team.
- update error msg for empty permissions
2025-03-25 01:41:18 +05:30
Abhinav Raut
8101c202fa improve logging messages for database installation and system user creation 2025-03-25 01:40:39 +05:30
Abhinav Raut
09746fb365 fix: set default value for Mailbox input in EmailInboxForm 2025-03-25 01:39:51 +05:30
Abhinav Raut
f59ea59a2e fix: set default value to empty array for teams in user form schema 2025-03-25 01:32:36 +05:30
Abhinav Raut
a2cdd728c0 remove app version print 2025-03-25 01:11:56 +05:30
Abhinav Raut
ac59a5defc refactor: update form field description for improved clarity. 2025-03-25 00:54:09 +05:30
Abhinav Raut
05fbe39315 refactor: update placeholder text in TagsForm for improved clarity 2025-03-25 00:53:22 +05:30
Abhinav Raut
c7c65a3d83 refactor: update tag delete dialog alert description for clarity.
fix: show toast when tag is created
2025-03-25 00:53:14 +05:30
Abhinav Raut
5bf6b7df47 refactor: update placeholder text in SLAForm recipients for improved clarity 2025-03-25 00:52:09 +05:30
Abhinav Raut
c034c21fa5 refactor: update label for From Address field to From Email Address in EmailInboxForm 2025-03-25 00:51:50 +05:30
Abhinav Raut
4ed241a03d refactor: update description for Root URL input in GeneralSettingForm 2025-03-25 00:51:35 +05:30
Abhinav Raut
6b00f70c37 fix: business hours form, more zod schema validatons to the business hours form 2025-03-25 00:51:03 +05:30
Abhinav Raut
c51073d289 feat[shadcn]: allow boolean along with string in RadioGroup components for model value 2025-03-25 00:50:10 +05:30
Abhinav Raut
d03d4477de fix: show toast on any tag api errors 2025-03-25 00:48:53 +05:30
Abhinav Raut
3b211dc372 fix: handle unique constraint violation when creating a tag 2025-03-25 00:47:52 +05:30
Abhinav Raut
6b4f243b74 chore: more validations before savign an inbox
chore: update error msgs for clarity
2025-03-25 00:47:35 +05:30
Abhinav Raut
9ff5a53ebb fix: handle null events array in automation rules 2025-03-25 00:39:08 +05:30
Abhinav Raut
9b9282dfd9 feat: add no records found message to SimpleTable component 2025-03-24 23:14:29 +05:30
Abhinav Raut
698e2d960e chore: add more comments to message handlers. 2025-03-24 23:13:55 +05:30
Abhinav Raut
a8db8f64b5 refactor: conversation handlers to use existing enforceConversationAccess function for authz check 2025-03-24 23:13:23 +05:30
Jonathan Leroy
a5a9d1304c Allow to disable cookies secure flag when needed 2025-03-24 12:24:50 +01:00
Abhinav Raut
f688be1c88 feat: adds support for showing HTML tables in tiptap editor on *paste*.
does not support inserting a table yet from the menu yet.
Adds tiptap extension for the same.
2025-03-22 20:48:48 +05:30
Abhinav Raut
d3eb3499df fix: update docker compose to bind Postgres to local interface by default to prevent unintended access to the DB. 2025-03-22 20:43:11 +05:30
Abhinav Raut
721f7c811c fix: variables not being rendered when there's an fetching default outgoing email template / the outgoing email template is not found. 2025-03-22 18:56:50 +05:30
Abhinav Raut
a33e1453a8 fix: set proper error type when user is not found 2025-03-22 18:55:26 +05:30
Abhinav Raut
b6ce6975c9 fix: mark schedule notification as processed when agent fetch fails for email notification 2025-03-22 18:55:01 +05:30
Abhinav Raut
860b216e2b fix: remove redirect from /admin to /admin/general as some users may not have permission to the general settings 2025-03-22 00:30:04 +05:30
Abhinav Raut
eaa2b1ddcf fix: fixes blur in commandbox when zoom is not 100% 2025-03-22 00:11:22 +05:30
Abhinav Raut
0f12b2a3f3 feat: allow searching conversations by contact email address 2025-03-21 23:54:43 +05:30
Abhinav Raut
def0bb8e4c fix: limit search results to 30 in conversation messages search query 2025-03-21 23:42:24 +05:30
Abhinav Raut
a41c360cdb fix: update timezone from 'Asia/Calcutta' to 'Asia/Kolkata' in schema 2025-03-21 23:39:31 +05:30
Abhinav Raut
159cca6866 feat: add email templates to migration 2025-03-21 23:38:53 +05:30
Abhinav Raut
83f553227a migrations for v0.5.0 2025-03-21 23:24:18 +05:30
Abhinav Raut
28a6a3d246 refactor: use updated SelectTag component that accepts options array instead of string array to work 2025-03-21 23:24:06 +05:30
Abhinav Raut
7e16cc1a74 refactor(notifier): remove dependency on user store, instead accept recipient emails 2025-03-21 23:23:35 +05:30
Abhinav Raut
aeef7d4ad7 feat: configurable SLA alerts per SLA. 2025-03-21 23:23:03 +05:30
Abhinav Raut
f0358f67f0 feat: SelectTag component now supports object-based options (value & label) instead of a plain array 2025-03-21 23:18:48 +05:30
Abhinav Raut
12f2453f5a fix: dashboard chart query 2025-03-21 19:03:01 +05:30
Abhinav Raut
2742be5619 fix: exclude sent messages from report/dashboard charts to avoid data skew 2025-03-21 19:00:56 +05:30
Abhinav Raut
d837defbc9 Merge pull request #37 from abhinavxd/fix/remove-users-from-balancer
Fix: Remove soft deleted / disabled users from round robin balance pool
2025-03-18 23:41:43 +05:30
Abhinav Raut
5cc849e7eb tidy go mod 2025-03-18 23:22:41 +05:30
Abhinav Raut
729faf980c fix: incorrect balance import 2025-03-18 23:18:18 +05:30
Abhinav Raut
a36c81141b fix: update balance 2025-03-18 23:17:11 +05:30
Abhinav Raut
756147a2c9 fix: remove deleted / disabled users from auto assigner balancer. 2025-03-16 23:10:18 +05:30
Abhinav Raut
88a641fe09 fix: missing icon import. 2025-03-15 01:41:27 +05:30
Abhinav Raut
785da6715c feat: add timezone constants and update forms to use new timezone data instead of javscript internationalization to get timezone data 2025-03-15 00:28:51 +05:30
Abhinav Raut
32401fa231 fix: add tzdata package to Dockerfile 2025-03-15 00:27:54 +05:30
Abhinav Raut
83b891c92a fix: import tz data in main.go 2025-03-15 00:27:46 +05:30
Abhinav Raut
f277f76a0a fix: sql for migration 2025-03-14 22:59:27 +05:30
Abhinav Raut
5f1a40acba feat: adds new TLS type, tls skip verify and hello name to migrations and schema 2025-03-14 22:42:59 +05:30
Abhinav Raut
d90b9c2be7 feat: add TLS type, skip TLS verify, and hello hostname config options to SMTP notification settings 2025-03-14 22:42:09 +05:30
Abhinav Raut
43184ec2f3 feat: add TLS type option to inbox SMTP and IMAP config and TLS skip verify option.
feat: Adds `scan_inbox_since` config for IMAP to set the `SINCE` parameter for imap search, this will allow to scan only the emails received after the given date / time.
chore: remove autoform, use individual form fields for form field.
2025-03-14 21:53:15 +05:30
Abhinav Raut
2fdcf68a22 chore: set closed_at and resolved_at only once in conversations, on subsequent updates they are not to be updated again and again to the current time
- Remove unncessary websocket update due to this change
2025-03-12 03:21:13 +05:30
Abhinav Raut
4bef3e80a2 fix: remove unncessary onClickOutside for SelectTag component 2025-03-12 03:02:44 +05:30
Abhinav Raut
09703c1090 migrations for v0.5.0 2025-03-12 02:45:32 +05:30
Abhinav Raut
45541c221a fix: various bugs in SLA calculation
prevents multiple update queries unnecessarily on applied sla table.
clear next sla deadline in conversations properly when there's no deadline to be met.
uses the new status column in the applied sla table to determine if the sla is still active and has to be calculated again.
2025-03-12 02:45:17 +05:30
Abhinav Raut
fc0e0a8fff fix: Reopen conversations on all statuses, currently custom statuses were not reopening conversations when a new message was received by the customer.
fix: Set resolved_at timestamp when conversation is marked as closed. As agents might close the conversation without resolving it.
2025-03-12 02:41:51 +05:30
Abhinav Raut
d1f931106d fix: do not reopen onversations on agent messges, let the agents open conversations by themselves. 2025-03-12 02:40:06 +05:30
Abhinav Raut
227aa26c35 - fix: Inline images present in email quote replies previously not visible, now show up correctly, the media does not get uploaded again instead the existing media url is replaced with the cid url.
- fix: content id check for attachments, as content id is not globally unqiue.

- fix: send missing websocket updates to the fronend on conversation status update.

- refactor: combine get media by id and uuid into a singlequery
2025-03-12 02:39:39 +05:30
Abhinav Raut
79a3f0ff70 refactor: move set SLA deadlines to SQL query and remove from code 2025-03-12 02:31:19 +05:30
Abhinav Raut
eefacdbda2 chore: adds new column applied sla status to applied slas table. 2025-03-12 02:30:43 +05:30
Abhinav Raut
3783cce1be fix(email/imap): Properly extract all HTML parts to handle Apple Mail parsing quirks
Resolved issues where Apple Mail:
- Split HTML content across MIME parts, causing rendering inconsistencies, this fix combines them.
- Apple mails sends file attachments as inline......f......, leading to missing files if no Content-ID was present, this fix will treat all attachments without a Content-ID as attachments and not inline.

- Set imap lookback to 48 hrs.
2025-03-12 00:25:06 +05:30
Abhinav Raut
a4cb373f32 fix: validate time durations and ensure first response time is less than resolution time in SLA handling 2025-03-11 02:47:55 +05:30
Abhinav Raut
99e8949be6 fix: update first reply time only when sender is a non system user 2025-03-10 02:58:43 +05:30
Abhinav Raut
1240051825 fix: bind model value and handle change for SelectTag in UserForm and CreateOrEditRule components 2025-03-10 02:37:26 +05:30
Abhinav Raut
5398d4ec41 fix: close holidays dialog on save. 2025-03-10 02:36:47 +05:30
Abhinav Raut
fd4e47dc68 fix: Close dropdown on outside click in SelectTag component 2025-03-10 02:35:56 +05:30
Abhinav Raut
1ff7317c4d fix: Auto setting of SLA not working on change of assigned team. 2025-03-09 20:41:48 +05:30
Abhinav Raut
d6449b9336 feat: adds link functionality to tiptap text editor 2025-03-09 20:37:21 +05:30
Abhinav Raut
580fb76a39 fix: handle non-existent media deletion gracefully and improve logging 2025-03-09 17:28:25 +05:30
Abhinav Raut
91889423a2 fix: SQL for fetching media not linked to any message. 2025-03-09 17:27:55 +05:30
Abhinav Raut
f12efe5511 fix: remove trailing slash from root URL in settings update 2025-03-09 16:43:40 +05:30
Abhinav Raut
56187ddc46 fix: add background color for private notes in ReplyBox 2025-03-09 13:18:49 +05:30
Abhinav Raut
47af51d0dd update simple s3 2025-03-09 13:18:49 +05:30
Abhinav Raut
47a3985a51 Merge pull request #31 from keybits/patch-1
Clarify Docker installation instructions
2025-03-08 09:22:55 +05:30
Tom Atkins
3f11af13b8 Clarify Docker installation instructions 2025-03-07 12:51:52 +00:00
Abhinav Raut
da629c864c docs: update installation guide to include Nginx configuration for websocket support 2025-03-06 21:08:11 +05:30
Abhinav Raut
6fb35b90b3 fix: move apply SLA on team change from handler to conversations pkg as automations will also change assigned team and that should also set the appropriate SLA defined for the team. 2025-03-06 20:47:19 +05:30
Abhinav Raut
9892f9dae7 fix: shuffle users in team balancer to prevent ordering bias on app restart 2025-03-06 20:34:51 +05:30
Abhinav Raut
277586f025 fix: round robin assignment not working due to balancer being reloaded entirely. 2025-03-06 20:19:30 +05:30
Abhinav Raut
f3070e13a7 fix: non reactive time input in business hours form. 2025-03-06 20:18:36 +05:30
Abhinav Raut
8ed29df11c fix: missing component in simple table. 2025-03-06 20:16:58 +05:30
Abhinav Raut
36d91de8f7 fix: remove email validation from SMTP username field in email notification form schema 2025-03-06 15:11:16 +05:30
Abhinav Raut
57c1948379 fix[OOM]: fix read buffer size configuration in server settings, the readbuffer was set to the max body size making the binary go OOM. 2025-03-06 15:10:23 +05:30
Abhinav Raut
772152c40c fix: filter out empty email message ids for setting email references headers.
chore: adds debug logs.
2025-03-06 12:24:18 +05:30
Abhinav Raut
8e15d733ea fix: regression in sso login caused due to attempting in hiding client secret in the API response. Resolves #21 2025-03-05 16:13:13 +05:30
Abhinav Raut
fc47e65fcb chore: update screenshot in README 2025-03-05 04:33:11 +05:30
Abhinav Raut
760be37eda chore: update libredesk screenshot in documentation 2025-03-05 04:32:38 +05:30
Abhinav Raut
d1f08ce035 fix: handle null user last active time when marking agents offline. 2025-03-05 04:24:06 +05:30
Abhinav Raut
8551b65a27 fix: set references header in all outgoing emails, set the last 20 messages.
feat: set conversation reference number in the subject of conversation for better thread matching.
fix: hide CSAT link from conversation last message.
2025-03-05 03:49:22 +05:30
Abhinav Raut
eb499f64d0 chore: adds v0.4.0 to migration list. 2025-03-05 02:33:03 +05:30
Abhinav Raut
494bc15b0a feat: Enable agents to create conversations from the UI
Before this feature the only way to create a conversation was by adding inbox and sending an email.

Agents first search contacts by email, see a dropdown select an existing contact or fill a new email for new contact.

The backend creates contact if it does not exist, creates a conversation, sends a reply to the conversation.
Optinally assigns conversation to a user / team.

fix: Replies to emails create a new conversation instead of attaching to the previous one.

Was not happening in gmail, as gmail was sending the references headers in all replies and I missed this completely. So when libredesk searches a conversation by references headers it worked!

Instead the right way is to generate the outgoing email message id and saving it in DB. This commit fixes that.

There could be more backup strategies like putting reference number in the subject but that can be explored later.

chore: new role `conversatons:write` that enables the create conversations feature for an agent.

chore: migrations for v0.4.0.
2025-03-05 01:17:42 +05:30
Abhinav Raut
360557c58f fix: remove client_id and client_secret from get-all-oidc query 2025-03-04 22:02:42 +05:30
Abhinav Raut
8d8f08e1d2 chore add comments to command box component 2025-03-02 20:58:03 +05:30
Abhinav Raut
10b4f9d08c feat: show app version in admin tab
fix: view form validations and issues with reactivity
feat: save team inbox and view inbox dropdown state in localstorage.
fix: view inbox dropdown icon alignment.
2025-03-02 20:49:19 +05:30
Abhinav Raut
79f74363da fix: hide status dropdown in conversation list as views are prefiltered. 2025-03-02 20:44:05 +05:30
Abhinav Raut
8f6295542e fix: destroy user session when user account is disabled. 2025-03-02 19:17:42 +05:30
Abhinav Raut
8e286e2273 fix: /account navigation from sidebar. 2025-03-02 18:37:05 +05:30
Abhinav Raut
3aad69fc52 fix: update sample database credentials in config file
Matched it with default docker compose password.
2025-03-02 16:31:35 +05:30
Abhinav Raut
58825c3de9 fix: handle invalid sessions by destroying them and redirecting to login 2025-03-02 16:31:00 +05:30
Abhinav Raut
03c68afc4c fix: max age not working for cookies
Switch from expires to max age for setting cookie expiry
Set default max age to 9 hours
2025-03-02 16:28:26 +05:30
Abhinav Raut
15b9caaaed fix: prevent zap logo shrinking and ensure text wraps correctly in command bar
chore: increase command bar size.
2025-03-02 03:31:34 +05:30
Abhinav Raut
b0d3dcb5dd fix: Reply box layout for fullscreen mode 2025-03-02 03:05:51 +05:30
Abhinav Raut
96ef62b509 fix: reduce pagination sizes for conversation and message lists 2025-03-02 03:03:33 +05:30
Abhinav Raut
79c3f5a60c fix: do not clear editor state on API errors.
fix: handle macro errors silently, clear editor state on macro errors as most likely they are permission errors.
2025-03-02 03:02:46 +05:30
Abhinav Raut
70bef7b3ab fix: use explicit v-model binding to match defineModel name for action builder. 2025-03-02 02:55:20 +05:30
Abhinav Raut
b1e1dff3eb feat: replace quill editor with tiptap editor, removes the stupid hack as both editors handle new lines and empty content differently.
Quill adds <p><br></p> for new lines, while Tiptap uses <br> for Shift + Enter and <p> for Enter.

This commit fixes this hack I had added, now all editors in Libredesk are tiptap editors.

fix: Typography for agent and contact message bubbles and macro preview, as tailwind removes browser defaults. Introduces new class `native-html` for this.

fix: removes hardcoded classes in tiptap starter kit configuration as the new class `native-html` takes care of it and has to be just applied.

fix: Form validation for automations and macro form.

fix: automation list padding between items.

feat: adds bullet list and ordered list menu options to tiptap editor.
2025-03-02 01:42:17 +05:30
Abhinav Raut
9b34c2737d feat: multi-tab sync for user availability status and last activity 2025-03-01 20:33:40 +05:30
Abhinav Raut
1b63f03bb1 feat: include recipient details in email templates
With this the admin can simply add
```
Dear {{.Recipient.FirstName}},
```

To the default outgoing template and all outgoing emails will have the receipient name.
2025-03-01 20:04:49 +05:30
Abhinav Raut
26d76c966f feat: allow setting OpenAI API KEY from the UI.
feat: new `ai:manage` permission for the same
Migrations for new role.
2025-03-01 19:40:18 +05:30
Abhinav Raut
1ff335f772 fix: improve welcome email template styling and content
fix: extra large app logo in base template.
refactor: standardize template variables, explicitly pass variables for rendering into template
2025-03-01 19:10:50 +05:30
Abhinav Raut
5836ee8d90 fix: annoying scroll bar when there's a single message in a conversation
adjusts padding around single message in a conversation.
2025-02-28 22:22:13 +05:30
Abhinav Raut
98534f3c5a fix: reduce update check interval and initial sleep duration
As Libredesk is Alpha I will be pushing quick updates and fixes
2025-02-28 22:12:01 +05:30
798 changed files with 40970 additions and 13398 deletions

16
.github/ISSUE_TEMPLATE/confirmed-bug.md vendored Normal file
View File

@@ -0,0 +1,16 @@
---
name: Confirmed Bug Report
about: Report a confirmed bug in Libredesk
title: "[Bug] <brief summary>"
labels: bug
assignees: ""
---
**Version:**
- libredesk: [eg: v0.7.0]
**Description of the bug and steps to reproduce:**
A clear and concise description of what the bug is.
**Logs / Screenshots:**
Attach any relevant logs or screenshots to help diagnose the issue.

16
.github/ISSUE_TEMPLATE/possible-bug.md vendored Normal file
View File

@@ -0,0 +1,16 @@
---
name: Possible Bug Report
about: Something in Libredesk might be broken but needs confirmation
title: "[Possible Bug] <brief summary>"
labels: bug, needs-investigation
assignees: ""
---
**Version:**
- libredesk: [eg: v0.7.0]
**Description of the bug and steps to reproduce:**
A clear and concise description of what the bug is.
**Logs / Screenshots:**
Attach any relevant logs or screenshots to help diagnose the issue.

47
.github/workflows/crowdin.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Crowdin
on:
push:
paths:
# Only trigger a Crowdin update when the source localization file is
# updated.
- 'i18n/en.json'
# Only watches for changes happening on "main" branch.
branches: [ main ]
jobs:
crowdin:
runs-on: ubuntu-latest
# Only run on the original repository, not forks
if: github.event.repository.fork == false
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Crowdin push
uses: crowdin/github-action@v2
with:
# Send source (english) strings to Crowdin.
upload_sources: true
# See: https://crowdin.github.io/crowdin-cli/commands
# /crowdin-upload#options
upload_sources_args: '--preserve-hierarchy --delete-obsolete'
# Don't upload or download translations.
upload_translations: false
download_translations: false
# Source language file.
source: 'i18n/en.json'
# Translations files.
translation: 'i18n/%two_letters_code%.json'
env:
# Crowdin.com > Project > Tools > API > Project ID.
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
# When creating a personal token in Crowdin, you'll be asked to select
# the necessary scopes. The basic Crowdin Personal Token scopes are
# the following:
# - Projects (List, Get, Create, Edit) -> Read
# - Translation Status -> Read Only
# - Source files & strings -> Read and Write
# - Translations -> Read and Write
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

71
.github/workflows/frontend-ci.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
services:
db:
image: postgres:17-alpine
ports:
- 5432:5432
env:
POSTGRES_USER: libredesk
POSTGRES_PASSWORD: libredesk
POSTGRES_DB: libredesk
options: >-
--health-cmd="pg_isready -U libredesk"
--health-interval=10s
--health-timeout=5s
--health-retries=5
redis:
image: redis
ports:
- 6379:6379
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: "1.24.3"
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: "20"
- name: Install pnpm
run: npm install -g pnpm
- name: Install cypress deps
run: sudo apt-get update && sudo apt-get install -y libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6 xauth xvfb
- name: Build binary and frontend
run: make build
- name: Configure app
run: |
cp config.sample.toml config.toml
sed -i 's/host = "db"/host = "127.0.0.1"/' config.toml
sed -i 's/address = "redis:6379"/address = "localhost:6379"/' config.toml
- name: Run unit tests for frontend
run: cd frontend && pnpm test:run
- name: Install db schema and run tests
env:
LIBREDESK_SYSTEM_USER_PASSWORD: "StrongPass!123"
run: |
./libredesk --install --idempotent-install --yes --config ./config.toml
./libredesk --upgrade --yes --config ./config.toml
./libredesk --config ./config.toml &
sleep 10
cd frontend
pnpm run test:e2e:ci

22
.github/workflows/go.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Go
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.24.3"
- name: Install dependencies
run: go get -v ./...
- name: Run tests
run: make test

View File

@@ -40,7 +40,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.21"
go-version: "1.24.3"
cache: true
- name: Set up Node.js

View File

@@ -2,7 +2,7 @@
FROM alpine:latest
# Install necessary packages
RUN apk --no-cache add ca-certificates
RUN apk --no-cache add ca-certificates tzdata
# Set the working directory to /libredesk
WORKDIR /libredesk

View File

@@ -15,7 +15,7 @@ GOPATH ?= $(HOME)/go
STUFFBIN ?= $(GOPATH)/bin/stuffbin
# The default target to run when `make` is executed.
.DEFAULT_GOAL := build
.DEFAULT_GOAL := build
# Install stuffbin if it doesn't exist.
$(STUFFBIN):
@@ -28,32 +28,61 @@ install-deps: $(STUFFBIN)
@echo "→ Installing frontend dependencies..."
@cd ${FRONTEND_DIR} && pnpm install
# Build the frontend for production.
# Build the frontend for production (both apps).
.PHONY: frontend-build
frontend-build: install-deps
@echo "→ Building frontend for production..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build
@echo "→ Building frontend for production - main app & widget..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:main
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:widget
# Build only the main frontend app.
.PHONY: frontend-build-main
frontend-build-main: install-deps
@echo "→ Building main frontend app for production..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:main
# Build only the widget frontend app.
.PHONY: frontend-build-widget
frontend-build-widget: install-deps
@echo "→ Building widget frontend app for production..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:widget
# Run the Go backend server in development mode.
.PHONY: run-backend
run-backend:
@echo "→ Running backend..."
CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go
CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'github.com/abhinavxd/libredesk/internal/version.Version=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go
# Run the JS frontend server in development mode.
# Run the JS frontend server in development mode (main app only).
.PHONY: run-frontend
run-frontend:
@echo "→ Installing frontend dependencies (if not already installed)..."
@cd ${FRONTEND_DIR} && pnpm install
@echo "→ Running frontend..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev
@echo "→ Running main frontend app..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev:main
# Run the main frontend app in development mode.
.PHONY: run-frontend-main
run-frontend-main:
@echo "→ Installing frontend dependencies (if not already installed)..."
@cd ${FRONTEND_DIR} && pnpm install
@echo "→ Running main frontend app..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev:main
# Run the widget frontend app in development mode.
.PHONY: run-frontend-widget
run-frontend-widget:
@echo "→ Installing frontend dependencies (if not already installed)..."
@cd ${FRONTEND_DIR} && pnpm install
@echo "→ Running widget frontend app..."
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev:widget
# Build the backend binary.
.PHONY: build-backend
build-backend: $(STUFFBIN)
@echo "→ Building backend..."
@CGO_ENABLED=0 go build -a\
-ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -s -w" \
@CGO_ENABLED=0 go build -a \
-ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'github.com/abhinavxd/libredesk/internal/version.Version=${VERSION}' -s -w" \
-o ${BIN} cmd/*.go
# Main build target: builds both frontend and backend, then stuffs static assets into the binary.
@@ -71,4 +100,10 @@ stuff: $(STUFFBIN)
.PHONY: demo-build
demo-build:
@echo "→ Building in demo mode..."
@export VITE_DEMO_BUILD="true" && $(MAKE) build
@export VITE_DEMO_BUILD="true" && $(MAKE) build
# Run tests.
.PHONY: test
test:
@echo "→ Running tests..."
go test -count=1 ./...

View File

@@ -5,17 +5,17 @@
Open source, self-hosted customer support desk. Single binary app.
![image](docs/docs/images/hero.png)
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
![Screenshot_20250220_231723](https://github.com/user-attachments/assets/55e0ec68-b624-4442-8387-6157742da253)
> **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
## Features
- **Multi Inbox**
Libredesk supports multiple inboxes, letting you manage conversations across teams effortlessly.
- **Multi Shared Inbox**
Libredesk supports multiple shared inboxes, letting you manage conversations across teams effortlessly.
- **Granular Permissions**
Create custom roles with granular permissions for teams and individual agents.
- **Smart Automation**
@@ -30,12 +30,16 @@ Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live
Distribute workload with auto assignment rules. Auto-assign conversations based on agent capacity or custom criteria.
- **SLA Management**
Set and track response time targets. Get notified when conversations are at risk of breaching SLA commitments.
- **Business Intelligence**
Connect your favorite BI tools like Metabase and create custom dashboards and reports with your support data—without lock-ins.
- **AI-Assisted Response Rewrite**
- **Custom attributes**
Create custom attributes for contacts or conversations such as the subscription plan or the date of their first purchase.
- **AI-Assist**
Instantly rewrite responses with AI to make them more friendly, professional, or polished.
- **Activity logs**
Track all actions performed by agents and admins—updates and key events across the system—for auditing and accountability.
- **Webhooks**
Integrate with external systems using real-time HTTP notifications for conversation and message events.
- **Command Bar**
Opens with a simple shortcut (CTRL+k) and lets you quickly perform actions on conversations.
Opens with a simple shortcut (CTRL+K) and lets you quickly perform actions on conversations.
And more checkout - [libredesk.io](https://libredesk.io)
@@ -61,7 +65,7 @@ docker compose up -d
docker exec -it libredesk_app ./libredesk --set-system-user-password
```
Go to `http://localhost:9000` and login with username `System` and the password you set using the --set-system-user-password command.
Go to `http://localhost:9000` and login with username `System` and the password you set using the `--set-system-user-password` command.
See [installation docs](https://libredesk.io/docs/installation/)
@@ -80,3 +84,12 @@ __________________
## Developers
If you are interested in contributing, refer to the [developer setup](https://libredesk.io/docs/developer-setup/). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
## Development Status
Libredesk is under active development.
Track roadmap and progress on the GitHub Project Board: [https://github.com/users/abhinavxd/projects/1](https://github.com/users/abhinavxd/projects/1)
## Translators
You can help translate Libredesk into your language on [Crowdin](https://crowdin.com/project/libredesk).

36
cmd/actvity_log.go Normal file
View File

@@ -0,0 +1,36 @@
package main
import (
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/zerodha/fastglue"
)
// handleGetActivityLogs returns activity logs from the database.
func handleGetActivityLogs(r *fastglue.Request) error {
var (
app = r.Context.(*App)
order = string(r.RequestCtx.QueryArgs().Peek("order"))
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
total = 0
)
logs, err := app.activityLog.GetAll(order, orderBy, filters, page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
if len(logs) > 0 {
total = logs[0].Total
}
return r.SendEnvelope(envelope.PageResults{
Results: logs,
Total: total,
PerPage: pageSize,
TotalPages: (total + pageSize - 1) / pageSize,
Page: page,
})
}

View File

@@ -1,15 +1,32 @@
package main
import "github.com/zerodha/fastglue"
import (
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/zerodha/fastglue"
)
type aiCompletionReq struct {
PromptKey string `json:"prompt_key"`
Content string `json:"content"`
}
type providerUpdateReq struct {
Provider string `json:"provider"`
APIKey string `json:"api_key"`
}
// handleAICompletion handles AI completion requests
func handleAICompletion(r *fastglue.Request) error {
var (
app = r.Context.(*App)
promptKey = string(r.RequestCtx.PostArgs().Peek("prompt_key"))
content = string(r.RequestCtx.PostArgs().Peek("content"))
app = r.Context.(*App)
req = aiCompletionReq{}
)
resp, err := app.ai.Completion(promptKey, content)
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
resp, err := app.ai.Completion(req.PromptKey, req.Content)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -27,3 +44,18 @@ func handleGetAIPrompts(r *fastglue.Request) error {
}
return r.SendEnvelope(resp)
}
// handleUpdateAIProvider updates the AI provider
func handleUpdateAIProvider(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req providerUpdateReq
)
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
if err := app.ai.UpdateProvider(req.Provider, req.APIKey); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Provider updated successfully")
}

View File

@@ -6,6 +6,7 @@ import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/stringutil"
realip "github.com/ferluci/fast-realip"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -22,20 +23,21 @@ func handleOIDCLogin(r *fastglue.Request) error {
)
if err != nil {
app.lo.Error("error parsing provider id", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error parsing provider id.", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.GeneralError)
}
// Set a state and save it in the session, to prevent CSRF attacks.
state, err := stringutil.RandomAlphanumeric(32)
if err != nil {
app.lo.Error("error generating state", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error generating state.", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorGenerating", "name", "state"), nil, envelope.GeneralError)
}
if err = app.auth.SetSessionValues(r, map[string]interface{}{
oidcStateSessKey: state,
}); err != nil {
app.lo.Error("error saving state in session", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error saving state in session.", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorSaving", "name", "{globals.terms.session}"), nil, envelope.GeneralError)
}
authURL, err := app.auth.LoginURL(providerID, state)
@@ -52,30 +54,32 @@ func handleOIDCCallback(r *fastglue.Request) error {
code = string(r.RequestCtx.QueryArgs().Peek("code"))
state = string(r.RequestCtx.QueryArgs().Peek("state"))
providerID, err = strconv.Atoi(string(r.RequestCtx.UserValue("id").(string)))
ip = realip.FromRequest(r.RequestCtx)
)
if err != nil {
app.lo.Error("error parsing provider id", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error parsing provider id.", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.GeneralError)
}
// Compare the state from the session with the state from the query.
sessionState, err := app.auth.GetSessionValue(r, oidcStateSessKey)
if err != nil {
app.lo.Error("error getting state from session", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error getting state from session.", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.session}"), nil, envelope.GeneralError)
}
if state != sessionState {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Invalid state.", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.mismatch", "name", "{globals.terms.state}"), nil, envelope.GeneralError)
}
_, claims, err := app.auth.ExchangeOIDCToken(r.RequestCtx, providerID, code)
if err != nil {
app.lo.Error("error exchanging oidc token", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error exchanging OIDC token.", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError,
app.i18n.T("globals.messages.errorExchangingToken"), nil, envelope.GeneralError)
}
// Lookup the user by email and set the session.
user, err := app.user.GetByEmail(claims.Email)
user, err := app.user.GetAgent(0, claims.Email)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -86,7 +90,18 @@ func handleOIDCCallback(r *fastglue.Request) error {
FirstName: user.FirstName,
LastName: user.LastName,
}, r); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error saving session.", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorSaving", "name", "{globals.terms.session}"), nil, envelope.GeneralError)
}
// Update last login time.
if err := app.user.UpdateLastLoginAt(user.ID); err != nil {
return sendErrorEnvelope(r, err)
}
// Insert activity log.
if err := app.activityLog.Login(user.ID, user.Email.String, ip); err != nil {
app.lo.Error("error creating login activity log", "error", err)
}
return r.Redirect("/", fasthttp.StatusFound, nil, "")

View File

@@ -9,6 +9,10 @@ import (
"github.com/zerodha/fastglue"
)
type updateAutomationRuleExecutionModeReq struct {
Mode string `json:"mode"`
}
// handleGetAutomationRules gets all automation rules
func handleGetAutomationRules(r *fastglue.Request) error {
var (
@@ -41,10 +45,11 @@ func handleToggleAutomationRule(r *fastglue.Request) error {
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if err := app.automation.ToggleRule(id); err != nil {
toggledRule, err := app.automation.ToggleRule(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Rule toggled successfully")
return r.SendEnvelope(toggledRule)
}
// handleUpdateAutomationRule updates an automation rule
@@ -55,18 +60,18 @@ func handleUpdateAutomationRule(r *fastglue.Request) error {
id, err = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid rule `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&rule, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err = app.automation.UpdateRule(id, rule);err != nil {
updatedRule, err := app.automation.UpdateRule(id, rule)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Rule updated successfully")
return r.SendEnvelope(updatedRule)
}
// handleCreateAutomationRule creates a new automation rule
@@ -76,12 +81,13 @@ func handleCreateAutomationRule(r *fastglue.Request) error {
rule = amodels.RuleRecord{}
)
if err := r.Decode(&rule, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := app.automation.CreateRule(rule); err != nil {
createdRule, err := app.automation.CreateRule(rule)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Rule created successfully")
return r.SendEnvelope(createdRule)
}
// handleDeleteAutomationRule deletes an automation rule
@@ -92,15 +98,12 @@ func handleDeleteAutomationRule(r *fastglue.Request) error {
id, err = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid rule `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
err = app.automation.DeleteRule(id)
if err != nil {
if err = app.automation.DeleteRule(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Rule deleted successfully")
return r.SendEnvelope(true)
}
// handleUpdateAutomationRuleWeights updates the weights of the automation rules
@@ -110,27 +113,33 @@ func handleUpdateAutomationRuleWeights(r *fastglue.Request) error {
weights = make(map[int]int)
)
if err := r.Decode(&weights, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
err := app.automation.UpdateRuleWeights(weights)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Weights updated successfully")
return r.SendEnvelope(true)
}
// handleUpdateAutomationRuleExecutionMode updates the execution mode of the automation rules for a given type
func handleUpdateAutomationRuleExecutionMode(r *fastglue.Request) error {
var (
app = r.Context.(*App)
mode = string(r.RequestCtx.PostArgs().Peek("mode"))
app = r.Context.(*App)
req = updateAutomationRuleExecutionModeReq{}
)
if mode != amodels.ExecutionModeAll && mode != amodels.ExecutionModeFirstMatch {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid execution mode", nil, envelope.InputError)
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
if req.Mode != amodels.ExecutionModeAll && req.Mode != amodels.ExecutionModeFirstMatch {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("automation.invalidRuleExecutionMode"), nil, envelope.InputError)
}
// Only new conversation rules can be updated as they are the only ones that have execution mode.
if err := app.automation.UpdateRuleExecutionMode(amodels.RuleTypeNewConversation, mode); err != nil {
if err := app.automation.UpdateRuleExecutionMode(amodels.RuleTypeNewConversation, req.Mode); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Execution mode updated successfully")
return r.SendEnvelope(true)
}

View File

@@ -29,14 +29,14 @@ func handleGetBusinessHour(r *fastglue.Request) error {
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid business hour `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
businessHour, err := app.businessHours.Get(id)
if err != nil {
if err == businessHours.ErrBusinessHoursNotFound {
return r.SendErrorEnvelope(fasthttp.StatusNotFound, err.Error(), nil, envelope.NotFoundError)
}
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error fetching business hour", nil, "")
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.businessHour}"), nil, "")
}
return r.SendEnvelope(businessHour)
}
@@ -48,18 +48,19 @@ func handleCreateBusinessHours(r *fastglue.Request) error {
businessHours = models.BusinessHours{}
)
if err := r.Decode(&businessHours, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if businessHours.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty business hour `Name`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if err := app.businessHours.Create(businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays); err != nil {
createdBusinessHours, err := app.businessHours.Create(businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(createdBusinessHours)
}
// handleDeleteBusinessHour deletes the business hour with the given id.
@@ -69,14 +70,11 @@ func handleDeleteBusinessHour(r *fastglue.Request) error {
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid business hour `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
err = app.businessHours.Delete(id)
if err != nil {
if err = app.businessHours.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
@@ -88,20 +86,17 @@ func handleUpdateBusinessHours(r *fastglue.Request) error {
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid business hour `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&businessHours, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if businessHours.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty business hour `Name`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`name`"), nil, envelope.InputError)
}
if err := app.businessHours.Update(id, businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays); err != nil {
updatedBusinessHours, err := app.businessHours.Update(id, businessHours.Name, businessHours.Description, businessHours.IsAlwaysOpen, businessHours.Hours, businessHours.Holidays)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedBusinessHours)
}

1129
cmd/chat.go Normal file

File diff suppressed because it is too large Load Diff

269
cmd/contacts.go Normal file
View File

@@ -0,0 +1,269 @@
package main
import (
"path/filepath"
"strconv"
"strings"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/abhinavxd/libredesk/internal/user/models"
"github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue"
)
type createContactNoteReq struct {
Note string `json:"note"`
}
type blockContactReq struct {
Enabled bool `json:"enabled"`
}
// handleGetContacts returns a list of contacts from the database.
func handleGetContacts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
order = string(r.RequestCtx.QueryArgs().Peek("order"))
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
total = 0
)
contacts, err := app.user.GetContacts(page, pageSize, order, orderBy, filters)
if err != nil {
return sendErrorEnvelope(r, err)
}
if len(contacts) > 0 {
total = contacts[0].Total
}
return r.SendEnvelope(envelope.PageResults{
Results: contacts,
Total: total,
PerPage: pageSize,
TotalPages: (total + pageSize - 1) / pageSize,
Page: page,
})
}
// handleGetTags returns a contact from the database.
func handleGetContact(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
c, err := app.user.GetContact(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(c)
}
// handleUpdateContact updates a contact in the database.
func handleUpdateContact(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
contact, err := app.user.GetContact(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
form, err := r.RequestCtx.MultipartForm()
if err != nil {
app.lo.Error("error parsing form data", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError)
}
// Parse form data
firstName := ""
if v, ok := form.Value["first_name"]; ok && len(v) > 0 {
firstName = string(v[0])
}
lastName := ""
if v, ok := form.Value["last_name"]; ok && len(v) > 0 {
lastName = string(v[0])
}
email := ""
if v, ok := form.Value["email"]; ok && len(v) > 0 {
email = strings.TrimSpace(string(v[0]))
}
phoneNumber := ""
if v, ok := form.Value["phone_number"]; ok && len(v) > 0 {
phoneNumber = string(v[0])
}
phoneNumberCallingCode := ""
if v, ok := form.Value["phone_number_calling_code"]; ok && len(v) > 0 {
phoneNumberCallingCode = string(v[0])
}
avatarURL := ""
if v, ok := form.Value["avatar_url"]; ok && len(v) > 0 {
avatarURL = string(v[0])
}
// Set nulls to empty strings.
if avatarURL == "null" {
avatarURL = ""
}
if phoneNumberCallingCode == "null" {
phoneNumberCallingCode = ""
}
if phoneNumber == "null" {
phoneNumber = ""
}
// Validate mandatory fields.
if email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "email"), nil, envelope.InputError)
}
if !stringutil.ValidEmail(email) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "email"), nil, envelope.InputError)
}
if firstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "first_name"), nil, envelope.InputError)
}
// Another contact with same new email?
existingContact, _ := app.user.GetContact(0, email)
if existingContact.ID > 0 && existingContact.ID != id {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("contact.alreadyExistsWithEmail"), nil, envelope.InputError)
}
contactToUpdate := models.User{
FirstName: firstName,
LastName: lastName,
Email: null.StringFrom(email),
AvatarURL: null.NewString(avatarURL, avatarURL != ""),
PhoneNumber: null.NewString(phoneNumber, phoneNumber != ""),
PhoneNumberCallingCode: null.NewString(phoneNumberCallingCode, phoneNumberCallingCode != ""),
}
if err := app.user.UpdateContact(id, contactToUpdate); err != nil {
return sendErrorEnvelope(r, err)
}
// Delete avatar?
if avatarURL == "" && contact.AvatarURL.Valid {
fileName := filepath.Base(contact.AvatarURL.String)
app.media.Delete(fileName)
contact.AvatarURL.Valid = false
contact.AvatarURL.String = ""
}
// Upload avatar?
files, ok := form.File["files"]
if ok && len(files) > 0 {
if err := uploadUserAvatar(r, &contact, files); err != nil {
return sendErrorEnvelope(r, err)
}
}
return r.SendEnvelope(true)
}
// handleGetContactNotes returns all notes for a contact.
func handleGetContactNotes(r *fastglue.Request) error {
var (
app = r.Context.(*App)
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if contactID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
notes, err := app.user.GetNotes(contactID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(notes)
}
// handleCreateContactNote creates a note for a contact.
func handleCreateContactNote(r *fastglue.Request) error {
var (
app = r.Context.(*App)
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = createContactNoteReq{}
)
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
if len(req.Note) == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "note"), nil, envelope.InputError)
}
if err := app.user.CreateNote(contactID, auser.ID, req.Note); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleDeleteContactNote deletes a note for a contact.
func handleDeleteContactNote(r *fastglue.Request) error {
var (
app = r.Context.(*App)
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
noteID, _ = strconv.Atoi(r.RequestCtx.UserValue("note_id").(string))
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
if contactID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if noteID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`note_id`"), nil, envelope.InputError)
}
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Allow deletion of only own notes and not those created by others, but also allow `Admin` to delete any note.
if !agent.HasAdminRole() {
note, err := app.user.GetNote(noteID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if note.UserID != auser.ID {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.canOnlyDeleteOwn", "name", "{globals.terms.note}"), nil, envelope.InputError)
}
}
if err := app.user.DeleteNote(noteID, contactID); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleBlockContact blocks a contact.
func handleBlockContact(r *fastglue.Request) error {
var (
app = r.Context.(*App)
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
req = blockContactReq{}
)
if contactID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
if err := app.user.ToggleEnabled(contactID, models.UserTypeContact, req.Enabled); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}

View File

@@ -1,7 +1,6 @@
package main
import (
"encoding/json"
"strconv"
"time"
@@ -10,12 +9,48 @@ import (
"github.com/abhinavxd/libredesk/internal/automation/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
medModels "github.com/abhinavxd/libredesk/internal/media/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
wmodels "github.com/abhinavxd/libredesk/internal/webhook/models"
"github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue"
)
type assigneeChangeReq struct {
AssigneeID int `json:"assignee_id"`
}
type teamAssigneeChangeReq struct {
AssigneeID int `json:"assignee_id"`
}
type priorityUpdateReq struct {
Priority string `json:"priority"`
}
type statusUpdateReq struct {
Status string `json:"status"`
SnoozedUntil string `json:"snoozed_until,omitempty"`
}
type tagsUpdateReq struct {
Tags []string `json:"tags"`
}
type createConversationRequest struct {
InboxID int `json:"inbox_id"`
AssignedAgentID int `json:"agent_id"`
AssignedTeamID int `json:"team_id"`
Email string `json:"contact_email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Subject string `json:"subject"`
Content string `json:"content"`
Attachments []int `json:"attachments"`
}
// handleGetAllConversations retrieves all conversations.
func handleGetAllConversations(r *fastglue.Request) error {
var (
@@ -37,14 +72,6 @@ func handleGetAllConversations(r *fastglue.Request) error {
total = conversations[0].Total
}
// Set deadlines for SLA if conversation has a policy
for i := range conversations {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
Results: conversations,
Total: total,
@@ -68,20 +95,12 @@ func handleGetAssignedConversations(r *fastglue.Request) error {
)
conversations, err := app.conversation.GetAssignedConversationsList(user.ID, order, orderBy, filters, page, pageSize)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
return sendErrorEnvelope(r, err)
}
if len(conversations) > 0 {
total = conversations[0].Total
}
// Set deadlines for SLA if conversation has a policy
for i := range conversations {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
Results: conversations,
Total: total,
@@ -105,20 +124,12 @@ func handleGetUnassignedConversations(r *fastglue.Request) error {
conversations, err := app.conversation.GetUnassignedConversationsList(order, orderBy, filters, page, pageSize)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
return sendErrorEnvelope(r, err)
}
if len(conversations) > 0 {
total = conversations[0].Total
}
// Set deadlines for SLA if conversation has a policy
for i := range conversations {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
Results: conversations,
Total: total,
@@ -141,7 +152,7 @@ func handleGetViewConversations(r *fastglue.Request) error {
total = 0
)
if viewID < 1 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `view_id`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`view_id`"), nil, envelope.InputError)
}
// Check if user has access to the view.
@@ -150,15 +161,15 @@ func handleGetViewConversations(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
if view.UserID != auser.ID {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "You don't have access to this view.", nil, envelope.PermissionError)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("conversation.viewPermissionDenied"), nil, envelope.PermissionError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Prepare lists user has access to based on user permissions, internally this affects the SQL query.
// Prepare lists user has access to based on user permissions, internally this prepares the SQL query.
lists := []string{}
for _, perm := range user.Permissions {
if perm == authzModels.PermConversationsReadAll {
@@ -179,7 +190,7 @@ func handleGetViewConversations(r *fastglue.Request) error {
// No lists found, user doesn't have access to any conversations.
if len(lists) == 0 {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Permission denied", nil, envelope.PermissionError)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
}
conversations, err := app.conversation.GetViewConversationsList(user.ID, user.Teams.IDs(), lists, order, orderBy, string(view.Filters), page, pageSize)
@@ -190,14 +201,6 @@ func handleGetViewConversations(r *fastglue.Request) error {
total = conversations[0].Total
}
// Set deadlines for SLA if conversation has a policy
for i := range conversations {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
Results: conversations,
Total: total,
@@ -222,7 +225,7 @@ func handleGetTeamUnassignedConversations(r *fastglue.Request) error {
)
teamID, _ := strconv.Atoi(teamIDStr)
if teamID < 1 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `team_id`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`team_id`"), nil, envelope.InputError)
}
// Check if user belongs to the team.
@@ -232,7 +235,7 @@ func handleGetTeamUnassignedConversations(r *fastglue.Request) error {
}
if !exists {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "You're not a member of this team, Please refresh the page and try again.", nil))
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, app.i18n.T("conversation.notMemberOfTeam"), nil))
}
conversations, err := app.conversation.GetTeamUnassignedConversationsList(teamID, order, orderBy, filters, page, pageSize)
@@ -243,14 +246,6 @@ func handleGetTeamUnassignedConversations(r *fastglue.Request) error {
total = conversations[0].Total
}
// Set deadlines for SLA if conversation has a policy
for i := range conversations {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
Results: conversations,
Total: total,
@@ -268,7 +263,7 @@ func handleGetConversation(r *fastglue.Request) error {
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -278,13 +273,8 @@ func handleGetConversation(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
if conv.SLAPolicyID.Int != 0 {
setSLADeadlines(app, conv)
}
prev, _ := app.conversation.GetContactConversations(conv.ContactID)
conv.PreviousConversations = filterCurrentConv(prev, conv.UUID)
conv.ID = 0
return r.SendEnvelope(conv)
}
@@ -295,7 +285,7 @@ func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -306,7 +296,7 @@ func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
if err = app.conversation.UpdateConversationAssigneeLastSeen(uuid); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Last seen updated successfully")
return r.SendEnvelope(true)
}
// handleGetConversationParticipants retrieves participants of a conversation.
@@ -316,7 +306,7 @@ func handleGetConversationParticipants(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -334,33 +324,37 @@ func handleGetConversationParticipants(r *fastglue.Request) error {
// handleUpdateUserAssignee updates the user assigned to a conversation.
func handleUpdateUserAssignee(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
assigneeID = r.RequestCtx.PostArgs().GetUintOrZero("assignee_id")
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = assigneeChangeReq{}
)
if assigneeID == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `assignee_id`", nil, envelope.InputError)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding assignee change request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
_, err = enforceConversationAccess(app, uuid, user)
conversation, err := enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.conversation.UpdateConversationUserAssignee(uuid, assigneeID, user); err != nil {
// Already assigned?
if conversation.AssignedUserID.Int == req.AssigneeID {
return r.SendEnvelope(true)
}
if err := app.conversation.UpdateConversationUserAssignee(uuid, req.AssigneeID, user); err != nil {
return sendErrorEnvelope(r, err)
}
// Evaluate automation rules.
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationUserAssigned)
return r.SendEnvelope("User assigned successfully")
return r.SendEnvelope(true)
}
// handleUpdateTeamAssignee updates the team assigned to a conversation.
@@ -369,13 +363,17 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = teamAssigneeChangeReq{}
)
assigneeID, err := r.RequestCtx.PostArgs().GetUint("assignee_id")
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid assignee `id`.", nil, envelope.InputError)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding team assignee change request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
user, err := app.user.Get(auser.ID)
assigneeID := req.AssigneeID
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -389,89 +387,85 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
// Already assigned?
if conversation.AssignedTeamID.Int == assigneeID {
return r.SendEnvelope(true)
}
if err := app.conversation.UpdateConversationTeamAssignee(uuid, assigneeID, user); err != nil {
return sendErrorEnvelope(r, err)
}
// Evaluate automation rules on team assignment.
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationTeamAssigned)
// Apply SLA policy if team has changed and the new team has an SLA policy.
if conversation.AssignedTeamID.Int != assigneeID && assigneeID != 0 {
team, err := app.team.Get(assigneeID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if team.SLAPolicyID.Int != 0 {
if err := app.conversation.ApplySLA(*conversation, team.SLAPolicyID.Int, user); err != nil {
return sendErrorEnvelope(r, err)
}
}
}
return r.SendEnvelope("Team assigned successfully")
return r.SendEnvelope(true)
}
// handleUpdateConversationPriority updates the priority of a conversation.
func handleUpdateConversationPriority(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
priority = string(r.RequestCtx.PostArgs().Peek("priority"))
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = priorityUpdateReq{}
)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding priority update request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
priority := req.Priority
if priority == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `priority`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`priority`"), nil, envelope.InputError)
}
conversation, err := app.conversation.GetConversation(0, uuid)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
user, err := app.user.Get(auser.ID)
_, err = enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
allowed, err := app.authz.EnforceConversationAccess(user, conversation)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !allowed {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
}
if err := app.conversation.UpdateConversationPriority(uuid, 0 /**priority_id**/, priority, user); err != nil {
return sendErrorEnvelope(r, err)
}
// Evaluate automation rules.
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationPriorityChange)
return r.SendEnvelope("Priority updated successfully")
return r.SendEnvelope(true)
}
// handleUpdateConversationStatus updates the status of a conversation.
func handleUpdateConversationStatus(r *fastglue.Request) error {
var (
app = r.Context.(*App)
status = string(r.RequestCtx.PostArgs().Peek("status"))
snoozedUntil = string(r.RequestCtx.PostArgs().Peek("snoozed_until"))
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = statusUpdateReq{}
)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding status update request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
status := req.Status
snoozedUntil := req.SnoozedUntil
// Validate inputs
if status == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `status`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`status`"), nil, envelope.InputError)
}
if snoozedUntil == "" && status == cmodels.StatusSnoozed {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `snoozed_until`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`snoozed_until`"), nil, envelope.InputError)
}
if status == cmodels.StatusSnoozed {
_, err := time.ParseDuration(snoozedUntil)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `snoozed_until`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`snoozed_until`"), nil, envelope.InputError)
}
}
// Enforce conversation access.
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -480,19 +474,11 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
// Make sure a user is assigned before resolving conversation.
if status == cmodels.StatusResolved && conversation.AssignedUserID.Int == 0 {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, "Cannot resolve the conversation without an assigned user, Please assign a user before attempting to resolve.", nil))
}
// Update conversation status.
if err := app.conversation.UpdateConversationStatus(uuid, 0 /**status_id**/, status, snoozedUntil, user); err != nil {
return sendErrorEnvelope(r, err)
}
// Evaluate automation rules.
app.automation.EvaluateConversationUpdateRules(uuid, models.EventConversationStatusChange)
// If status is `Resolved`, send CSAT survey if enabled on inbox.
if status == cmodels.StatusResolved {
// Check if CSAT is enabled on the inbox and send CSAT survey message.
@@ -506,67 +492,98 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
}
}
}
return r.SendEnvelope("Status updated successfully")
return r.SendEnvelope(true)
}
// handleUpdateConversationtags updates conversation tags.
func handleUpdateConversationtags(r *fastglue.Request) error {
var (
app = r.Context.(*App)
tagNames = []string{}
tagJSON = r.RequestCtx.PostArgs().Peek("tags")
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
req = tagsUpdateReq{}
)
if err := json.Unmarshal(tagJSON, &tagNames); err != nil {
app.lo.Error("error unmarshalling tags JSON", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error unmarshalling tags JSON", nil, envelope.GeneralError)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding tags update request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
conversation, err := app.conversation.GetConversation(0, uuid)
tagNames := req.Tags
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
_, err = enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
user, err := app.user.Get(auser.ID)
if err != nil {
if err := app.conversation.SetConversationTags(uuid, models.ActionSetTags, tagNames, user); err != nil {
return sendErrorEnvelope(r, err)
}
if allowed, err := app.authz.EnforceConversationAccess(user, conversation); err != nil {
return sendErrorEnvelope(r, err)
} else if !allowed {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
}
if err := app.conversation.UpsertConversationTags(uuid, tagNames, user); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Tags added successfully")
return r.SendEnvelope(true)
}
// handleDashboardCounts retrieves general dashboard counts for all users.
func handleDashboardCounts(r *fastglue.Request) error {
// handleUpdateConversationCustomAttributes updates custom attributes of a conversation.
func handleUpdateConversationCustomAttributes(r *fastglue.Request) error {
var (
app = r.Context.(*App)
app = r.Context.(*App)
attributes = map[string]any{}
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
)
counts, err := app.conversation.GetDashboardCounts(0, 0)
if err := r.Decode(&attributes, ""); err != nil {
app.lo.Error("error unmarshalling custom attributes JSON", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
// Enforce conversation access.
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(counts)
_, err = enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Update custom attributes.
if err := app.conversation.UpdateConversationCustomAttributes(uuid, attributes); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleDashboardCharts retrieves general dashboard chart data.
func handleDashboardCharts(r *fastglue.Request) error {
// handleUpdateContactCustomAttributes updates custom attributes of a contact.
func handleUpdateContactCustomAttributes(r *fastglue.Request) error {
var (
app = r.Context.(*App)
app = r.Context.(*App)
attributes = map[string]any{}
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
)
charts, err := app.conversation.GetDashboardChart(0, 0)
if err := r.Decode(&attributes, ""); err != nil {
app.lo.Error("error unmarshalling custom attributes JSON", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
// Enforce conversation access.
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(charts)
conversation, err := enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.user.SaveCustomAttributes(conversation.ContactID, attributes, false); err != nil {
return sendErrorEnvelope(r, err)
}
// Broadcast update.
app.conversation.BroadcastConversationUpdate(conversation.UUID, "contact.custom_attributes", attributes)
return r.SendEnvelope(true)
}
// enforceConversationAccess fetches the conversation and checks if the user has access to it.
@@ -577,7 +594,7 @@ func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmode
}
allowed, err := app.authz.EnforceConversationAccess(user, conversation)
if err != nil {
return nil, envelope.NewError(envelope.GeneralError, "Error checking permissions", nil)
return nil, err
}
if !allowed {
return nil, envelope.NewError(envelope.PermissionError, "Permission denied", nil)
@@ -585,21 +602,6 @@ func enforceConversationAccess(app *App, uuid string, user umodels.User) (*cmode
return &conversation, nil
}
// setSLADeadlines gets the latest SLA deadlines for a conversation and sets them.
func setSLADeadlines(app *App, conversation *cmodels.Conversation) error {
if conversation.ID < 1 {
return nil
}
first, resolution, err := app.sla.GetLatestDeadlines(conversation.ID)
if err != nil {
app.lo.Error("error getting SLA deadlines", "id", conversation.ID, "error", err)
return err
}
conversation.FirstResponseDueAt = null.NewTime(first, first != time.Time{})
conversation.ResolutionDueAt = null.NewTime(resolution, resolution != time.Time{})
return nil
}
// handleRemoveUserAssignee removes the user assigned to a conversation.
func handleRemoveUserAssignee(r *fastglue.Request) error {
var (
@@ -607,7 +609,7 @@ func handleRemoveUserAssignee(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -615,7 +617,7 @@ func handleRemoveUserAssignee(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.conversation.RemoveConversationAssignee(uuid, "user"); err != nil {
if err = app.conversation.RemoveConversationAssignee(uuid, "user", user); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
@@ -628,7 +630,7 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -636,7 +638,7 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.conversation.RemoveConversationAssignee(uuid, "team"); err != nil {
if err = app.conversation.RemoveConversationAssignee(uuid, "team", user); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
@@ -651,3 +653,110 @@ func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conv
}
return []cmodels.Conversation{}
}
// handleCreateConversation creates a new conversation and sends a message to it.
func handleCreateConversation(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = createConversationRequest{}
)
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error decoding create conversation request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
to := []string{req.Email}
// Validate required fields
if req.InboxID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil, envelope.InputError)
}
if req.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil, envelope.InputError)
}
if req.Email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil, envelope.InputError)
}
if req.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil, envelope.InputError)
}
if !stringutil.ValidEmail(req.Email) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil, envelope.InputError)
}
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check if inbox exists and is enabled.
inbox, err := app.inbox.GetDBRecord(req.InboxID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "inbox"), nil, envelope.InputError)
}
// Find or create contact.
contact := umodels.User{
Email: null.StringFrom(req.Email),
FirstName: req.FirstName,
LastName: req.LastName,
}
if err := app.user.CreateContact(&contact); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil))
}
// Create conversation
conversationID, conversationUUID, err := app.conversation.CreateConversation(
contact.ID,
req.InboxID,
"", /** last_message **/
time.Now(), /** last_message_at **/
req.Subject,
true, /** append reference number to subject **/
)
if err != nil {
app.lo.Error("error creating conversation", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil))
}
// Prepare attachments.
var media = make([]medModels.Media, 0, len(req.Attachments))
for _, id := range req.Attachments {
m, err := app.media.Get(id, "")
if err != nil {
app.lo.Error("error fetching media", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.media}"), nil, envelope.GeneralError)
}
media = append(media, m)
}
// Send reply to the created conversation.
if _, err := app.conversation.SendReply(media, req.InboxID, auser.ID, contact.ID, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
// Delete the conversation if reply fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)
}
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
}
// Assign the conversation to the agent or team.
if req.AssignedAgentID > 0 {
app.conversation.UpdateConversationUserAssignee(conversationUUID, req.AssignedAgentID, user)
}
if req.AssignedTeamID > 0 {
app.conversation.UpdateConversationTeamAssignee(conversationUUID, req.AssignedTeamID, user)
}
// Trigger webhook event for conversation created.
conversation, err := app.conversation.GetConversation(conversationID, "")
if err == nil {
app.webhook.TriggerEvent(wmodels.EventConversationCreated, conversation)
}
return r.SendEnvelope(conversation)
}

View File

@@ -3,9 +3,16 @@ package main
import (
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
type csatResponse struct {
Rating int `json:"rating"`
Feedback string `json:"feedback"`
}
// handleShowCSAT renders the CSAT page for a given csat.
func handleShowCSAT(r *fastglue.Request) error {
var (
@@ -42,7 +49,7 @@ func handleShowCSAT(r *fastglue.Request) error {
return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{
"Data": map[string]interface{}{
"Title": "Rate your interaction with us",
"Title": "Rate your interaction with us",
"CSAT": map[string]interface{}{
"UUID": csat.UUID,
},
@@ -72,7 +79,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
})
}
if ratingI < 1 || ratingI > 5 {
if ratingI < 0 || ratingI > 5 {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
"ErrorMessage": "Invalid `rating`",
@@ -103,3 +110,36 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
},
})
}
// handleSubmitCSATResponse handles CSAT response submission from the widget API.
func handleSubmitCSATResponse(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
req = csatResponse{}
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid JSON", nil, envelope.InputError)
}
if req.Rating < 0 || req.Rating > 5 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Rating must be between 0 and 5 (0 means no rating)", nil, envelope.InputError)
}
// At least one of rating or feedback must be provided
if req.Rating == 0 && req.Feedback == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Either rating or feedback must be provided", nil, envelope.InputError)
}
if uuid == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid UUID", nil, envelope.InputError)
}
// Update CSAT response
if err := app.csat.UpdateResponse(uuid, req.Rating, req.Feedback); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}

139
cmd/custom_attributes.go Normal file
View File

@@ -0,0 +1,139 @@
package main
import (
"slices"
"strconv"
cmodels "github.com/abhinavxd/libredesk/internal/custom_attribute/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
var (
// disallowedKeys contains keys that are not allowed for custom attributes as they're the default fields.
disallowedKeys = []string{
"contact_email",
"content",
"subject",
"status",
"priority",
"assigned_team",
"assigned_user",
"hours_since_created",
"hours_since_first_reply",
"hours_since_last_reply",
"hours_since_resolved",
"inbox",
}
)
// handleGetCustomAttribute retrieves a custom attribute by its ID.
func handleGetCustomAttribute(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
attribute, err := app.customAttribute.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(attribute)
}
// handleGetCustomAttributes retrieves all custom attributes from the database.
func handleGetCustomAttributes(r *fastglue.Request) error {
var (
app = r.Context.(*App)
appliesTo = string(r.RequestCtx.QueryArgs().Peek("applies_to"))
)
attributes, err := app.customAttribute.GetAll(appliesTo)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(attributes)
}
// handleCreateCustomAttribute creates a new custom attribute in the database.
func handleCreateCustomAttribute(r *fastglue.Request) error {
var (
app = r.Context.(*App)
attribute = cmodels.CustomAttribute{}
)
if err := r.Decode(&attribute, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if err := validateCustomAttribute(app, attribute); err != nil {
return sendErrorEnvelope(r, err)
}
createdAttr, err := app.customAttribute.Create(attribute)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(createdAttr)
}
// handleUpdateCustomAttribute updates an existing custom attribute in the database.
func handleUpdateCustomAttribute(r *fastglue.Request) error {
var (
app = r.Context.(*App)
attribute = cmodels.CustomAttribute{}
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&attribute, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if err := validateCustomAttribute(app, attribute); err != nil {
return sendErrorEnvelope(r, err)
}
updatedAttr, err := app.customAttribute.Update(id, attribute)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(updatedAttr)
}
// handleDeleteCustomAttribute deletes a custom attribute from the database.
func handleDeleteCustomAttribute(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err = app.customAttribute.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// validateCustomAttribute validates a custom attribute.
func validateCustomAttribute(app *App, attribute cmodels.CustomAttribute) error {
if attribute.Name == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
}
if attribute.AppliesTo == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`applies_to`"), nil)
}
if attribute.DataType == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`type`"), nil)
}
if attribute.Description == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`description`"), nil)
}
if attribute.Key == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`key`"), nil)
}
if slices.Contains(disallowedKeys, attribute.Key) {
return envelope.NewError(envelope.InputError, app.i18n.T("admin.customAttributes.keyNotAllowed"), nil)
}
return nil
}

View File

@@ -1,29 +1,32 @@
package main
import (
"encoding/json"
"mime"
"net/http"
"path"
"path/filepath"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/httputil"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
"github.com/abhinavxd/libredesk/internal/ws"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
var (
slaReqFields = map[string][2]int{"name": {1, 255}, "description": {1, 255}, "first_response_time": {1, 255}, "resolution_time": {1, 255}}
)
// initHandlers initializes the HTTP routes and handlers for the application.
func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Authentication.
g.POST("/api/v1/login", handleLogin)
g.GET("/logout", handleLogout)
g.POST("/api/v1/auth/login", handleLogin)
g.GET("/logout", auth(handleLogout))
g.GET("/api/v1/oidc/{id}/login", handleOIDCLogin)
g.GET("/api/v1/oidc/{id}/finish", handleOIDCCallback)
// i18n.
g.GET("/api/v1/lang/{lang}", handleGetI18nLang)
// Media.
g.GET("/uploads/{uuid}", auth(handleServeMedia))
g.POST("/api/v1/media", auth(handleMediaUpload))
@@ -38,7 +41,6 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC)
g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage"))
g.POST("/api/v1/oidc/test", perm(handleTestOIDC, "oidc:manage"))
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
g.PUT("/api/v1/oidc/{id}", perm(handleUpdateOIDC, "oidc:manage"))
g.DELETE("/api/v1/oidc/{id}", perm(handleDeleteOIDC, "oidc:manage"))
@@ -63,10 +65,14 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages:read"))
g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages:write"))
g.PUT("/api/v1/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages:write"))
g.POST("/api/v1/conversations", perm(handleCreateConversation, "conversations:write"))
g.PUT("/api/v1/conversations/{uuid}/custom-attributes", auth(handleUpdateConversationCustomAttributes))
g.PUT("/api/v1/conversations/{uuid}/contacts/custom-attributes", auth(handleUpdateContactCustomAttributes))
// Search.
g.GET("/api/v1/conversations/search", perm(handleSearchConversations, "conversations:read"))
g.GET("/api/v1/messages/search", perm(handleSearchMessages, "messages:read"))
g.GET("/api/v1/contacts/search", perm(handleSearchContacts, "contacts:read"))
// Views.
g.GET("/api/v1/views/me", perm(handleGetUserViews, "view:manage"))
@@ -81,7 +87,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.DELETE("/api/v1/statuses/{id}", perm(handleDeleteStatus, "status:manage"))
g.GET("/api/v1/priorities", auth(handleGetPriorities))
// Tag.
// Tags.
g.GET("/api/v1/tags", auth(handleGetTags))
g.POST("/api/v1/tags", perm(handleCreateTag, "tags:manage"))
g.PUT("/api/v1/tags/{id}", perm(handleUpdateTag, "tags:manage"))
@@ -95,22 +101,36 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.DELETE("/api/v1/macros/{id}", perm(handleDeleteMacro, "macros:manage"))
g.POST("/api/v1/conversations/{uuid}/macros/{id}/apply", auth(handleApplyMacro))
// User.
g.GET("/api/v1/users/me", auth(handleGetCurrentUser))
g.PUT("/api/v1/users/me", auth(handleUpdateCurrentUser))
g.GET("/api/v1/users/me/teams", auth(handleGetCurrentUserTeams))
g.PUT("/api/v1/users/me/availability", auth(handleUpdateUserAvailability))
g.DELETE("/api/v1/users/me/avatar", auth(handleDeleteAvatar))
g.GET("/api/v1/users/compact", auth(handleGetUsersCompact))
g.GET("/api/v1/users", perm(handleGetUsers, "users:manage"))
g.GET("/api/v1/users/{id}", perm(handleGetUser, "users:manage"))
g.POST("/api/v1/users", perm(handleCreateUser, "users:manage"))
g.PUT("/api/v1/users/{id}", perm(handleUpdateUser, "users:manage"))
g.DELETE("/api/v1/users/{id}", perm(handleDeleteUser, "users:manage"))
g.POST("/api/v1/users/reset-password", tryAuth(handleResetPassword))
g.POST("/api/v1/users/set-password", tryAuth(handleSetPassword))
// Agents.
g.GET("/api/v1/agents/me", auth(handleGetCurrentAgent))
g.PUT("/api/v1/agents/me", auth(handleUpdateCurrentAgent))
g.GET("/api/v1/agents/me/teams", auth(handleGetCurrentAgentTeams))
g.PUT("/api/v1/agents/me/availability", auth(handleUpdateAgentAvailability))
g.DELETE("/api/v1/agents/me/avatar", auth(handleDeleteCurrentAgentAvatar))
// Team.
g.GET("/api/v1/agents/compact", auth(handleGetAgentsCompact))
g.GET("/api/v1/agents", perm(handleGetAgents, "users:manage"))
g.GET("/api/v1/agents/{id}", perm(handleGetAgent, "users:manage"))
g.POST("/api/v1/agents", perm(handleCreateAgent, "users:manage"))
g.PUT("/api/v1/agents/{id}", perm(handleUpdateAgent, "users:manage"))
g.DELETE("/api/v1/agents/{id}", perm(handleDeleteAgent, "users:manage"))
g.POST("/api/v1/agents/{id}/api-key", perm(handleGenerateAPIKey, "users:manage"))
g.DELETE("/api/v1/agents/{id}/api-key", perm(handleRevokeAPIKey, "users:manage"))
g.POST("/api/v1/agents/reset-password", tryAuth(handleResetPassword))
g.POST("/api/v1/agents/set-password", tryAuth(handleSetPassword))
// Contacts.
g.GET("/api/v1/contacts", perm(handleGetContacts, "contacts:read_all"))
g.GET("/api/v1/contacts/{id}", perm(handleGetContact, "contacts:read"))
g.PUT("/api/v1/contacts/{id}", perm(handleUpdateContact, "contacts:write"))
g.PUT("/api/v1/contacts/{id}/block", perm(handleBlockContact, "contacts:block"))
// Contact notes.
g.GET("/api/v1/contacts/{id}/notes", perm(handleGetContactNotes, "contact_notes:read"))
g.POST("/api/v1/contacts/{id}/notes", perm(handleCreateContactNote, "contact_notes:write"))
g.DELETE("/api/v1/contacts/{id}/notes/{note_id}", perm(handleDeleteContactNote, "contact_notes:delete"))
// Teams.
g.GET("/api/v1/teams/compact", auth(handleGetTeamsCompact))
g.GET("/api/v1/teams", perm(handleGetTeams, "teams:manage"))
g.GET("/api/v1/teams/{id}", perm(handleGetTeam, "teams:manage"))
@@ -118,20 +138,17 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.PUT("/api/v1/teams/{id}", perm(handleUpdateTeam, "teams:manage"))
g.DELETE("/api/v1/teams/{id}", perm(handleDeleteTeam, "teams:manage"))
// i18n.
g.GET("/api/v1/lang/{lang}", handleGetI18nLang)
// Automations.
g.GET("/api/v1/automations/rules", perm(handleGetAutomationRules, "automations:manage"))
g.GET("/api/v1/automations/rules/{id}", perm(handleGetAutomationRule, "automations:manage"))
g.POST("/api/v1/automations/rules", perm(handleCreateAutomationRule, "automations:manage"))
g.PUT("/api/v1/automations/rules/{id}/toggle", perm(handleToggleAutomationRule, "automations:manage"))
g.PUT("/api/v1/automations/rules/{id}", perm(handleUpdateAutomationRule, "automations:manage"))
g.PUT("/api/v1/automations/rules/weights", perm(handleUpdateAutomationRuleWeights, "automations:manage"))
g.PUT("/api/v1/automations/rules/execution-mode", perm(handleUpdateAutomationRuleExecutionMode, "automations:manage"))
g.DELETE("/api/v1/automations/rules/{id}", perm(handleDeleteAutomationRule, "automations:manage"))
// Automation.
g.GET("/api/v1/automation/rules", perm(handleGetAutomationRules, "automations:manage"))
g.GET("/api/v1/automation/rules/{id}", perm(handleGetAutomationRule, "automations:manage"))
g.POST("/api/v1/automation/rules", perm(handleCreateAutomationRule, "automations:manage"))
g.PUT("/api/v1/automation/rules/{id}/toggle", perm(handleToggleAutomationRule, "automations:manage"))
g.PUT("/api/v1/automation/rules/{id}", perm(handleUpdateAutomationRule, "automations:manage"))
g.PUT("/api/v1/automation/rules/weights", perm(handleUpdateAutomationRuleWeights, "automations:manage"))
g.PUT("/api/v1/automation/rules/execution-mode", perm(handleUpdateAutomationRuleExecutionMode, "automations:manage"))
g.DELETE("/api/v1/automation/rules/{id}", perm(handleDeleteAutomationRule, "automations:manage"))
// Inbox.
// Inboxes.
g.GET("/api/v1/inboxes", auth(handleGetInboxes))
g.GET("/api/v1/inboxes/{id}", perm(handleGetInbox, "inboxes:manage"))
g.POST("/api/v1/inboxes", perm(handleCreateInbox, "inboxes:manage"))
@@ -139,18 +156,28 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.PUT("/api/v1/inboxes/{id}", perm(handleUpdateInbox, "inboxes:manage"))
g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes:manage"))
// Role.
// Roles.
g.GET("/api/v1/roles", perm(handleGetRoles, "roles:manage"))
g.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles:manage"))
g.POST("/api/v1/roles", perm(handleCreateRole, "roles:manage"))
g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles:manage"))
g.DELETE("/api/v1/roles/{id}", perm(handleDeleteRole, "roles:manage"))
// Dashboard.
g.GET("/api/v1/reports/overview/counts", perm(handleDashboardCounts, "reports:manage"))
g.GET("/api/v1/reports/overview/charts", perm(handleDashboardCharts, "reports:manage"))
// Webhooks.
g.GET("/api/v1/webhooks", perm(handleGetWebhooks, "webhooks:manage"))
g.GET("/api/v1/webhooks/{id}", perm(handleGetWebhook, "webhooks:manage"))
g.POST("/api/v1/webhooks", perm(handleCreateWebhook, "webhooks:manage"))
g.PUT("/api/v1/webhooks/{id}", perm(handleUpdateWebhook, "webhooks:manage"))
g.DELETE("/api/v1/webhooks/{id}", perm(handleDeleteWebhook, "webhooks:manage"))
g.PUT("/api/v1/webhooks/{id}/toggle", perm(handleToggleWebhook, "webhooks:manage"))
g.POST("/api/v1/webhooks/{id}/test", perm(handleTestWebhook, "webhooks:manage"))
// Template.
// Reports.
g.GET("/api/v1/reports/overview/sla", perm(handleOverviewSLA, "reports:manage"))
g.GET("/api/v1/reports/overview/counts", perm(handleOverviewCounts, "reports:manage"))
g.GET("/api/v1/reports/overview/charts", perm(handleOverviewCharts, "reports:manage"))
// Templates.
g.GET("/api/v1/templates", perm(handleGetTemplates, "templates:manage"))
g.GET("/api/v1/templates/{id}", perm(handleGetTemplate, "templates:manage"))
g.POST("/api/v1/templates", perm(handleCreateTemplate, "templates:manage"))
@@ -158,40 +185,73 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.DELETE("/api/v1/templates/{id}", perm(handleDeleteTemplate, "templates:manage"))
// Business hours.
g.GET("/api/v1/business-hours", perm(handleGetBusinessHours, "business_hours:manage"))
g.GET("/api/v1/business-hours", auth(handleGetBusinessHours))
g.GET("/api/v1/business-hours/{id}", perm(handleGetBusinessHour, "business_hours:manage"))
g.POST("/api/v1/business-hours", perm(handleCreateBusinessHours, "business_hours:manage"))
g.PUT("/api/v1/business-hours/{id}", perm(handleUpdateBusinessHours, "business_hours:manage"))
g.DELETE("/api/v1/business-hours/{id}", perm(handleDeleteBusinessHour, "business_hours:manage"))
// SLA.
// SLAs.
g.GET("/api/v1/sla", perm(handleGetSLAs, "sla:manage"))
g.GET("/api/v1/sla/{id}", perm(handleGetSLA, "sla:manage"))
g.POST("/api/v1/sla", perm(fastglue.ReqLenRangeParams(handleCreateSLA, slaReqFields), "sla:manage"))
g.PUT("/api/v1/sla/{id}", perm(fastglue.ReqLenRangeParams(handleUpdateSLA, slaReqFields), "sla:manage"))
g.POST("/api/v1/sla", perm(handleCreateSLA, "sla:manage"))
g.PUT("/api/v1/sla/{id}", perm(handleUpdateSLA, "sla:manage"))
g.DELETE("/api/v1/sla/{id}", perm(handleDeleteSLA, "sla:manage"))
// AI completion.
// AI completions.
g.GET("/api/v1/ai/prompts", auth(handleGetAIPrompts))
g.POST("/api/v1/ai/completion", auth(handleAICompletion))
g.PUT("/api/v1/ai/provider", perm(handleUpdateAIProvider, "ai:manage"))
// Custom attributes.
g.GET("/api/v1/custom-attributes", auth(handleGetCustomAttributes))
g.POST("/api/v1/custom-attributes", perm(handleCreateCustomAttribute, "custom_attributes:manage"))
g.GET("/api/v1/custom-attributes/{id}", perm(handleGetCustomAttribute, "custom_attributes:manage"))
g.PUT("/api/v1/custom-attributes/{id}", perm(handleUpdateCustomAttribute, "custom_attributes:manage"))
g.DELETE("/api/v1/custom-attributes/{id}", perm(handleDeleteCustomAttribute, "custom_attributes:manage"))
// Actvity logs.
g.GET("/api/v1/activity-logs", perm(handleGetActivityLogs, "activity_logs:manage"))
// CSAT.
g.POST("/api/v1/csat/{uuid}/response", handleSubmitCSATResponse)
// WebSocket.
g.GET("/ws", auth(func(r *fastglue.Request) error {
return handleWS(r, hub)
}))
// Live chat widget websocket.
g.GET("/widget/ws", handleWidgetWS)
// Widget APIs.
g.GET("/api/v1/widget/chat/settings/launcher", handleGetChatLauncherSettings)
g.GET("/api/v1/widget/chat/settings", handleGetChatSettings)
g.POST("/api/v1/widget/chat/conversations/init", rateLimitWidget(widgetAuth(handleChatInit)))
g.GET("/api/v1/widget/chat/conversations", rateLimitWidget(widgetAuth(handleGetConversations)))
g.POST("/api/v1/widget/chat/conversations/{uuid}/update-last-seen", rateLimitWidget(widgetAuth(handleChatUpdateLastSeen)))
g.GET("/api/v1/widget/chat/conversations/{uuid}", rateLimitWidget(widgetAuth(handleChatGetConversation)))
g.POST("/api/v1/widget/chat/conversations/{uuid}/message", rateLimitWidget(widgetAuth(handleChatSendMessage)))
g.POST("/api/v1/widget/media/upload", rateLimitWidget(widgetAuth(handleWidgetMediaUpload)))
// Frontend pages.
g.GET("/", notAuthPage(serveIndexPage))
g.GET("/widget", serveWidgetIndexPage)
g.GET("/inboxes/{all:*}", authPage(serveIndexPage))
g.GET("/teams/{all:*}", authPage(serveIndexPage))
g.GET("/views/{all:*}", authPage(serveIndexPage))
g.GET("/admin/{all:*}", authPage(serveIndexPage))
g.GET("/contacts/{all:*}", authPage(serveIndexPage))
g.GET("/reports/{all:*}", authPage(serveIndexPage))
g.GET("/account/{all:*}", authPage(serveIndexPage))
g.GET("/reset-password", notAuthPage(serveIndexPage))
g.GET("/set-password", notAuthPage(serveIndexPage))
// FIXME: Don't need three separate routes for the same thing.
// Assets and static files.
// FIXME: Reduce the number of routes.
g.GET("/widget.js", serveWidgetJS)
g.GET("/assets/{all:*}", serveFrontendStaticFiles)
g.GET("/widget/assets/{all:*}", serveWidgetStaticFiles)
g.GET("/images/{all:*}", serveFrontendStaticFiles)
g.GET("/static/public/{all:*}", serveStaticFiles)
@@ -215,7 +275,7 @@ func serveIndexPage(r *fastglue.Request) error {
// Serve the index.html file from the embedded filesystem.
file, err := app.fs.Get(path.Join(frontendDir, "index.html"))
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, "Page not found", nil, envelope.NotFoundError)
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError)
}
r.RequestCtx.Response.Header.Set("Content-Type", "text/html")
r.RequestCtx.SetBody(file.ReadBytes())
@@ -223,11 +283,82 @@ func serveIndexPage(r *fastglue.Request) error {
// Set CSRF cookie if not already set.
if err := app.auth.SetCSRFCookie(r); err != nil {
app.lo.Error("error setting csrf cookie", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.errorAcquiringSession"), nil))
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSaving", "name", "{globals.terms.session}"), nil))
}
return nil
}
// validateWidgetReferer validates the Referer header against trusted domains configured in the live chat inbox settings.
func validateWidgetReferer(app *App, r *fastglue.Request, inboxID int) error {
// Get the Referer header from the request
referer := string(r.RequestCtx.Request.Header.Peek("Referer"))
// If no referer header is present, allow direct access.
if referer == "" {
return nil
}
// Get inbox configuration
inbox, err := app.inbox.GetDBRecord(inboxID)
if err != nil {
app.lo.Error("error fetching inbox for referer check", "inbox_id", inboxID, "error", err)
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.NotFoundError)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(http.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Parse the live chat config
var config livechat.Config
if err := json.Unmarshal(inbox.Config, &config); err != nil {
app.lo.Error("error parsing live chat config for referer check", "error", err)
return r.SendErrorEnvelope(http.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
// If trusted domains list is empty, allow all referers
if len(config.TrustedDomains) == 0 {
return nil
}
// Check if the referer matches any of the trusted domains
if !httputil.IsOriginTrusted(referer, config.TrustedDomains) {
app.lo.Warn("widget request from untrusted referer blocked",
"referer", referer,
"inbox_id", inboxID,
"trusted_domains", config.TrustedDomains)
return r.SendErrorEnvelope(http.StatusForbidden, "Widget not allowed from this origin: "+referer, nil, envelope.PermissionError)
}
app.lo.Debug("widget request from trusted referer allowed", "referer", referer, "inbox_id", inboxID)
return nil
}
// serveWidgetIndexPage serves the widget index page of the application.
func serveWidgetIndexPage(r *fastglue.Request) error {
app := r.Context.(*App)
// Extract inbox ID and validate trusted domains if present
inboxID := r.RequestCtx.QueryArgs().GetUintOrZero("inbox_id")
if err := validateWidgetReferer(app, r, inboxID); err != nil {
return err
}
// Prevent caching of the index page.
r.RequestCtx.Response.Header.Add("Cache-Control", "no-store, no-cache, must-revalidate, post-check=0, pre-check=0")
r.RequestCtx.Response.Header.Add("Pragma", "no-cache")
r.RequestCtx.Response.Header.Add("Expires", "-1")
// Serve the index.html file from the embedded filesystem.
file, err := app.fs.Get(path.Join(widgetDir, "index.html"))
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError)
}
r.RequestCtx.Response.Header.Set("Content-Type", "text/html")
r.RequestCtx.SetBody(file.ReadBytes())
return nil
}
// serveStaticFiles serves static assets from the embedded filesystem.
func serveStaticFiles(r *fastglue.Request) error {
app := r.Context.(*App)
@@ -237,7 +368,7 @@ func serveStaticFiles(r *fastglue.Request) error {
file, err := app.fs.Get(filePath)
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, "File not found", nil, envelope.NotFoundError)
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError)
}
// Set the appropriate Content-Type based on the file extension.
@@ -262,7 +393,7 @@ func serveFrontendStaticFiles(r *fastglue.Request) error {
finalPath := filepath.Join(frontendDir, filePath)
file, err := app.fs.Get(finalPath)
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, "File not found", nil, envelope.NotFoundError)
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError)
}
// Set the appropriate Content-Type based on the file extension.
@@ -276,6 +407,47 @@ func serveFrontendStaticFiles(r *fastglue.Request) error {
return nil
}
// serveWidgetStaticFiles serves widget static assets from the embedded filesystem.
func serveWidgetStaticFiles(r *fastglue.Request) error {
app := r.Context.(*App)
filePath := string(r.RequestCtx.Path())
finalPath := filepath.Join(widgetDir, strings.TrimPrefix(filePath, "/widget"))
file, err := app.fs.Get(finalPath)
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError)
}
// Set the appropriate Content-Type based on the file extension.
ext := filepath.Ext(filePath)
contentType := mime.TypeByExtension(ext)
if contentType == "" {
contentType = http.DetectContentType(file.ReadBytes())
}
r.RequestCtx.Response.Header.Set("Content-Type", contentType)
r.RequestCtx.SetBody(file.ReadBytes())
return nil
}
// serveWidgetJS serves the widget JavaScript file.
func serveWidgetJS(r *fastglue.Request) error {
app := r.Context.(*App)
// Set appropriate headers for JavaScript
r.RequestCtx.Response.Header.Set("Content-Type", "application/javascript")
r.RequestCtx.Response.Header.Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour
// Serve the widget.js file from the embedded filesystem.
file, err := app.fs.Get("static/widget.js")
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError)
}
r.RequestCtx.SetBody(file.ReadBytes())
return nil
}
// sendErrorEnvelope sends a standardized error response to the client.
func sendErrorEnvelope(r *fastglue.Request, err error) error {
e, ok := err.(envelope.Error)

View File

@@ -27,6 +27,7 @@ func handleGetI18nLang(r *fastglue.Request) error {
return r.SendBytes(http.StatusOK, "application/json", i.JSON())
}
// loadI18nLang loads the i18n language pack for the given language code.
func loadI18nLang(lang string, fs stuffbin.FileSystem) (*i18n.I18n, error) {
// Helper function to read and initialize i18n language.
readLang := func(lang string) ([]byte, error) {

View File

@@ -1,14 +1,18 @@
package main
import (
"encoding/json"
"net/mail"
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
// handleGetInboxes returns all inboxes
func handleGetInboxes(r *fastglue.Request) error {
var app = r.Context.(*App)
inboxes, err := app.inbox.GetAll()
@@ -18,6 +22,7 @@ func handleGetInboxes(r *fastglue.Request) error {
return r.SendEnvelope(inboxes)
}
// handleGetInbox returns an inbox by ID
func handleGetInbox(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -25,33 +30,45 @@ func handleGetInbox(r *fastglue.Request) error {
)
inbox, err := app.inbox.GetDBRecord(id)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error fetching inbox", nil, envelope.GeneralError)
return sendErrorEnvelope(r, err)
}
if err := inbox.ClearPasswords(); err != nil {
app.lo.Error("error clearing out passwords", "error", err)
return envelope.NewError(envelope.GeneralError, "Error fetching inbox", nil)
app.lo.Error("error clearing inbox passwords from response", "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil)
}
return r.SendEnvelope(inbox)
}
// handleCreateInbox creates a new inbox
func handleCreateInbox(r *fastglue.Request) error {
var (
app = r.Context.(*App)
inb = imodels.Inbox{}
app = r.Context.(*App)
inbox = imodels.Inbox{}
)
if err := r.Decode(&inb, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
if err := r.Decode(&inbox, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
err := app.inbox.Create(inb)
createdInbox, err := app.inbox.Create(inbox)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := reloadInboxes(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reloading inboxes", nil, envelope.GeneralError)
if err := validateInbox(app, createdInbox); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
if err := reloadInboxes(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
// Clear passwords before returning.
if err := createdInbox.ClearPasswords(); err != nil {
app.lo.Error("error clearing inbox passwords from response", "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil)
}
return r.SendEnvelope(createdInbox)
}
// handleUpdateInbox updates an inbox
@@ -63,24 +80,36 @@ func handleUpdateInbox(r *fastglue.Request) error {
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid inbox `id`.", nil, envelope.InputError)
app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&inbox, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
err = app.inbox.Update(id, inbox)
if err := validateInbox(app, inbox); err != nil {
return sendErrorEnvelope(r, err)
}
updatedInbox, err := app.inbox.Update(id, inbox)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Could not update inbox.", nil, envelope.GeneralError)
return sendErrorEnvelope(r, err)
}
if err := reloadInboxes(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reloading inboxes, Please restart the app if the issue persists", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
return r.SendEnvelope(inbox)
// Clear passwords before returning.
if err := updatedInbox.ClearPasswords(); err != nil {
app.lo.Error("error clearing inbox passwords from response", "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil)
}
return r.SendEnvelope(updatedInbox)
}
// handleToggleInbox toggles an inbox
func handleToggleInbox(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -88,20 +117,28 @@ func handleToggleInbox(r *fastglue.Request) error {
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid inbox `id`.", nil, envelope.InputError)
app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err = app.inbox.Toggle(id); err != nil {
toggledInbox, err := app.inbox.Toggle(id)
if err != nil {
return err
}
if err := reloadInboxes(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reloading inboxes", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
return r.SendEnvelope(true)
// Clear passwords before returning
if err := toggledInbox.ClearPasswords(); err != nil {
app.lo.Error("error clearing inbox passwords from response", "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil)
}
return r.SendEnvelope(toggledInbox)
}
// handleDeleteInbox deletes an inbox
func handleDeleteInbox(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -109,12 +146,58 @@ func handleDeleteInbox(r *fastglue.Request) error {
)
err := app.inbox.SoftDelete(id)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Could not update inbox.", nil, envelope.GeneralError)
return sendErrorEnvelope(r, err)
}
if err := reloadInboxes(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reloading inboxes", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
return r.SendEnvelope(true)
}
// validateInbox validates the inbox
func validateInbox(app *App, inbox imodels.Inbox) error {
// Validate from address only for email channels.
if inbox.Channel == "email" {
if _, err := mail.ParseAddress(inbox.From); err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalidFromAddress"), nil)
}
}
if len(inbox.Config) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "config"), nil)
}
if inbox.Name == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "name"), nil)
}
if inbox.Channel == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "channel"), nil)
}
// Validate livechat-specific configuration
if inbox.Channel == livechat.ChannelLiveChat {
var config livechat.Config
if err := json.Unmarshal(inbox.Config, &config); err == nil {
// ShowOfficeHoursAfterAssignment cannot be enabled if ShowOfficeHoursInChat is disabled
if config.ShowOfficeHoursAfterAssignment && !config.ShowOfficeHoursInChat {
return envelope.NewError(envelope.InputError, "`show_office_hours_after_assignment` cannot be enabled when `show_office_hours_in_chat` is disabled", nil)
}
}
// Validate linked email inbox if specified
if inbox.LinkedEmailInboxID.Valid {
linkedInbox, err := app.inbox.GetDBRecord(int(inbox.LinkedEmailInboxID.Int))
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "linked_email_inbox_id"), nil)
}
// Ensure linked inbox is an email channel
if linkedInbox.Channel != "email" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "linked_email_inbox_id"), nil)
}
// Ensure linked inbox is enabled
if !linkedInbox.Enabled {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "linked_email_inbox_id"), nil)
}
}
}
return nil
}

View File

@@ -12,6 +12,7 @@ import (
"html/template"
activitylog "github.com/abhinavxd/libredesk/internal/activity_log"
"github.com/abhinavxd/libredesk/internal/ai"
auth_ "github.com/abhinavxd/libredesk/internal/auth"
"github.com/abhinavxd/libredesk/internal/authz"
@@ -23,8 +24,10 @@ import (
"github.com/abhinavxd/libredesk/internal/conversation/priority"
"github.com/abhinavxd/libredesk/internal/conversation/status"
"github.com/abhinavxd/libredesk/internal/csat"
customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute"
"github.com/abhinavxd/libredesk/internal/inbox"
"github.com/abhinavxd/libredesk/internal/inbox/channel/email"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
"github.com/abhinavxd/libredesk/internal/macro"
"github.com/abhinavxd/libredesk/internal/media"
@@ -33,6 +36,8 @@ import (
notifier "github.com/abhinavxd/libredesk/internal/notification"
emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email"
"github.com/abhinavxd/libredesk/internal/oidc"
"github.com/abhinavxd/libredesk/internal/ratelimit"
"github.com/abhinavxd/libredesk/internal/report"
"github.com/abhinavxd/libredesk/internal/role"
"github.com/abhinavxd/libredesk/internal/search"
"github.com/abhinavxd/libredesk/internal/setting"
@@ -42,6 +47,7 @@ import (
tmpl "github.com/abhinavxd/libredesk/internal/template"
"github.com/abhinavxd/libredesk/internal/user"
"github.com/abhinavxd/libredesk/internal/view"
"github.com/abhinavxd/libredesk/internal/webhook"
"github.com/abhinavxd/libredesk/internal/ws"
"github.com/jmoiron/sqlx"
"github.com/knadh/go-i18n"
@@ -128,7 +134,8 @@ func initConstants() *constants {
// initFS initializes the stuffbin FileSystem.
func initFS() stuffbin.FileSystem {
var files = []string{
"frontend/dist",
"frontend/dist/main",
"frontend/dist/widget",
"i18n",
"static",
}
@@ -217,8 +224,9 @@ func initConversations(
csat *csat.Manager,
automationEngine *automation.Engine,
template *tmpl.Manager,
webhook *webhook.Manager,
) *conversation.Manager {
c, err := conversation.New(hub, i18n, notif, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, settings, csat, automationEngine, template, conversation.Opts{
c, err := conversation.New(hub, i18n, notif, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, settings, csat, automationEngine, template, webhook, conversation.Opts{
DB: db,
Lo: initLogger("conversation_manager"),
OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"),
@@ -231,11 +239,12 @@ func initConversations(
}
// initTag inits tag manager.
func initTag(db *sqlx.DB) *tag.Manager {
func initTag(db *sqlx.DB, i18n *i18n.I18n) *tag.Manager {
var lo = initLogger("tag_manager")
mgr, err := tag.New(tag.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing tags: %v", err)
@@ -257,11 +266,12 @@ func initView(db *sqlx.DB) *view.Manager {
}
// initMacro inits macro manager.
func initMacro(db *sqlx.DB) *macro.Manager {
func initMacro(db *sqlx.DB, i18n *i18n.I18n) *macro.Manager {
var lo = initLogger("macro")
m, err := macro.New(macro.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing macro manager: %v", err)
@@ -270,11 +280,12 @@ func initMacro(db *sqlx.DB) *macro.Manager {
}
// initBusinessHours inits business hours manager.
func initBusinessHours(db *sqlx.DB) *businesshours.Manager {
func initBusinessHours(db *sqlx.DB, i18n *i18n.I18n) *businesshours.Manager {
var lo = initLogger("business-hours")
m, err := businesshours.New(businesshours.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing business hours manager: %v", err)
@@ -283,12 +294,13 @@ func initBusinessHours(db *sqlx.DB) *businesshours.Manager {
}
// initSLA inits SLA manager.
func initSLA(db *sqlx.DB, teamManager *team.Manager, settings *setting.Manager, businessHours *businesshours.Manager) *sla.Manager {
func initSLA(db *sqlx.DB, teamManager *team.Manager, settings *setting.Manager, businessHours *businesshours.Manager, notifier *notifier.Service, template *tmpl.Manager, userManager *user.Manager, i18n *i18n.I18n) *sla.Manager {
var lo = initLogger("sla")
m, err := sla.New(sla.Opts{
DB: db,
Lo: lo,
}, teamManager, settings, businessHours)
DB: db,
Lo: lo,
I18n: i18n,
}, teamManager, settings, businessHours, notifier, template, userManager)
if err != nil {
log.Fatalf("error initializing SLA manager: %v", err)
}
@@ -296,11 +308,12 @@ func initSLA(db *sqlx.DB, teamManager *team.Manager, settings *setting.Manager,
}
// initCSAT inits CSAT manager.
func initCSAT(db *sqlx.DB) *csat.Manager {
func initCSAT(db *sqlx.DB, i18n *i18n.I18n) *csat.Manager {
var lo = initLogger("csat")
m, err := csat.New(csat.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing CSAT manager: %v", err)
@@ -314,7 +327,7 @@ func initWS(user *user.Manager) *ws.Hub {
}
// initTemplates inits template manager.
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants) *tmpl.Manager {
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *i18n.I18n) *tmpl.Manager {
var (
lo = initLogger("template")
funcMap = getTmplFuncs(consts)
@@ -327,7 +340,7 @@ func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants) *tmpl.
if err != nil {
log.Fatalf("error parsing web templates: %v", err)
}
m, err := tmpl.New(lo, db, webTpls, tpls, funcMap)
m, err := tmpl.New(lo, db, webTpls, tpls, funcMap, i18n)
if err != nil {
log.Fatalf("error initializing template manager: %v", err)
}
@@ -398,11 +411,12 @@ func reloadTemplates(app *App) error {
}
// initTeam inits team manager.
func initTeam(db *sqlx.DB) *team.Manager {
func initTeam(db *sqlx.DB, i18n *i18n.I18n) *team.Manager {
var lo = initLogger("team-manager")
mgr, err := team.New(team.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing team manager: %v", err)
@@ -411,7 +425,7 @@ func initTeam(db *sqlx.DB) *team.Manager {
}
// initMedia inits media manager.
func initMedia(db *sqlx.DB) *media.Manager {
func initMedia(db *sqlx.DB, i18n *i18n.I18n) *media.Manager {
var (
store media.Store
err error
@@ -449,9 +463,11 @@ func initMedia(db *sqlx.DB) *media.Manager {
}
media, err := media.New(media.Opts{
Store: store,
Lo: lo,
DB: db,
Store: store,
Lo: lo,
DB: db,
I18n: i18n,
Secret: ko.String("upload.secret"),
})
if err != nil {
log.Fatalf("error initializing media: %v", err)
@@ -460,9 +476,9 @@ func initMedia(db *sqlx.DB) *media.Manager {
}
// initInbox initializes the inbox manager without registering inboxes.
func initInbox(db *sqlx.DB) *inbox.Manager {
func initInbox(db *sqlx.DB, i18n *i18n.I18n) *inbox.Manager {
var lo = initLogger("inbox-manager")
mgr, err := inbox.New(lo, db)
mgr, err := inbox.New(lo, db, i18n)
if err != nil {
log.Fatalf("error initializing inbox manager: %v", err)
}
@@ -470,11 +486,12 @@ func initInbox(db *sqlx.DB) *inbox.Manager {
}
// initAutomationEngine initializes the automation engine.
func initAutomationEngine(db *sqlx.DB) *automation.Engine {
func initAutomationEngine(db *sqlx.DB, i18n *i18n.I18n) *automation.Engine {
var lo = initLogger("automation_engine")
engine, err := automation.New(automation.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing automation engine: %v", err)
@@ -496,13 +513,13 @@ func initAutoAssigner(teamManager *team.Manager, userManager *user.Manager, conv
}
// initNotifier initializes the notifier service with available providers.
func initNotifier(userStore notifier.UserStore) *notifier.Service {
func initNotifier() *notifier.Service {
smtpCfg := email.SMTPConfig{}
if err := ko.UnmarshalWithConf("notification.email", &smtpCfg, koanf.UnmarshalConf{Tag: "json"}); err != nil {
log.Fatalf("error unmarshalling email notification provider config: %v", err)
}
emailNotifier, err := emailnotifier.New([]email.SMTPConfig{smtpCfg}, userStore, emailnotifier.Opts{
emailNotifier, err := emailnotifier.New([]email.SMTPConfig{smtpCfg}, emailnotifier.Opts{
Lo: initLogger("email-notifier"),
FromEmail: ko.String("notification.email.email_address"),
})
@@ -518,7 +535,7 @@ func initNotifier(userStore notifier.UserStore) *notifier.Service {
}
// initEmailInbox initializes the email inbox.
func initEmailInbox(inboxRecord imodels.Inbox, store inbox.MessageStore) (inbox.Inbox, error) {
func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) {
var config email.Config
// Load JSON data into Koanf.
@@ -544,7 +561,7 @@ func initEmailInbox(inboxRecord imodels.Inbox, store inbox.MessageStore) (inbox.
log.Printf("WARNING: No `from` email address set for `%s` inbox: Name: `%s`", inboxRecord.Channel, inboxRecord.Name)
}
inbox, err := email.New(store, email.Opts{
inbox, err := email.New(msgStore, usrStore, email.Opts{
ID: inboxRecord.ID,
Config: config,
Lo: initLogger("email_inbox"),
@@ -559,11 +576,41 @@ func initEmailInbox(inboxRecord imodels.Inbox, store inbox.MessageStore) (inbox.
return inbox, nil
}
// initLiveChatInbox initializes the live chat inbox.
func initLiveChatInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) {
var config livechat.Config
// Load JSON data into Koanf.
if err := ko.Load(rawbytes.Provider([]byte(inboxRecord.Config)), kjson.Parser()); err != nil {
return nil, fmt.Errorf("loading config: %w", err)
}
if err := ko.UnmarshalWithConf("", &config, koanf.UnmarshalConf{Tag: "json"}); err != nil {
return nil, fmt.Errorf("unmarshalling `%s` %s config: %w", inboxRecord.Channel, inboxRecord.Name, err)
}
inbox, err := livechat.New(msgStore, usrStore, livechat.Opts{
ID: inboxRecord.ID,
Config: config,
Lo: initLogger("livechat_inbox"),
})
if err != nil {
return nil, fmt.Errorf("initializing `%s` inbox: `%s` error : %w", inboxRecord.Channel, inboxRecord.Name, err)
}
log.Printf("`%s` inbox successfully initialized", inboxRecord.Name)
return inbox, nil
}
// initializeInboxes handles inbox initialization.
func initializeInboxes(inboxR imodels.Inbox, store inbox.MessageStore) (inbox.Inbox, error) {
func initializeInboxes(inboxR imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) {
switch inboxR.Channel {
case "email":
return initEmailInbox(inboxR, store)
return initEmailInbox(inboxR, msgStore, usrStore)
case "livechat":
return initLiveChatInbox(inboxR, msgStore, usrStore)
default:
return nil, fmt.Errorf("unknown inbox channel: %s", inboxR.Channel)
}
@@ -576,8 +623,9 @@ func reloadInboxes(app *App) error {
}
// startInboxes registers the active inboxes and starts receiver for each.
func startInboxes(ctx context.Context, mgr *inbox.Manager, store inbox.MessageStore) {
mgr.SetMessageStore(store)
func startInboxes(ctx context.Context, mgr *inbox.Manager, msgStore inbox.MessageStore, usrStore inbox.UserStore) {
mgr.SetMessageStore(msgStore)
mgr.SetUserStore(usrStore)
if err := mgr.InitInboxes(initializeInboxes); err != nil {
log.Fatalf("error initializing inboxes: %v", err)
@@ -589,8 +637,8 @@ func startInboxes(ctx context.Context, mgr *inbox.Manager, store inbox.MessageSt
}
// initAuthz initializes authorization enforcer.
func initAuthz() *authz.Enforcer {
enforcer, err := authz.NewEnforcer(initLogger("authz"))
func initAuthz(i18n *i18n.I18n) *authz.Enforcer {
enforcer, err := authz.NewEnforcer(initLogger("authz"), i18n)
if err != nil {
log.Fatalf("error initializing authz: %v", err)
}
@@ -598,7 +646,7 @@ func initAuthz() *authz.Enforcer {
}
// initAuth initializes the authentication manager.
func initAuth(o *oidc.Manager, rd *redis.Client) *auth_.Auth {
func initAuth(o *oidc.Manager, rd *redis.Client, i18n *i18n.I18n) *auth_.Auth {
lo := initLogger("auth")
providers, err := buildProviders(o)
@@ -606,7 +654,8 @@ func initAuth(o *oidc.Manager, rd *redis.Client) *auth_.Auth {
log.Fatalf("error initializing auth: %v", err)
}
auth, err := auth_.New(auth_.Config{Providers: providers}, rd, lo)
secure := !ko.Bool("app.server.disable_secure_cookies")
auth, err := auth_.New(auth_.Config{Providers: providers, SecureCookies: secure}, i18n, rd, lo)
if err != nil {
log.Fatalf("error initializing auth: %v", err)
}
@@ -653,11 +702,12 @@ func buildProviders(o *oidc.Manager) ([]auth_.Provider, error) {
}
// initOIDC initializes open id connect config manager.
func initOIDC(db *sqlx.DB, settings *setting.Manager) *oidc.Manager {
func initOIDC(db *sqlx.DB, settings *setting.Manager, i18n *i18n.I18n) *oidc.Manager {
lo := initLogger("oidc")
o, err := oidc.New(oidc.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
}, settings)
if err != nil {
log.Fatalf("error initializing oidc: %v", err)
@@ -667,9 +717,11 @@ func initOIDC(db *sqlx.DB, settings *setting.Manager) *oidc.Manager {
// initI18n inits i18n.
func initI18n(fs stuffbin.FileSystem) *i18n.I18n {
file, err := fs.Get("i18n/" + cmp.Or(ko.String("app.lang"), defLang) + ".json")
fileName := cmp.Or(ko.String("app.lang"), defLang)
log.Printf("loading i18n language file: %s", fileName)
file, err := fs.Get("i18n/" + fileName + ".json")
if err != nil {
log.Fatalf("error reading i18n language file")
log.Fatalf("error reading i18n language file `%s` : %v", fileName, err)
}
i18n, err := i18n.New(file.ReadBytes())
if err != nil {
@@ -713,11 +765,12 @@ func initDB() *sqlx.DB {
}
// initRedis inits role manager.
func initRole(db *sqlx.DB) *role.Manager {
func initRole(db *sqlx.DB, i18n *i18n.I18n) *role.Manager {
var lo = initLogger("role_manager")
r, err := role.New(role.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing role manager: %v", err)
@@ -726,10 +779,11 @@ func initRole(db *sqlx.DB) *role.Manager {
}
// initStatus inits conversation status manager.
func initStatus(db *sqlx.DB) *status.Manager {
func initStatus(db *sqlx.DB, i18n *i18n.I18n) *status.Manager {
manager, err := status.New(status.Opts{
DB: db,
Lo: initLogger("status-manager"),
DB: db,
Lo: initLogger("status-manager"),
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing status manager: %v", err)
@@ -738,10 +792,11 @@ func initStatus(db *sqlx.DB) *status.Manager {
}
// initPriority inits conversation priority manager.
func initPriority(db *sqlx.DB) *priority.Manager {
func initPriority(db *sqlx.DB, i18n *i18n.I18n) *priority.Manager {
manager, err := priority.New(priority.Opts{
DB: db,
Lo: initLogger("priority-manager"),
DB: db,
Lo: initLogger("priority-manager"),
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing priority manager: %v", err)
@@ -750,11 +805,12 @@ func initPriority(db *sqlx.DB) *priority.Manager {
}
// initAI inits AI manager.
func initAI(db *sqlx.DB) *ai.Manager {
func initAI(db *sqlx.DB, i18n *i18n.I18n) *ai.Manager {
lo := initLogger("ai")
m, err := ai.New(ai.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing AI manager: %v", err)
@@ -763,11 +819,12 @@ func initAI(db *sqlx.DB) *ai.Manager {
}
// initSearch inits search manager.
func initSearch(db *sqlx.DB) *search.Manager {
func initSearch(db *sqlx.DB, i18n *i18n.I18n) *search.Manager {
lo := initLogger("search")
m, err := search.New(search.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing search manager: %v", err)
@@ -775,6 +832,65 @@ func initSearch(db *sqlx.DB) *search.Manager {
return m
}
// initCustomAttribute inits custom attribute manager.
func initCustomAttribute(db *sqlx.DB, i18n *i18n.I18n) *customAttribute.Manager {
lo := initLogger("custom-attribute")
m, err := customAttribute.New(customAttribute.Opts{
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing custom attribute manager: %v", err)
}
return m
}
// initActivityLog inits activity log manager.
func initActivityLog(db *sqlx.DB, i18n *i18n.I18n) *activitylog.Manager {
lo := initLogger("activity-log")
m, err := activitylog.New(activitylog.Opts{
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing activity log manager: %v", err)
}
return m
}
// initReport inits report manager.
func initReport(db *sqlx.DB, i18n *i18n.I18n) *report.Manager {
lo := initLogger("report")
m, err := report.New(report.Opts{
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing report manager: %v", err)
}
return m
}
// initWebhook inits webhook manager.
func initWebhook(db *sqlx.DB, i18n *i18n.I18n) *webhook.Manager {
var lo = initLogger("webhook")
m, err := webhook.New(webhook.Opts{
DB: db,
Lo: lo,
I18n: i18n,
Workers: ko.MustInt("webhook.workers"),
QueueSize: ko.MustInt("webhook.queue_size"),
Timeout: ko.MustDuration("webhook.timeout"),
})
if err != nil {
log.Fatalf("error initializing webhook manager: %v", err)
}
return m
}
// initLogger initializes a logf logger.
func initLogger(src string) *logf.Logger {
lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env")
@@ -812,3 +928,12 @@ func getLogLevel(lvl string) logf.Level {
return logf.InfoLevel
}
}
// initRateLimit initializes the rate limiter.
func initRateLimit(redisClient *redis.Client) *ratelimit.Limiter {
var config ratelimit.Config
if err := ko.UnmarshalWithConf("rate_limit", &config, koanf.UnmarshalConf{Tag: "toml"}); err != nil {
log.Fatalf("error unmarshalling rate limit config: %v", err)
}
return ratelimit.New(redisClient, config)
}

View File

@@ -25,8 +25,8 @@ func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem, idempoten
// Make sure the system user password is strong enough.
password := os.Getenv("LIBREDESK_SYSTEM_USER_PASSWORD")
if password != "" && !user.IsStrongSystemUserPassword(password) && !schemaInstalled {
log.Fatalf("system user password is not strong, %s", user.SystemUserPasswordHint)
if password != "" && !user.IsStrongPassword(password) && !schemaInstalled {
log.Fatalf("system user password is not strong, %s", user.PasswordHint)
}
if !idempotentInstall {
@@ -49,11 +49,10 @@ func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem, idempoten
os.Exit(0)
}
} else {
log.Println("installing database schema...")
time.Sleep(5 * time.Second)
}
log.Println("installing database schema...")
// Install schema.
if err := installSchema(db, fs); err != nil {
log.Fatalf("error installing schema: %v", err)

View File

@@ -3,28 +3,43 @@ package main
import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
realip "github.com/ferluci/fast-realip"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
// handleLogin logs a user in.
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// handleLogin logs in the user and returns the user.
func handleLogin(r *fastglue.Request) error {
var (
app = r.Context.(*App)
email = string(r.RequestCtx.PostArgs().Peek("email"))
password = r.RequestCtx.PostArgs().Peek("password")
ip = realip.FromRequest(r.RequestCtx)
loginReq loginRequest
)
user, err := app.user.VerifyPassword(email, password)
// Decode JSON request.
if err := r.Decode(&loginReq, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if loginReq.Email == "" || loginReq.Password == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
}
// Verify email and password.
user, err := app.user.VerifyPassword(loginReq.Email, []byte(loginReq.Password))
if err != nil {
return sendErrorEnvelope(r, err)
}
// Set user availability status to online.
if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil {
return sendErrorEnvelope(r, err)
// Check if user is enabled.
if !user.Enabled {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.accountDisabled"), nil))
}
user.AvailabilityStatus = umodels.Online
if err := app.auth.SaveSession(amodels.User{
ID: user.ID,
@@ -33,25 +48,43 @@ func handleLogin(r *fastglue.Request) error {
LastName: user.LastName,
}, r); err != nil {
app.lo.Error("error saving session", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.errorAcquiringSession"), nil))
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSaving", "name", "{globals.terms.session}"), nil))
}
// Set CSRF cookie if not already set.
if err := app.auth.SetCSRFCookie(r); err != nil {
app.lo.Error("error setting csrf cookie", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.errorAcquiringSession"), nil))
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSaving", "name", "{globals.terms.session}"), nil))
}
// Update last login time.
if err := app.user.UpdateLastLoginAt(user.ID); err != nil {
return sendErrorEnvelope(r, err)
}
// Insert activity log.
if err := app.activityLog.Login(user.ID, user.Email.String, ip); err != nil {
app.lo.Error("error creating login activity log", "error", err)
}
return r.SendEnvelope(user)
}
// handleLogout logs out the user and redirects to the dashboard.
func handleLogout(r *fastglue.Request) error {
var (
app = r.Context.(*App)
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx)
)
if err := app.auth.DestroySession(r); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.errorAcquiringSession"), nil))
// Insert activity log.
if err := app.activityLog.Logout(auser.ID, auser.Email, ip); err != nil {
app.lo.Error("error creating logout activity log", "error", err)
}
if err := app.auth.DestroySession(r); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorDestroying", "name", "{globals.terms.session}"), nil))
}
// Add no-cache headers.
r.RequestCtx.Response.Header.Add("Cache-Control",
"no-store, no-cache, must-revalidate, post-check=0, pre-check=0")

View File

@@ -2,7 +2,6 @@ package main
import (
"encoding/json"
"fmt"
"slices"
"strconv"
@@ -24,14 +23,14 @@ func handleGetMacros(r *fastglue.Request) error {
for i, m := range macros {
var actions []autoModels.RuleAction
if err := json.Unmarshal(m.Actions, &actions); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error unmarshalling macro actions", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil, envelope.GeneralError)
}
// Set display values for actions as the value field can contain DB IDs
if err := setDisplayValues(app, actions); err != nil {
app.lo.Warn("error setting display values", "error", err)
}
if macros[i].Actions, err = json.Marshal(actions); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "marshal failed", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil, envelope.GeneralError)
}
}
return r.SendEnvelope(macros)
@@ -44,8 +43,7 @@ func handleGetMacro(r *fastglue.Request) error {
id, err = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid macro `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
macro, err := app.macro.Get(id)
@@ -55,14 +53,14 @@ func handleGetMacro(r *fastglue.Request) error {
var actions []autoModels.RuleAction
if err := json.Unmarshal(macro.Actions, &actions); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error unmarshalling macro actions", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil, envelope.GeneralError)
}
// Set display values for actions as the value field can contain DB IDs
if err := setDisplayValues(app, actions); err != nil {
app.lo.Warn("error setting display values", "error", err)
}
if macro.Actions, err = json.Marshal(actions); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "marshal failed", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil, envelope.GeneralError)
}
return r.SendEnvelope(macro)
@@ -76,19 +74,19 @@ func handleCreateMacro(r *fastglue.Request) error {
)
if err := r.Decode(&macro, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if err := validateMacro(macro); err != nil {
if err := validateMacro(app, macro); err != nil {
return sendErrorEnvelope(r, err)
}
err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions)
createdMacro, err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(macro)
return r.SendEnvelope(createdMacro)
}
// handleUpdateMacro updates a macro.
@@ -108,32 +106,29 @@ func handleUpdateMacro(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
if err := validateMacro(macro); err != nil {
if err := validateMacro(app, macro); err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions); err != nil {
updatedMacro, err := app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(macro)
return r.SendEnvelope(updatedMacro)
}
// handleDeleteMacro deletes macro.
func handleDeleteMacro(r *fastglue.Request) error {
var app = r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid macro `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.macro.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Macro deleted successfully")
return r.SendEnvelope(true)
}
// handleApplyMacro applies macro actions to a conversation.
@@ -145,7 +140,7 @@ func handleApplyMacro(r *fastglue.Request) error {
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
incomingActions = []autoModels.RuleAction{}
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -156,7 +151,7 @@ func handleApplyMacro(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
if allowed, err := app.authz.EnforceConversationAccess(user, conversation); err != nil || !allowed {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil))
}
macro, err := app.macro.Get(id)
@@ -167,7 +162,7 @@ func handleApplyMacro(r *fastglue.Request) error {
// Decode incoming actions.
if err := r.Decode(&incomingActions, "json"); err != nil {
app.lo.Error("error unmashalling incoming actions", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Failed to decode incoming actions", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), err.Error(), envelope.InputError)
}
// Make sure no duplicate action types are present.
@@ -175,7 +170,7 @@ func handleApplyMacro(r *fastglue.Request) error {
for _, act := range incomingActions {
if actionTypes[act.Type] {
app.lo.Warn("duplicate action types found in macro apply apply request", "action", act.Type, "user_id", user.ID)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Duplicate actions are not allowed", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("macro.duplicateActionsNotAllowed"), nil, envelope.InputError)
}
actionTypes[act.Type] = true
}
@@ -184,11 +179,11 @@ func handleApplyMacro(r *fastglue.Request) error {
for _, act := range incomingActions {
if !isMacroActionAllowed(act.Type) {
app.lo.Warn("action not allowed in macro", "action", act.Type, "user_id", user.ID)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Action not allowed in macro", nil, envelope.PermissionError)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("macro.actionNotAllowed", "name", act.Type), nil, envelope.PermissionError)
}
if !hasActionPermission(act.Type, user.Permissions) {
app.lo.Warn("no permission to execute macro action", "action", act.Type, "user_id", user.ID)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "No permission to execute this macro", nil, envelope.PermissionError)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("macro.permissionDenied"), nil, envelope.PermissionError)
}
}
@@ -201,7 +196,7 @@ func handleApplyMacro(r *fastglue.Request) error {
}
if successCount == 0 {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Failed to apply macro", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("macro.couldNotApply"), nil, envelope.GeneralError)
}
// Increment usage count.
@@ -209,12 +204,12 @@ func handleApplyMacro(r *fastglue.Request) error {
if successCount < len(incomingActions) {
return r.SendJSON(fasthttp.StatusMultiStatus, map[string]interface{}{
"message": fmt.Sprintf("Macro executed with errors. %d actions succeeded out of %d", successCount, len(incomingActions)),
"message": app.i18n.T("macro.partiallyApplied"),
})
}
return r.SendJSON(fasthttp.StatusOK, map[string]interface{}{
"message": "Macro applied successfully",
"message": app.i18n.T("macro.applied"),
})
}
@@ -239,7 +234,7 @@ func setDisplayValues(app *App, actions []autoModels.RuleAction) error {
return t.Name, nil
},
autoModels.ActionAssignUser: func(id int) (string, error) {
u, err := app.user.Get(id)
u, err := app.user.GetAgent(id, "")
if err != nil {
app.lo.Warn("user not found for macro action", "user_id", id)
return "", err
@@ -276,18 +271,22 @@ func setDisplayValues(app *App, actions []autoModels.RuleAction) error {
}
// validateMacro validates an incoming macro.
func validateMacro(macro models.Macro) error {
func validateMacro(app *App, macro models.Macro) error {
if macro.Name == "" {
return envelope.NewError(envelope.InputError, "Empty macro `name`", nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
}
if len(macro.VisibleWhen) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`visible_when`"), nil)
}
var act []autoModels.RuleAction
if err := json.Unmarshal(macro.Actions, &act); err != nil {
return envelope.NewError(envelope.InputError, "Could not parse macro actions", nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil)
}
for _, a := range act {
if len(a.Value) == 0 {
return envelope.NewError(envelope.InputError, fmt.Sprintf("Empty value for action: %s", a.Type), nil)
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", a.Type), nil)
}
}
return nil
@@ -298,7 +297,7 @@ func isMacroActionAllowed(action string) bool {
switch action {
case autoModels.ActionSendPrivateNote, autoModels.ActionReply:
return false
case autoModels.ActionAssignTeam, autoModels.ActionAssignUser, autoModels.ActionSetStatus, autoModels.ActionSetPriority, autoModels.ActionSetTags:
case autoModels.ActionAssignTeam, autoModels.ActionAssignUser, autoModels.ActionSetStatus, autoModels.ActionSetPriority, autoModels.ActionAddTags, autoModels.ActionSetTags, autoModels.ActionRemoveTags:
return true
default:
return false

View File

@@ -11,14 +11,19 @@ import (
"syscall"
"time"
_ "time/tzdata"
activitylog "github.com/abhinavxd/libredesk/internal/activity_log"
"github.com/abhinavxd/libredesk/internal/ai"
auth_ "github.com/abhinavxd/libredesk/internal/auth"
"github.com/abhinavxd/libredesk/internal/authz"
businesshours "github.com/abhinavxd/libredesk/internal/business_hours"
"github.com/abhinavxd/libredesk/internal/colorlog"
"github.com/abhinavxd/libredesk/internal/csat"
customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute"
"github.com/abhinavxd/libredesk/internal/macro"
notifier "github.com/abhinavxd/libredesk/internal/notification"
"github.com/abhinavxd/libredesk/internal/report"
"github.com/abhinavxd/libredesk/internal/search"
"github.com/abhinavxd/libredesk/internal/sla"
"github.com/abhinavxd/libredesk/internal/view"
@@ -30,12 +35,14 @@ import (
"github.com/abhinavxd/libredesk/internal/inbox"
"github.com/abhinavxd/libredesk/internal/media"
"github.com/abhinavxd/libredesk/internal/oidc"
"github.com/abhinavxd/libredesk/internal/ratelimit"
"github.com/abhinavxd/libredesk/internal/role"
"github.com/abhinavxd/libredesk/internal/setting"
"github.com/abhinavxd/libredesk/internal/tag"
"github.com/abhinavxd/libredesk/internal/team"
"github.com/abhinavxd/libredesk/internal/template"
"github.com/abhinavxd/libredesk/internal/user"
"github.com/abhinavxd/libredesk/internal/webhook"
"github.com/knadh/go-i18n"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
@@ -48,7 +55,8 @@ var (
ko = koanf.New(".")
ctx = context.Background()
appName = "libredesk"
frontendDir = "frontend/dist"
frontendDir = "frontend/dist/main"
widgetDir = "frontend/dist/widget"
// Injected at build time.
buildString string
@@ -57,33 +65,38 @@ var (
// App is the global app context which is passed and injected in the http handlers.
type App struct {
fs stuffbin.FileSystem
consts atomic.Value
auth *auth_.Auth
authz *authz.Enforcer
i18n *i18n.I18n
lo *logf.Logger
oidc *oidc.Manager
media *media.Manager
setting *setting.Manager
role *role.Manager
user *user.Manager
team *team.Manager
status *status.Manager
priority *priority.Manager
tag *tag.Manager
inbox *inbox.Manager
tmpl *template.Manager
macro *macro.Manager
conversation *conversation.Manager
automation *automation.Engine
businessHours *businesshours.Manager
sla *sla.Manager
csat *csat.Manager
view *view.Manager
ai *ai.Manager
search *search.Manager
notifier *notifier.Service
fs stuffbin.FileSystem
consts atomic.Value
auth *auth_.Auth
authz *authz.Enforcer
i18n *i18n.I18n
lo *logf.Logger
oidc *oidc.Manager
media *media.Manager
setting *setting.Manager
role *role.Manager
user *user.Manager
team *team.Manager
status *status.Manager
priority *priority.Manager
tag *tag.Manager
inbox *inbox.Manager
tmpl *template.Manager
macro *macro.Manager
conversation *conversation.Manager
automation *automation.Engine
businessHours *businesshours.Manager
sla *sla.Manager
csat *csat.Manager
view *view.Manager
ai *ai.Manager
search *search.Manager
activityLog *activitylog.Manager
notifier *notifier.Service
customAttribute *customAttribute.Manager
report *report.Manager
webhook *webhook.Manager
rateLimit *ratelimit.Limiter
// Global state that stores data on an available app update.
update *AppUpdate
@@ -106,7 +119,6 @@ func main() {
// Build string injected at build time.
colorlog.Green("Build: %s", buildString)
colorlog.Green("Version: %s", versionString)
// Load the config files into Koanf.
initConfig(ko)
@@ -152,76 +164,99 @@ func main() {
settings := initSettings(db)
loadSettings(settings)
// Fallback for config typo. Logs a warning but continues to work with the incorrect key.
// Uses 'message.message_outgoing_scan_interval' (correct key) as default key, falls back to the common typo.
msgOutgoingScanIntervalKey := "message.message_outgoing_scan_interval"
if ko.String(msgOutgoingScanIntervalKey) == "" {
if ko.String("message.message_outoing_scan_interval") != "" {
colorlog.Red("WARNING: typo in config key 'message.message_outoing_scan_interval' detected. Use 'message.message_outgoing_scan_interval' instead in your config.toml file. Support for this incorrect key will be removed in a future release.")
msgOutgoingScanIntervalKey = "message.message_outoing_scan_interval"
}
}
var (
autoAssignInterval = ko.MustDuration("autoassigner.autoassign_interval")
unsnoozeInterval = ko.MustDuration("conversation.unsnooze_interval")
automationWorkers = ko.MustInt("automation.worker_count")
messageOutgoingQWorkers = ko.MustDuration("message.outgoing_queue_workers")
messageIncomingQWorkers = ko.MustDuration("message.incoming_queue_workers")
messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval")
messageOutgoingScanInterval = ko.MustDuration(msgOutgoingScanIntervalKey)
slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval")
lo = initLogger(appName)
rdb = initRedis()
constants = initConstants()
i18n = initI18n(fs)
csat = initCSAT(db)
oidc = initOIDC(db, settings)
status = initStatus(db)
priority = initPriority(db)
auth = initAuth(oidc, rdb)
template = initTemplate(db, fs, constants)
media = initMedia(db)
inbox = initInbox(db)
team = initTeam(db)
businessHours = initBusinessHours(db)
csat = initCSAT(db, i18n)
oidc = initOIDC(db, settings, i18n)
status = initStatus(db, i18n)
priority = initPriority(db, i18n)
auth = initAuth(oidc, rdb, i18n)
template = initTemplate(db, fs, constants, i18n)
media = initMedia(db, i18n)
inbox = initInbox(db, i18n)
team = initTeam(db, i18n)
businessHours = initBusinessHours(db, i18n)
webhook = initWebhook(db, i18n)
user = initUser(i18n, db)
wsHub = initWS(user)
notifier = initNotifier(user)
automation = initAutomationEngine(db)
sla = initSLA(db, team, settings, businessHours)
conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template)
notifier = initNotifier()
automation = initAutomationEngine(db, i18n)
sla = initSLA(db, team, settings, businessHours, notifier, template, user, i18n)
conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template, webhook)
autoassigner = initAutoAssigner(team, user, conversation)
rateLimiter = initRateLimit(rdb)
)
wsHub.SetConversationStore(conversation)
automation.SetConversationStore(conversation)
startInboxes(ctx, inbox, conversation)
// Start inboxes.
startInboxes(ctx, inbox, conversation, user)
go automation.Run(ctx, automationWorkers)
go autoassigner.Run(ctx, autoAssignInterval)
go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval)
go conversation.RunUnsnoozer(ctx, unsnoozeInterval)
go webhook.Run(ctx)
go notifier.Run(ctx)
go sla.Run(ctx, slaEvaluationInterval)
go sla.SendNotifications(ctx)
go media.DeleteUnlinkedMedia(ctx)
go user.MonitorAgentAvailability(ctx)
var app = &App{
lo: lo,
fs: fs,
sla: sla,
oidc: oidc,
i18n: i18n,
auth: auth,
media: media,
setting: settings,
inbox: inbox,
user: user,
team: team,
status: status,
priority: priority,
tmpl: template,
notifier: notifier,
consts: atomic.Value{},
conversation: conversation,
automation: automation,
businessHours: businessHours,
authz: initAuthz(),
view: initView(db),
csat: initCSAT(db),
search: initSearch(db),
role: initRole(db),
tag: initTag(db),
macro: initMacro(db),
ai: initAI(db),
lo: lo,
fs: fs,
sla: sla,
oidc: oidc,
i18n: i18n,
auth: auth,
media: media,
setting: settings,
inbox: inbox,
user: user,
team: team,
status: status,
priority: priority,
tmpl: template,
notifier: notifier,
consts: atomic.Value{},
conversation: conversation,
automation: automation,
businessHours: businessHours,
activityLog: initActivityLog(db, i18n),
customAttribute: initCustomAttribute(db, i18n),
authz: initAuthz(i18n),
view: initView(db),
report: initReport(db, i18n),
csat: initCSAT(db, i18n),
search: initSearch(db, i18n),
role: initRole(db, i18n),
tag: initTag(db, i18n),
macro: initMacro(db, i18n),
ai: initAI(db, i18n),
webhook: webhook,
rateLimit: rateLimiter,
}
app.consts.Store(constants)
@@ -235,7 +270,7 @@ func main() {
WriteTimeout: ko.MustDuration("app.server.write_timeout"),
MaxRequestBodySize: ko.MustInt("app.server.max_body_size"),
MaxKeepaliveDuration: ko.MustDuration("app.server.keepalive_timeout"),
ReadBufferSize: ko.MustInt("app.server.max_body_size"),
ReadBufferSize: ko.Int("app.server.read_buffer_size"),
}
go func() {
@@ -250,7 +285,7 @@ func main() {
// Start the app update checker.
if ko.Bool("app.check_updates") {
go checkUpdates(versionString, time.Hour*24, app)
go checkUpdates(versionString, time.Hour*1, app)
}
// Wait for shutdown signal.
@@ -265,6 +300,8 @@ func main() {
autoassigner.Close()
colorlog.Red("Shutting down notifier...")
notifier.Close()
colorlog.Red("Shutting down webhook...")
webhook.Close()
colorlog.Red("Shutting down conversation...")
conversation.Close()
colorlog.Red("Shutting down SLA...")

View File

@@ -24,6 +24,7 @@ const (
thumbPrefix = "thumb_"
)
// handleMediaUpload handles media uploads.
func handleMediaUpload(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -33,19 +34,19 @@ func handleMediaUpload(r *fastglue.Request) error {
form, err := r.RequestCtx.MultipartForm()
if err != nil {
app.lo.Error("error parsing form data.", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error parsing data", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError)
}
files, ok := form.File["files"]
if !ok || len(files) == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "File not found", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.InputError)
}
fileHeader := files[0]
file, err := fileHeader.Open()
if err != nil {
app.lo.Error("error reading uploaded file", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reading file", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil, envelope.GeneralError)
}
defer file.Close()
@@ -74,15 +75,15 @@ func handleMediaUpload(r *fastglue.Request) error {
if bytesToMegabytes(srcFileSize) > float64(consts.MaxFileUploadSizeMB) {
app.lo.Error("error: uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", consts.MaxFileUploadSizeMB)
return r.SendErrorEnvelope(
http.StatusRequestEntityTooLarge,
fmt.Sprintf("File size is too large. Please upload file lesser than %d MB", consts.MaxFileUploadSizeMB),
fasthttp.StatusRequestEntityTooLarge,
app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", consts.MaxFileUploadSizeMB)),
nil,
envelope.GeneralError,
)
}
if !slices.Contains(consts.AllowedUploadFileExtensions, "*") && !slices.Contains(consts.AllowedUploadFileExtensions, srcExt) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "File type not allowed", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("media.fileTypeNotAllowed"), nil, envelope.InputError)
}
// Delete files on any error.
@@ -102,11 +103,10 @@ func handleMediaUpload(r *fastglue.Request) error {
thumbFile, err := image.CreateThumb(image.DefThumbSize, file)
if err != nil {
app.lo.Error("error creating thumb image", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error creating image thumbnail", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.thumbnail}"), nil, envelope.GeneralError)
}
thumbName, err = app.media.Upload(thumbName, srcContentType, thumbFile)
if err != nil {
app.lo.Error("error uploading thumbnail", "error", err)
return sendErrorEnvelope(r, err)
}
@@ -116,7 +116,7 @@ func handleMediaUpload(r *fastglue.Request) error {
if err != nil {
cleanUp = true
app.lo.Error("error getting image dimensions", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error uploading file", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.media}"), nil, envelope.GeneralError)
}
meta, _ = json.Marshal(map[string]interface{}{
"width": width,
@@ -129,7 +129,7 @@ func handleMediaUpload(r *fastglue.Request) error {
if err != nil {
cleanUp = true
app.lo.Error("error uploading file", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error uploading file", nil, envelope.GeneralError)
return sendErrorEnvelope(r, err)
}
// Insert in DB.
@@ -137,52 +137,57 @@ func handleMediaUpload(r *fastglue.Request) error {
if err != nil {
cleanUp = true
app.lo.Error("error inserting metadata into database", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error inserting media", nil, envelope.GeneralError)
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(media)
}
// handleServeMedia serves uploaded media.
// Supports both authenticated agent access and unauthenticated access via signed URLs.
func handleServeMedia(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
)
user, err := app.user.Get(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Fetch media from DB.
media, err := app.media.GetByUUID(strings.TrimPrefix(uuid, thumbPrefix))
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check if the user has permission to access the linked model.
allowed, err := app.authz.EnforceMediaAccess(user, media.Model.String)
if err != nil {
app.lo.Error("error checking media permission", "error", err, "model", media.Model.String, "model_id", media.ModelID)
return sendErrorEnvelope(r, err)
}
// For messages, check access to the conversation this message is part of.
if media.Model.String == "messages" {
conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int)
// Check if user is authenticated (agent access)
auser := r.RequestCtx.UserValue("user")
if auser != nil {
// Authenticated.
user, err := app.user.GetAgent(auser.(amodels.User).ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
allowed, err = app.authz.EnforceConversationAccess(user, conversation)
// Fetch media from DB.
media, err := app.media.Get(0, strings.TrimPrefix(uuid, thumbPrefix))
if err != nil {
return sendErrorEnvelope(r, err)
}
}
if !allowed {
return r.SendErrorEnvelope(http.StatusUnauthorized, "Permission denied", nil, envelope.PermissionError)
// Check if the user has permission to access the linked model.
allowed, err := app.authz.EnforceMediaAccess(user, media.Model.String)
if err != nil {
return sendErrorEnvelope(r, err)
}
// For messages, check access to the conversation this message is part of.
if media.Model.String == "messages" {
conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int)
if err != nil {
return sendErrorEnvelope(r, err)
}
allowed, err = app.authz.EnforceConversationAccess(user, conversation)
if err != nil {
return sendErrorEnvelope(r, err)
}
}
if !allowed {
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.UnauthorizedError)
}
}
// If no authenticated user, the middleware has already verified the request signature serve the file.
consts := app.consts.Load().(*constants)
switch consts.UploadProvider {
case "fs":
@@ -193,6 +198,7 @@ func handleServeMedia(r *fastglue.Request) error {
return nil
}
// bytesToMegabytes converts bytes to megabytes.
func bytesToMegabytes(bytes int64) float64 {
return float64(bytes) / 1024 / 1024
}

View File

@@ -4,7 +4,7 @@ import (
"strconv"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/automation/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
medModels "github.com/abhinavxd/libredesk/internal/media/models"
"github.com/valyala/fasthttp"
@@ -15,6 +15,7 @@ type messageReq struct {
Attachments []int `json:"attachments"`
Message string `json:"message"`
Private bool `json:"private"`
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc"`
}
@@ -30,7 +31,7 @@ func handleGetMessages(r *fastglue.Request) error {
total = 0
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -41,18 +42,22 @@ func handleGetMessages(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
messages, pageSize, err := app.conversation.GetConversationMessages(uuid, page, pageSize)
messages, pageSize, err := app.conversation.GetConversationMessages(uuid, []string{cmodels.MessageIncoming, cmodels.MessageOutgoing, cmodels.MessageActivity}, nil, page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
for i := range messages {
total = messages[i].Total
// Populate attachment URLs
for j := range messages[i].Attachments {
messages[i].Attachments[j].URL = app.media.GetURL(messages[i].Attachments[j].UUID)
}
messages[i].CensorCSATContent()
}
// Process CSAT status for all messages (will only affect CSAT messages)
app.conversation.ProcessCSATStatus(messages)
return r.SendEnvelope(envelope.PageResults{
Total: total,
Results: messages,
@@ -70,7 +75,7 @@ func handleGetMessage(r *fastglue.Request) error {
cuuid = r.RequestCtx.UserValue("cuuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -86,8 +91,10 @@ func handleGetMessage(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
// Redact CSAT survey link
message.CensorCSATContent()
// Process CSAT status for the message (will only affect CSAT messages)
messages := []cmodels.Message{message}
app.conversation.ProcessCSATStatus(messages)
message = messages[0]
for j := range message.Attachments {
message.Attachments[j].URL = app.media.GetURL(message.Attachments[j].UUID)
@@ -105,7 +112,7 @@ func handleRetryMessage(r *fastglue.Request) error {
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -116,8 +123,7 @@ func handleRetryMessage(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
err = app.conversation.MarkMessageAsPending(uuid)
if err != nil {
if err = app.conversation.MarkMessageAsPending(uuid); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
@@ -129,51 +135,56 @@ func handleSendMessage(r *fastglue.Request) error {
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
cuuid = r.RequestCtx.UserValue("cuuid").(string)
media = []medModels.Media{}
req = messageReq{}
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check permission
_, err = enforceConversationAccess(app, cuuid, user)
// Check access to conversation.
conv, err := enforceConversationAccess(app, cuuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error unmarshalling message request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "error decoding request", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
// Make sure the inbox is enabled.
inbox, err := app.inbox.GetDBRecord(conv.InboxID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Prepare attachments.
var media = make([]medModels.Media, 0, len(req.Attachments))
for _, id := range req.Attachments {
m, err := app.media.Get(id)
m, err := app.media.Get(id, "")
if err != nil {
app.lo.Error("error fetching media", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error fetching media", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.media}"), nil, envelope.GeneralError)
}
media = append(media, m)
}
if req.Private {
if err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message); err != nil {
message, err := app.conversation.SendPrivateNote(media, user.ID, cuuid, req.Message)
if err != nil {
return sendErrorEnvelope(r, err)
}
} else {
if err := app.conversation.SendReply(media, user.ID, cuuid, req.Message, req.CC, req.BCC, map[string]interface{}{}); err != nil {
return sendErrorEnvelope(r, err)
}
// Evaluate automation rules.
app.automation.EvaluateConversationUpdateRules(cuuid, models.EventConversationMessageOutgoing)
return r.SendEnvelope(message)
}
// Reopen if snoozed/closed/resolved regardless of automation rules - this is the default behavior
if err := app.conversation.ReOpenConversation(cuuid, user); err != nil {
message, err := app.conversation.SendReply(media, conv.InboxID, user.ID, conv.ContactID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Message sent successfully")
return r.SendEnvelope(message)
}

View File

@@ -6,29 +6,80 @@ import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/user/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
"github.com/zerodha/simplesessions/v3"
)
// tryAuth is a middleware that attempts to authenticate the user and add them to the context
// but doesn't enforce authentication. Handlers can check if user exists in context optionally.
// authenticateUser handles both API key and session-based authentication
// Returns the authenticated user or an error
// For session-based auth, CSRF is checked for POST/PUT/DELETE requests
func authenticateUser(r *fastglue.Request, app *App) (models.User, error) {
var user models.User
// Check for Authorization header first (API key authentication)
apiKey, apiSecret, err := r.ParseAuthHeader(fastglue.AuthBasic | fastglue.AuthToken)
if err == nil && len(apiKey) > 0 && len(apiSecret) > 0 {
user, err = app.user.ValidateAPIKey(string(apiKey), string(apiSecret))
if err != nil {
return user, err
}
return user, nil
}
// Session-based authentication - Check CSRF first.
method := string(r.RequestCtx.Method())
if method == "POST" || method == "PUT" || method == "DELETE" {
cookieToken := string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
hdrToken := string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
// Match CSRF token from cookie and header.
if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
app.lo.Error("csrf token mismatch", "method", method, "cookie_token", cookieToken, "header_token", hdrToken)
return user, envelope.NewError(envelope.PermissionError, app.i18n.T("auth.csrfTokenMismatch"), nil)
}
}
// Validate session and fetch user.
sessUser, err := app.auth.ValidateSession(r)
if err != nil || sessUser.ID <= 0 {
app.lo.Error("error validating session", "error", err)
return user, envelope.NewError(envelope.GeneralError, app.i18n.T("auth.invalidOrExpiredSession"), nil)
}
// Get agent user from cache or load it.
user, err = app.user.GetAgentCachedOrLoad(sessUser.ID)
if err != nil {
return user, err
}
// Destroy session if user is disabled.
if !user.Enabled {
if err := app.auth.DestroySession(r); err != nil {
app.lo.Error("error destroying session", "error", err)
}
return user, envelope.NewError(envelope.PermissionError, app.i18n.T("user.accountDisabled"), nil)
}
return user, nil
}
// tryAuth attempts to authenticate the user and add them to the context but doesn't enforce authentication.
// Handlers can check if user exists in context optionally.
// Supports both API key authentication (Authorization header) and session-based authentication.
func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
app := r.Context.(*App)
// Try to validate session without returning error.
userSession, err := app.auth.ValidateSession(r)
if err != nil || userSession.ID <= 0 {
return handler(r)
}
// Try to get user.
user, err := app.user.Get(userSession.ID)
// Try to authenticate user using shared authentication logic, but don't return errors
user, err := authenticateUser(r, app)
if err != nil {
// Authentication failed, but this is optional, so continue without user
return handler(r)
}
// Set user in context if found.
// Set user in context if authentication succeeded.
r.RequestCtx.SetUserValue("user", amodels.User{
ID: user.ID,
Email: user.Email.String,
@@ -40,23 +91,42 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
}
// auth makes sure the user is logged in.
// auth validates the session or API key and adds the user to the request context.
// Supports both API key authentication (Authorization header) and session-based authentication.
func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var app = r.Context.(*App)
// Validate session and fetch user.
userSession, err := app.auth.ValidateSession(r)
if err != nil || userSession.ID <= 0 {
app.lo.Error("error validating session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session", nil, envelope.PermissionError)
// For media uploads, check if signature is provided in the query parameters, if so, verify it.
path := string(r.RequestCtx.Path())
if strings.HasPrefix(path, "/uploads/") {
signature := string(r.RequestCtx.QueryArgs().Peek("signature"))
expires := string(r.RequestCtx.QueryArgs().Peek("expires"))
if signature != "" && expires != "" {
if err := app.media.VerifySignature(r); err != nil {
app.lo.Error("error verifying media signature", "error",
err, "path", string(r.RequestCtx.Path()), "query", string(r.RequestCtx.QueryArgs().QueryString()))
return r.SendErrorEnvelope(http.StatusUnauthorized, "signature verification failed", nil, envelope.PermissionError)
}
return handler(r)
}
// If no signature, continue with normal authentication.
}
// Authenticate user using shared authentication logic
user, err := authenticateUser(r, app)
if err != nil {
if envErr, ok := err.(envelope.Error); ok {
if envErr.ErrorType == envelope.PermissionError {
return r.SendErrorEnvelope(http.StatusForbidden, envErr.Message, nil, envelope.PermissionError)
}
return r.SendErrorEnvelope(http.StatusUnauthorized, envErr.Message, nil, envelope.GeneralError)
}
return sendErrorEnvelope(r, err)
}
// Set user in the request context.
user, err := app.user.Get(userSession.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
r.RequestCtx.SetUserValue("user", amodels.User{
ID: user.ID,
Email: user.Email.String,
@@ -68,45 +138,36 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
}
// perm does session validation, CSRF, and permission enforcement.
// perm checks if the user has the required permission to access the endpoint.
// Supports both API key authentication (Authorization header) and session-based authentication.
func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var (
app = r.Context.(*App)
cookieToken = string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
hdrToken = string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
)
var app = r.Context.(*App)
if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
app.lo.Error("csrf token mismatch", "cookie_token", cookieToken, "header_token", hdrToken)
return r.SendErrorEnvelope(http.StatusForbidden, "Invalid CSRF token", nil, envelope.PermissionError)
}
// Validate session and fetch user.
sessUser, err := app.auth.ValidateSession(r)
if err != nil || sessUser.ID <= 0 {
app.lo.Error("error validating session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session", nil, envelope.PermissionError)
}
// Get user from DB.
user, err := app.user.Get(sessUser.ID)
// Authenticate user using shared authentication logic
user, err := authenticateUser(r, app)
if err != nil {
if envErr, ok := err.(envelope.Error); ok {
if envErr.ErrorType == envelope.PermissionError {
return r.SendErrorEnvelope(http.StatusForbidden, envErr.Message, nil, envelope.PermissionError)
}
return r.SendErrorEnvelope(http.StatusUnauthorized, envErr.Message, nil, envelope.GeneralError)
}
return sendErrorEnvelope(r, err)
}
// Split the permission string into object and action and enforce it.
parts := strings.Split(perm, ":")
if len(parts) != 2 {
return r.SendErrorEnvelope(http.StatusInternalServerError, "Invalid permission format", nil, envelope.GeneralError)
return r.SendErrorEnvelope(http.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.permission}"), nil, envelope.GeneralError)
}
object, action := parts[0], parts[1]
ok, err := app.authz.Enforce(user, object, action)
if err != nil {
return r.SendErrorEnvelope(http.StatusInternalServerError, "Error checking permissions", nil, envelope.GeneralError)
return r.SendErrorEnvelope(http.StatusInternalServerError, app.i18n.Ts("globals.messages.errorChecking", "name", "{globals.terms.permission}"), nil, envelope.GeneralError)
}
if !ok {
return r.SendErrorEnvelope(http.StatusForbidden, "Permission denied", nil, envelope.PermissionError)
return r.SendErrorEnvelope(http.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
}
// Set user in the request context.
@@ -129,9 +190,17 @@ func authPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
// Validate session.
user, err := app.auth.ValidateSession(r)
if err != nil {
app.lo.Error("error validating session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session", nil, envelope.PermissionError)
// Session is not valid, destroy it and redirect to login.
if err != simplesessions.ErrInvalidSession {
app.lo.Error("error validating session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.errorValidating", "name", "{globals.terms.session}"), nil, envelope.GeneralError)
}
if err := app.auth.DestroySession(r); err != nil {
app.lo.Error("error destroying session", "error", err)
}
}
// User is authenticated.
if user.ID > 0 {
return handler(r)
}
@@ -140,7 +209,7 @@ func authPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
if len(nextURI) == 0 {
nextURI = r.RequestCtx.RequestURI()
}
return r.RedirectURI("/", fasthttp.StatusFound, map[string]interface{}{
return r.RedirectURI("/", fasthttp.StatusFound, map[string]any{
"next": string(nextURI),
}, "")
}
@@ -155,7 +224,7 @@ func notAuthPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandle
user, err := app.auth.ValidateSession(r)
if err != nil {
app.lo.Error("error validating session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session", nil, envelope.PermissionError)
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.T("auth.invalidOrExpiredSessionClearCookie"), nil, envelope.GeneralError)
}
if user.ID != 0 {

View File

@@ -2,9 +2,11 @@ package main
import (
"strconv"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/oidc/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -26,6 +28,10 @@ func handleGetAllOIDC(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
// Replace secrets with dummy values.
for i := range out {
out[i].ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
}
return r.SendEnvelope(out)
}
@@ -35,7 +41,7 @@ func handleGetOIDC(r *fastglue.Request) error {
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid OIDC `id`", nil, envelope.InputError)
app.i18n.Ts("globals.messages.invalid", "name", "OIDC `id`"), nil, envelope.InputError)
}
o, err := app.oidc.Get(id, false)
if err != nil {
@@ -44,18 +50,6 @@ func handleGetOIDC(r *fastglue.Request) error {
return r.SendEnvelope(o)
}
// handleTestOIDC tests an OIDC provider URL by doing a discovery on the provider URL.
func handleTestOIDC(r *fastglue.Request) error {
var (
app = r.Context.(*App)
providerURL = string(r.RequestCtx.PostArgs().Peek("provider_url"))
)
if err := app.auth.TestProvider(providerURL); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("OIDC provider discovered successfully")
}
// handleCreateOIDC creates a new OIDC record.
func handleCreateOIDC(r *fastglue.Request) error {
var (
@@ -63,18 +57,28 @@ func handleCreateOIDC(r *fastglue.Request) error {
req = models.OIDC{}
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError)
}
if err := app.oidc.Create(req); err != nil {
// Test OIDC provider URL by performing a discovery.
if err := app.auth.TestProvider(req.ProviderURL); err != nil {
return sendErrorEnvelope(r, err)
}
createdOIDC, err := app.oidc.Create(req)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Reload the auth manager to update the OIDC providers.
if err := reloadAuth(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
}
return r.SendEnvelope("OIDC created successfully")
// Clear client secret before returning
createdOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(createdOIDC)
}
// handleUpdateOIDC updates an OIDC record.
@@ -85,23 +89,32 @@ func handleUpdateOIDC(r *fastglue.Request) error {
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid oidc `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "OIDC `id`"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError)
}
if err = app.oidc.Update(id, req); err != nil {
// Test OIDC provider URL by performing a discovery.
if err := app.auth.TestProvider(req.ProviderURL); err != nil {
return sendErrorEnvelope(r, err)
}
updatedOIDC, err := app.oidc.Update(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Reload the auth manager to update the OIDC providers.
if err := reloadAuth(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
}
return r.SendEnvelope("OIDC updated successfully")
// Clear client secret before returning
updatedOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(updatedOIDC)
}
// handleDeleteOIDC deletes an OIDC record.
@@ -109,11 +122,10 @@ func handleDeleteOIDC(r *fastglue.Request) error {
var app = r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid oidc `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "OIDC `id`"), nil, envelope.InputError)
}
if err = app.oidc.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("OIDC deleted successfully")
return r.SendEnvelope(true)
}

45
cmd/report.go Normal file
View File

@@ -0,0 +1,45 @@
package main
import (
"strconv"
"github.com/zerodha/fastglue"
)
// handleOverviewCounts retrieves general dashboard counts for all users.
func handleOverviewCounts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
counts, err := app.report.GetOverViewCounts()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(counts)
}
// handleOverviewCharts retrieves general dashboard chart data.
func handleOverviewCharts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
)
charts, err := app.report.GetOverviewChart(days)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(charts)
}
// handleOverviewSLA retrieves SLA data for the dashboard.
func handleOverviewSLA(r *fastglue.Request) error {
var (
app = r.Context.(*App)
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
)
sla, err := app.report.GetOverviewSLA(days)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(sla)
}

View File

@@ -14,11 +14,11 @@ func handleGetRoles(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
agents, err := app.role.GetAll()
roles, err := app.role.GetAll()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agents)
return r.SendEnvelope(roles)
}
// handleGetRole returns a single role
@@ -43,7 +43,7 @@ func handleDeleteRole(r *fastglue.Request) error {
if err := app.role.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Role deleted successfully")
return r.SendEnvelope(true)
}
// handleCreateRole creates a new role
@@ -53,12 +53,13 @@ func handleCreateRole(r *fastglue.Request) error {
req = models.Role{}
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := app.role.Create(req); err != nil {
createdRole, err := app.role.Create(req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Role created successfully")
return r.SendEnvelope(createdRole)
}
// handleUpdateRole updates a role
@@ -69,10 +70,11 @@ func handleUpdateRole(r *fastglue.Request) error {
req = models.Role{}
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := app.role.Update(id, req);err != nil {
updatedRole, err := app.role.Update(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Role updated successfully")
return r.SendEnvelope(updatedRole)
}

View File

@@ -1,6 +1,8 @@
package main
import (
"fmt"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/zerodha/fastglue"
)
@@ -11,36 +13,45 @@ const (
// handleSearchConversations searches conversations based on the query.
func handleSearchConversations(r *fastglue.Request) error {
var (
app = r.Context.(*App)
q = string(r.RequestCtx.QueryArgs().Peek("query"))
)
if len(q) < minSearchQueryLength {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, "Query length should be at least 3 characters", nil))
app := r.Context.(*App)
wrapper := func(query string) (interface{}, error) {
return app.search.Conversations(query)
}
conversations, err := app.search.Conversations(q)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(conversations)
return handleSearch(r, wrapper)
}
// handleSearchMessages searches messages based on the query.
func handleSearchMessages(r *fastglue.Request) error {
app := r.Context.(*App)
wrapper := func(query string) (interface{}, error) {
return app.search.Messages(query)
}
return handleSearch(r, wrapper)
}
// handleSearchContacts searches contacts based on the query.
func handleSearchContacts(r *fastglue.Request) error {
app := r.Context.(*App)
wrapper := func(query string) (interface{}, error) {
return app.search.Contacts(query)
}
return handleSearch(r, wrapper)
}
// handleSearch searches for the given query using the provided search function.
func handleSearch(r *fastglue.Request, searchFunc func(string) (interface{}, error)) error {
var (
app = r.Context.(*App)
q = string(r.RequestCtx.QueryArgs().Peek("query"))
)
if len(q) < minSearchQueryLength {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, "Query length should be at least 3 characters", nil))
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("search.minQueryLength", "length", fmt.Sprintf("%d", minSearchQueryLength)), nil))
}
messages, err := app.search.Messages(q)
results, err := searchFunc(q)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(messages)
return r.SendEnvelope(results)
}

View File

@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"net/mail"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
@@ -11,7 +12,7 @@ import (
"github.com/zerodha/fastglue"
)
// handleGetGeneralSettings fetches general settings.
// handleGetGeneralSettings fetches general settings, this endpoint is not behind auth as it has no sensitive data and is required for the app to function.
func handleGetGeneralSettings(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -20,14 +21,16 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
// Unmarshal to add the app.update to the settings.
// Unmarshal to set the app.update to the settings, so the frontend can show that an update is available.
var settings map[string]interface{}
if err := json.Unmarshal(out, &settings); err != nil {
app.lo.Error("error unmarshalling settings", "err", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error fetching settings", nil))
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", app.i18n.T("globals.terms.setting")), nil))
}
// Add the app.update to the settings, adding `app` prefix to the key to match the settings structure in db.
// Set the app.update to the settings, adding `app` prefix to the key to match the settings structure in db.
settings["app.update"] = app.update
// Set app version.
settings["app.version"] = versionString
return r.SendEnvelope(settings)
}
@@ -39,20 +42,23 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, "")
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
}
// Remove any trailing slash `/` from the root url.
req.RootURL = strings.TrimRight(req.RootURL, "/")
if err := app.setting.Update(req); err != nil {
return sendErrorEnvelope(r, err)
}
// Reload the settings and templates.
if err := reloadSettings(app); err != nil {
return envelope.NewError(envelope.GeneralError, "Could not reload settings, Please restart the app.", nil)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
}
if err := reloadTemplates(app); err != nil {
return envelope.NewError(envelope.GeneralError, "Could not reload settings, Please restart the app.", nil)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
}
return r.SendEnvelope("Settings updated successfully")
return r.SendEnvelope(true)
}
// handleGetEmailNotificationSettings fetches email notification settings.
@@ -69,7 +75,7 @@ func handleGetEmailNotificationSettings(r *fastglue.Request) error {
// Unmarshal and filter out password.
if err := json.Unmarshal(out, &notif); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error fetching settings", nil))
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", app.i18n.T("globals.terms.setting")), nil))
}
if notif.Password != "" {
notif.Password = strings.Repeat(stringutil.PasswordDummy, 10)
@@ -86,7 +92,7 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
}
out, err := app.setting.GetByPrefix("notification.email")
@@ -95,7 +101,12 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
}
if err := json.Unmarshal(out, &cur); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error updating settings", nil))
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUpdating", "name", app.i18n.T("globals.terms.setting")), nil))
}
// Make sure it's a valid from email address.
if _, err := mail.ParseAddress(req.EmailAddress); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.invalidFromAddress"), nil, envelope.InputError)
}
if req.Password == "" {
@@ -105,5 +116,7 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
if err := app.setting.Update(req); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Settings updated successfully, Please restart the app for changes to take effect.")
// No reload implemented, so user has to restart the app.
return r.SendEnvelope(true)
}

View File

@@ -5,10 +5,12 @@ import (
"time"
"github.com/abhinavxd/libredesk/internal/envelope"
smodels "github.com/abhinavxd/libredesk/internal/sla/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
// handleGetSLAs returns all SLAs.
func handleGetSLAs(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -20,50 +22,82 @@ func handleGetSLAs(r *fastglue.Request) error {
return r.SendEnvelope(slas)
}
// handleGetSLA returns the SLA with the given ID.
func handleGetSLA(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid SLA `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
sla, err := app.sla.Get(id)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(sla)
}
// handleCreateSLA creates a new SLA.
func handleCreateSLA(r *fastglue.Request) error {
var (
app = r.Context.(*App)
name = string(r.RequestCtx.PostArgs().Peek("name"))
desc = string(r.RequestCtx.PostArgs().Peek("description"))
firstRespTime = string(r.RequestCtx.PostArgs().Peek("first_response_time"))
resTime = string(r.RequestCtx.PostArgs().Peek("resolution_time"))
app = r.Context.(*App)
sla smodels.SLAPolicy
)
// Validate time duration strings
if _, err := time.ParseDuration(firstRespTime); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `first_response_time` duration.", nil, envelope.InputError)
if err := r.Decode(&sla, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if _, err := time.ParseDuration(resTime); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `resolution_time` duration.", nil, envelope.InputError)
}
if err := app.sla.Create(name, desc, firstRespTime, resTime); err != nil {
if err := validateSLA(app, &sla); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("SLA created successfully.")
createdSLA, err := app.sla.Create(sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(createdSLA)
}
// handleUpdateSLA updates the SLA with the given ID.
func handleUpdateSLA(r *fastglue.Request) error {
var (
app = r.Context.(*App)
sla smodels.SLAPolicy
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&sla, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if err := validateSLA(app, &sla); err != nil {
return sendErrorEnvelope(r, err)
}
updatedSLA, err := app.sla.Update(id, sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(updatedSLA)
}
// handleDeleteSLA deletes the SLA with the given ID.
func handleDeleteSLA(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid SLA `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err = app.sla.Delete(id); err != nil {
@@ -73,31 +107,83 @@ func handleDeleteSLA(r *fastglue.Request) error {
return r.SendEnvelope(true)
}
func handleUpdateSLA(r *fastglue.Request) error {
var (
app = r.Context.(*App)
name = string(r.RequestCtx.PostArgs().Peek("name"))
desc = string(r.RequestCtx.PostArgs().Peek("description"))
firstRespTime = string(r.RequestCtx.PostArgs().Peek("first_response_time"))
resTime = string(r.RequestCtx.PostArgs().Peek("resolution_time"))
)
// Validate time duration strings
if _, err := time.ParseDuration(firstRespTime); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `first_response_time` duration.", nil, envelope.InputError)
// validateSLA validates the SLA policy and returns an envelope.Error if any validation fails.
func validateSLA(app *App, sla *smodels.SLAPolicy) error {
if sla.Name == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
}
if _, err := time.ParseDuration(resTime); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `resolution_time` duration.", nil, envelope.InputError)
if sla.FirstResponseTime.String == "" && sla.NextResponseTime.String == "" && sla.ResolutionTime.String == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "At least one of `first_response_time`, `next_response_time`, or `resolution_time` must be provided."), nil)
}
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid SLA `id`.", nil, envelope.InputError)
// Validate notifications if any.
for _, n := range sla.Notifications {
if n.Type == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`type`"), nil)
}
if n.TimeDelayType == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`time_delay_type`"), nil)
}
if n.Metric == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`metric`"), nil)
}
if n.TimeDelayType != "immediately" {
if n.TimeDelay == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`time_delay`"), nil)
}
// Validate time delay duration.
td, err := time.ParseDuration(n.TimeDelay)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`time_delay`"), nil)
}
if td.Minutes() < 1 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`time_delay`"), nil)
}
}
if len(n.Recipients) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`recipients`"), nil)
}
}
if err := app.sla.Update(id, name, desc, firstRespTime, resTime); err != nil {
return sendErrorEnvelope(r, err)
// Validate first response time duration string if not empty.
if sla.FirstResponseTime.String != "" {
frt, err := time.ParseDuration(sla.FirstResponseTime.String)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil)
}
if frt.Minutes() < 1 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`first_response_time`"), nil)
}
}
return r.SendEnvelope(true)
// Validate resolution time duration string if not empty.
if sla.ResolutionTime.String != "" {
rt, err := time.ParseDuration(sla.ResolutionTime.String)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil)
}
if rt.Minutes() < 1 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`resolution_time`"), nil)
}
// Compare with first response time if both are present.
if sla.FirstResponseTime.String != "" {
frt, _ := time.ParseDuration(sla.FirstResponseTime.String)
if frt > rt {
return envelope.NewError(envelope.InputError, app.i18n.T("sla.firstResponseTimeAfterResolution"), nil)
}
}
}
// Validate next response time duration string if not empty.
if sla.NextResponseTime.String != "" {
nrt, err := time.ParseDuration(sla.NextResponseTime.String)
if err != nil {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`next_response_time`"), nil)
}
if nrt.Minutes() < 1 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`next_response_time`"), nil)
}
}
return nil
}

View File

@@ -26,19 +26,19 @@ func handleCreateStatus(r *fastglue.Request) error {
status = cmodels.Status{}
)
if err := r.Decode(&status, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if status.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty status `Name`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
err := app.status.Create(status.Name)
createdStatus, err := app.status.Create(status.Name)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(createdStatus)
}
func handleDeleteStatus(r *fastglue.Request) error {
@@ -46,20 +46,13 @@ func handleDeleteStatus(r *fastglue.Request) error {
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid status `id`.", nil, envelope.InputError)
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty status `ID`", nil, envelope.InputError)
}
err = app.status.Delete(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
@@ -70,22 +63,21 @@ func handleUpdateStatus(r *fastglue.Request) error {
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid status `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&status, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if status.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty status `Name`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
err = app.status.Update(id, status.Name)
updatedStatus, err := app.status.Update(id, status.Name)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedStatus)
}

View File

@@ -9,83 +9,80 @@ import (
"github.com/zerodha/fastglue"
)
// handleGetTags returns all tags from the database.
func handleGetTags(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
t, err := app.tag.GetAll()
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(t)
}
// handleCreateTag creates a new tag in the database.
func handleCreateTag(r *fastglue.Request) error {
var (
app = r.Context.(*App)
tag = tmodels.Tag{}
)
if err := r.Decode(&tag, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if tag.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty tag `Name`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
err := app.tag.Create(tag.Name)
createdTag, err := app.tag.Create(tag.Name)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(createdTag)
}
// handleDeleteTag deletes a tag from the database.
func handleDeleteTag(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid tag `id`.", nil, envelope.InputError)
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty tag `ID`", nil, envelope.InputError)
}
err = app.tag.Delete(id)
if err != nil {
if err = app.tag.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleUpdateTag updates an existing tag in the database.
func handleUpdateTag(r *fastglue.Request) error {
var (
app = r.Context.(*App)
tag = tmodels.Tag{}
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid tag `id`.", nil, envelope.InputError)
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&tag, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if tag.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty tag `Name`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
err = app.tag.Update(id, tag.Name)
updatedTag, err := app.tag.Update(id, tag.Name)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedTag)
}

View File

@@ -4,8 +4,8 @@ import (
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/team/models"
"github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue"
)
@@ -40,7 +40,7 @@ func handleGetTeam(r *fastglue.Request) error {
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id < 1 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid team `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
team, err := app.team.Get(id)
if err != nil {
@@ -52,41 +52,42 @@ func handleGetTeam(r *fastglue.Request) error {
// handleCreateTeam creates a new team.
func handleCreateTeam(r *fastglue.Request) error {
var (
app = r.Context.(*App)
name = string(r.RequestCtx.PostArgs().Peek("name"))
timezone = string(r.RequestCtx.PostArgs().Peek("timezone"))
emoji = string(r.RequestCtx.PostArgs().Peek("emoji"))
conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
businessHrsID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
slaPolicyID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("sla_policy_id")))
maxAutoAssignedConversations, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("max_auto_assigned_conversations")))
app = r.Context.(*App)
req = models.Team{}
)
if err := app.team.Create(name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID != 0), null.NewInt(slaPolicyID, slaPolicyID != 0), emoji, maxAutoAssignedConversations); err != nil {
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
createdTeam, err := app.team.Create(req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Team created successfully.")
return r.SendEnvelope(createdTeam)
}
// handleUpdateTeam updates an existing team.
func handleUpdateTeam(r *fastglue.Request) error {
var (
app = r.Context.(*App)
name = string(r.RequestCtx.PostArgs().Peek("name"))
timezone = string(r.RequestCtx.PostArgs().Peek("timezone"))
emoji = string(r.RequestCtx.PostArgs().Peek("emoji"))
conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
businessHrsID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
slaPolicyID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("sla_policy_id")))
maxAutoAssignedConversations, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("max_auto_assigned_conversations")))
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
req = models.Team{}
)
if id < 1 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid team `id`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.team.Update(id, name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID != 0), null.NewInt(slaPolicyID, slaPolicyID != 0), emoji, maxAutoAssignedConversations); err != nil {
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
updatedTeam, err := app.team.Update(id, req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations);
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Team updated successfully.")
return r.SendEnvelope(updatedTeam)
}
// handleDeleteTeam deletes a team
@@ -96,12 +97,11 @@ func handleDeleteTeam(r *fastglue.Request) error {
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid team `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
err = app.team.Delete(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Team deleted successfully.")
return r.SendEnvelope(true)
}

View File

@@ -16,7 +16,7 @@ func handleGetTemplates(r *fastglue.Request) error {
typ = string(r.RequestCtx.QueryArgs().Peek("type"))
)
if typ == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `type`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`type`"), nil, envelope.InputError)
}
t, err := app.tmpl.GetAll(typ)
if err != nil {
@@ -32,8 +32,7 @@ func handleGetTemplate(r *fastglue.Request) error {
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid template `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
t, err := app.tmpl.Get(id)
if err != nil {
@@ -49,12 +48,16 @@ func handleCreateTemplate(r *fastglue.Request) error {
req = models.Template{}
)
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err := app.tmpl.Create(req); err != nil {
if req.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
template, err := app.tmpl.Create(req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(template)
}
// handleUpdateTemplate updates a template.
@@ -69,12 +72,16 @@ func handleUpdateTemplate(r *fastglue.Request) error {
"Invalid template `id`.", nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err = app.tmpl.Update(id, req); err != nil {
if req.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
updatedTemplate, err := app.tmpl.Update(id, req)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedTemplate)
}
// handleDeleteTemplate deletes a template.
@@ -89,7 +96,7 @@ func handleDeleteTemplate(r *fastglue.Request) error {
"Invalid template `id`.", nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Bad request", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if err = app.tmpl.Delete(id); err != nil {
return sendErrorEnvelope(r, err)

View File

@@ -83,9 +83,9 @@ func checkUpdates(curVersion string, interval time.Duration, app *App) {
app.Unlock()
}
// Give a 15 minute buffer after app start in case the admin wants to disable
// Give a 5 minute buffer after app start in case the admin wants to disable
// update checks entirely and not make a request to upstream.
time.Sleep(time.Minute * 15)
time.Sleep(time.Minute * 5)
fnCheck()
// Thereafter, check every $interval.

View File

@@ -31,6 +31,11 @@ type migFunc struct {
// The functions are named as: v0.7.0 => migrations.V0_7_0() and are idempotent.
var migList = []migFunc{
{"v0.3.0", migrations.V0_3_0},
{"v0.4.0", migrations.V0_4_0},
{"v0.5.0", migrations.V0_5_0},
{"v0.6.0", migrations.V0_6_0},
{"v0.7.0", migrations.V0_7_0},
{"v0.8.0", migrations.V0_8_0},
}
// upgrade upgrades the database to the current version by running SQL migration files

View File

@@ -2,7 +2,7 @@ package main
import (
"fmt"
"net/http"
"mime/multipart"
"path/filepath"
"slices"
"strconv"
@@ -16,98 +16,141 @@ import (
"github.com/abhinavxd/libredesk/internal/stringutil"
tmpl "github.com/abhinavxd/libredesk/internal/template"
"github.com/abhinavxd/libredesk/internal/user/models"
realip "github.com/ferluci/fast-realip"
"github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue"
)
const (
maxAvatarSizeMB = 20
maxAvatarSizeMB = 2
)
// handleGetUsers returns all users.
func handleGetUsers(r *fastglue.Request) error {
// Request structs for user-related endpoints
// UpdateAvailabilityRequest represents the request to update user availability
type UpdateAvailabilityRequest struct {
Status string `json:"status"`
}
// ResetPasswordRequest represents the password reset request
type ResetPasswordRequest struct {
Email string `json:"email"`
}
// SetPasswordRequest represents the set password request
type SetPasswordRequest struct {
Token string `json:"token"`
Password string `json:"password"`
}
// AvailabilityRequest represents the request to update agent availability
type AvailabilityRequest struct {
Status string `json:"status"`
}
// handleGetAgents returns all agents.
func handleGetAgents(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
agents, err := app.user.GetAll()
agents, err := app.user.GetAgents()
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agents)
}
// handleGetUsersCompact returns all users in a compact format.
func handleGetUsersCompact(r *fastglue.Request) error {
// handleGetAgentsCompact returns all agents in a compact format.
func handleGetAgentsCompact(r *fastglue.Request) error {
var app = r.Context.(*App)
agents, err := app.user.GetAllCompact()
agents, err := app.user.GetAgentsCompact()
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agents)
}
// handleGetUser returns a user.
func handleGetUser(r *fastglue.Request) error {
// handleGetAgent returns an agent.
func handleGetAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid user `id`.", nil, envelope.InputError)
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
user, err := app.user.Get(id)
agent, err := app.user.GetAgent(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(user)
return r.SendEnvelope(agent)
}
// handleUpdateUserAvailability updates the current user availability.
func handleUpdateUserAvailability(r *fastglue.Request) error {
// handleUpdateAgentAvailability updates the current agent availability.
func handleUpdateAgentAvailability(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
status = string(r.RequestCtx.PostArgs().Peek("status"))
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx)
availReq AvailabilityRequest
)
if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
// Decode JSON request
if err := r.Decode(&availReq, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("User availability updated successfully.")
// Same status?
if agent.AvailabilityStatus == availReq.Status {
return r.SendEnvelope(true)
}
// Update availability status.
if err := app.user.UpdateAvailability(auser.ID, availReq.Status); err != nil {
return sendErrorEnvelope(r, err)
}
// Skip activity log if agent returns online from away (to avoid spam).
if !(agent.AvailabilityStatus == models.Away && availReq.Status == models.Online) {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, availReq.Status, ip, "", 0); err != nil {
app.lo.Error("error creating activity log", "error", err)
}
}
return r.SendEnvelope(true)
}
// handleGetCurrentUserTeams returns the teams of a user.
func handleGetCurrentUserTeams(r *fastglue.Request) error {
// handleGetCurrentAgentTeams returns the teams of an agent.
func handleGetCurrentAgentTeams(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
teams, err := app.team.GetUserTeams(user.ID)
teams, err := app.team.GetUserTeams(agent.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(teams)
}
// handleUpdateCurrentUser updates the current user.
func handleUpdateCurrentUser(r *fastglue.Request) error {
// handleUpdateCurrentAgent updates the current agent.
func handleUpdateCurrentAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Get current user.
currentUser, err := app.user.Get(user.ID)
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -115,104 +158,56 @@ func handleUpdateCurrentUser(r *fastglue.Request) error {
form, err := r.RequestCtx.MultipartForm()
if err != nil {
app.lo.Error("error parsing form data", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error parsing data", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError)
}
files, ok := form.File["files"]
// Upload avatar?
if ok && len(files) > 0 {
fileHeader := files[0]
file, err := fileHeader.Open()
if err != nil {
app.lo.Error("error reading uploaded", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error reading file", nil, envelope.GeneralError)
}
defer file.Close()
// Sanitize filename.
srcFileName := stringutil.SanitizeFilename(fileHeader.Filename)
srcContentType := fileHeader.Header.Get("Content-Type")
srcFileSize := fileHeader.Size
srcExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcFileName)), ".")
if !slices.Contains(image.Exts, srcExt) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "File type is not an image", nil, envelope.InputError)
}
// Check file size
if bytesToMegabytes(srcFileSize) > maxAvatarSizeMB {
app.lo.Error("error uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB)
return r.SendErrorEnvelope(
http.StatusRequestEntityTooLarge,
fmt.Sprintf("File size is too large. Please upload file lesser than %d MB", maxAvatarSizeMB),
nil,
envelope.GeneralError,
)
}
// Reset ptr.
file.Seek(0, 0)
linkedModel := null.StringFrom(mmodels.ModelUser)
linkedID := null.IntFrom(user.ID)
disposition := null.NewString("", false)
contentID := ""
meta := []byte("{}")
media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta)
if err != nil {
app.lo.Error("error uploading file", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error uploading file", nil, envelope.GeneralError)
}
// Delete current avatar.
if currentUser.AvatarURL.Valid {
fileName := filepath.Base(currentUser.AvatarURL.String)
app.media.Delete(fileName)
}
// Save file path.
path, err := stringutil.GetPathFromURL(media.URL)
if err != nil {
app.lo.Debug("error getting path from URL", "url", media.URL, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error uploading file", nil, envelope.GeneralError)
}
if err := app.user.UpdateAvatar(user.ID, path); err != nil {
if err := uploadUserAvatar(r, &agent, files); err != nil {
return sendErrorEnvelope(r, err)
}
}
return r.SendEnvelope("User updated successfully.")
return r.SendEnvelope(true)
}
// handleCreateUser creates a new user.
func handleCreateUser(r *fastglue.Request) error {
// handleCreateAgent creates a new agent.
func handleCreateAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = models.User{}
)
if err := r.Decode(&user, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if user.Email.String == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `email`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
}
user.Email = null.StringFrom(strings.TrimSpace(strings.ToLower(user.Email.String)))
if !stringutil.ValidEmail(user.Email.String) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
}
if user.Roles == nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Please select at least one role", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
}
if user.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `first_name`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
}
// Right now, only agents can be created.
if err := app.user.CreateAgent(&user); err != nil {
return sendErrorEnvelope(r, err)
}
// Upsert user teams.
if err := app.team.UpsertUserTeams(user.ID, user.Teams.Names()); err != nil {
return sendErrorEnvelope(r, err)
if len(user.Teams) > 0 {
if err := app.team.UpsertUserTeams(user.ID, user.Teams.Names()); err != nil {
return sendErrorEnvelope(r, err)
}
}
if user.SendWelcomeEmail {
@@ -223,82 +218,109 @@ func handleCreateUser(r *fastglue.Request) error {
}
// Render template and send email.
content, err := app.tmpl.RenderTemplate(tmpl.TmplWelcome, map[string]interface{}{
content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{
"ResetToken": resetToken,
"Email": user.Email,
"Email": user.Email.String,
})
if err != nil {
app.lo.Error("error rendering template", "error", err)
return r.SendEnvelope("User created successfully, but error rendering welcome email.")
return r.SendEnvelope(true)
}
if err := app.notifier.Send(notifier.Message{
UserIDs: []int{user.ID},
Subject: "Welcome",
Content: content,
Provider: notifier.ProviderEmail,
RecipientEmails: []string{user.Email.String},
Subject: "Welcome to Libredesk",
Content: content,
Provider: notifier.ProviderEmail,
}); err != nil {
app.lo.Error("error sending notification message", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "User created successfully, but could not send welcome email.", nil))
return r.SendEnvelope(true)
}
}
return r.SendEnvelope("User created successfully.")
return r.SendEnvelope(true)
}
// handleUpdateUser updates a user.
func handleUpdateUser(r *fastglue.Request) error {
// handleUpdateAgent updates an agent.
func handleUpdateAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = models.User{}
app = r.Context.(*App)
user = models.User{}
auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid user `id`.", nil, envelope.InputError)
if id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&user, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if user.Email.String == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `email`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
}
user.Email = null.StringFrom(strings.TrimSpace(strings.ToLower(user.Email.String)))
if !stringutil.ValidEmail(user.Email.String) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
}
if user.Roles == nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Please select at least one role", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
}
if user.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `first_name`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
}
// Update user.
if err = app.user.Update(id, user); err != nil {
agent, err := app.user.GetAgent(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
oldAvailabilityStatus := agent.AvailabilityStatus
// Update agent.
if err = app.user.UpdateAgent(id, user); err != nil {
return sendErrorEnvelope(r, err)
}
// Upsert user teams.
// Invalidate authz cache.
defer app.authz.InvalidateUserCache(id)
// Create activity log if user availability status changed.
if oldAvailabilityStatus != user.AvailabilityStatus {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, user.AvailabilityStatus, ip, user.Email.String, id); err != nil {
app.lo.Error("error creating activity log", "error", err)
}
}
// Upsert agent teams.
if err := app.team.UpsertUserTeams(id, user.Teams.Names()); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("User updated successfully.")
return r.SendEnvelope(true)
}
// handleDeleteUser soft deletes a user.
func handleDeleteUser(r *fastglue.Request) error {
// handleDeleteAgent soft deletes an agent.
func handleDeleteAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, err = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid user `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "{globals.terms.user} `id`"), nil, envelope.InputError)
}
// Disallow if self-deleting.
if id == auser.ID {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userCannotDeleteSelf"), nil, envelope.InputError)
}
// Soft delete user.
if err = app.user.SoftDelete(id); err != nil {
if err = app.user.SoftDeleteAgent(id); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -307,124 +329,251 @@ func handleDeleteUser(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("User deleted successfully.")
return r.SendEnvelope(true)
}
// handleGetCurrentUser returns the current logged in user.
func handleGetCurrentUser(r *fastglue.Request) error {
// handleGetCurrentAgent returns the current logged in agent.
func handleGetCurrentAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
u, err := app.user.Get(auser.ID)
u, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(u)
}
// handleDeleteAvatar deletes a user avatar.
func handleDeleteAvatar(r *fastglue.Request) error {
// handleDeleteCurrentAgentAvatar deletes the current agent's avatar.
func handleDeleteCurrentAgentAvatar(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
// Get user
user, err := app.user.Get(auser.ID)
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Valid str?
if user.AvatarURL.String == "" {
return r.SendEnvelope("Avatar deleted successfully.")
if agent.AvatarURL.String == "" {
return r.SendEnvelope(true)
}
fileName := filepath.Base(user.AvatarURL.String)
fileName := filepath.Base(agent.AvatarURL.String)
// Delete file from the store.
if err := app.media.Delete(fileName); err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.user.UpdateAvatar(user.ID, ""); err != nil {
if err = app.user.UpdateAvatar(agent.ID, ""); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Avatar deleted successfully.")
return r.SendEnvelope(true)
}
// handleResetPassword generates a reset password token and sends an email to the user.
// handleResetPassword generates a reset password token and sends an email to the agent.
func handleResetPassword(r *fastglue.Request) error {
var (
app = r.Context.(*App)
p = r.RequestCtx.PostArgs()
auser, ok = r.RequestCtx.UserValue("user").(amodels.User)
email = string(p.Peek("email"))
resetReq ResetPasswordRequest
)
if ok && auser.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "User is already logged in, Please logout to reset password.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
}
if email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `email`", nil, envelope.InputError)
// Decode JSON request
if err := r.Decode(&resetReq, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
user, err := app.user.GetByEmail(email)
if resetReq.Email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
}
agent, err := app.user.GetAgent(0, resetReq.Email)
if err != nil {
// Send 200 even if user not found, to prevent email enumeration.
return r.SendEnvelope("Reset password email sent successfully.")
}
token, err := app.user.SetResetPasswordToken(user.ID)
token, err := app.user.SetResetPasswordToken(agent.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Send email.
content, err := app.tmpl.RenderTemplate(tmpl.TmplResetPassword,
map[string]string{
"ResetToken": token,
})
content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplResetPassword, map[string]string{
"ResetToken": token,
})
if err != nil {
app.lo.Error("error rendering template", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error rendering template", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.errorSendingPasswordResetEmail"), nil, envelope.GeneralError)
}
if err := app.notifier.Send(notifier.Message{
UserIDs: []int{user.ID},
Subject: "Reset Password",
Content: content,
Provider: notifier.ProviderEmail,
RecipientEmails: []string{agent.Email.String},
Subject: "Reset Password",
Content: content,
Provider: notifier.ProviderEmail,
}); err != nil {
app.lo.Error("error sending password reset email", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error sending password reset email", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.errorSendingPasswordResetEmail"), nil, envelope.GeneralError)
}
return r.SendEnvelope("Reset password email sent successfully.")
return r.SendEnvelope(true)
}
// handleSetPassword resets the password with the provided token.
func handleSetPassword(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user, ok = r.RequestCtx.UserValue("user").(amodels.User)
p = r.RequestCtx.PostArgs()
password = string(p.Peek("password"))
token = string(p.Peek("token"))
app = r.Context.(*App)
agent, ok = r.RequestCtx.UserValue("user").(amodels.User)
req = SetPasswordRequest{}
)
if ok && user.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "User is already logged in", nil, envelope.InputError)
if ok && agent.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
}
if password == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `password`", nil, envelope.InputError)
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
if err := app.user.ResetPassword(token, password); err != nil {
if req.Password == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "{globals.terms.password}"), nil, envelope.InputError)
}
if err := app.user.ResetPassword(req.Token, req.Password); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Password reset successfully.")
return r.SendEnvelope(true)
}
// uploadUserAvatar uploads the user avatar.
func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart.FileHeader) error {
var app = r.Context.(*App)
fileHeader := files[0]
file, err := fileHeader.Open()
if err != nil {
app.lo.Error("error opening uploaded file", "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil)
}
defer file.Close()
// Sanitize filename.
srcFileName := stringutil.SanitizeFilename(fileHeader.Filename)
srcContentType := fileHeader.Header.Get("Content-Type")
srcFileSize := fileHeader.Size
srcExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcFileName)), ".")
if !slices.Contains(image.Exts, srcExt) {
return envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.fileTypeisNotAnImage"), nil)
}
// Check file size
if bytesToMegabytes(srcFileSize) > maxAvatarSizeMB {
app.lo.Error("error uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB)
return envelope.NewError(
envelope.InputError,
app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", maxAvatarSizeMB)),
nil,
)
}
// Reset ptr.
file.Seek(0, 0)
linkedModel := null.StringFrom(mmodels.ModelUser)
linkedID := null.IntFrom(user.ID)
disposition := null.NewString("", false)
contentID := ""
meta := []byte("{}")
media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta)
if err != nil {
app.lo.Error("error uploading file", "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
}
// Delete current avatar.
if user.AvatarURL.Valid {
fileName := filepath.Base(user.AvatarURL.String)
app.media.Delete(fileName)
}
// Save file path.
path, err := stringutil.GetPathFromURL(media.URL)
if err != nil {
app.lo.Debug("error getting path from URL", "url", media.URL, "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
}
fmt.Println("path", path)
if err := app.user.UpdateAvatar(user.ID, path); err != nil {
return sendErrorEnvelope(r, err)
}
return nil
}
// handleGenerateAPIKey generates a new API key for a user
func handleGenerateAPIKey(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
// Check if user exists
user, err := app.user.GetAgent(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Generate API key and secret
apiKey, apiSecret, err := app.user.GenerateAPIKey(user.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Return the API key and secret (only shown once)
response := struct {
APIKey string `json:"api_key"`
APISecret string `json:"api_secret"`
}{
APIKey: apiKey,
APISecret: apiSecret,
}
return r.SendEnvelope(response)
}
// handleRevokeAPIKey revokes a user's API key
func handleRevokeAPIKey(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
// Check if user exists
_, err := app.user.GetAgent(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Revoke API key
if err := app.user.RevokeAPIKey(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}

View File

@@ -16,7 +16,7 @@ func handleGetUserViews(r *fastglue.Request) error {
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -35,61 +35,50 @@ func handleCreateUserView(r *fastglue.Request) error {
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
if err := r.Decode(&view, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
if view.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Name`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`Name`"), nil, envelope.InputError)
}
if string(view.Filters) == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Filter`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`Filters`"), nil, envelope.InputError)
}
if err := app.view.Create(view.Name, view.Filters, user.ID); err != nil {
createdView, err := app.view.Create(view.Name, view.Filters, user.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("View created successfully")
return r.SendEnvelope(createdView)
}
// handleGetUserView deletes a view for a user.
// handleDeleteUserView deletes a view for a user.
func handleDeleteUserView(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid view `id`.", nil, envelope.InputError)
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `ID`", nil, envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
view, err := app.view.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
if view.UserID != user.ID {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Forbidden", nil, envelope.PermissionError)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
}
if err = app.view.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("View deleted successfully")
return r.SendEnvelope(true)
}
// handleUpdateUserView updates a view for a user.
@@ -101,39 +90,31 @@ func handleUpdateUserView(r *fastglue.Request) error {
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid view `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&view, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
if view.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Name`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if string(view.Filters) == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Filter`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`filters`"), nil, envelope.InputError)
}
v, err := app.view.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
if v.UserID != user.ID {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Forbidden", nil, envelope.PermissionError)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
}
if err = app.view.Update(id, view.Name, view.Filters); err != nil {
updatedView, err := app.view.Update(id, view.Name, view.Filters)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
return r.SendEnvelope(updatedView)
}

191
cmd/webhooks.go Normal file
View File

@@ -0,0 +1,191 @@
package main
import (
"strconv"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/abhinavxd/libredesk/internal/webhook/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
// handleGetWebhooks returns all webhooks from the database.
func handleGetWebhooks(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
webhooks, err := app.webhook.GetAll()
if err != nil {
return sendErrorEnvelope(r, err)
}
// Hide secrets.
for i := range webhooks {
if webhooks[i].Secret != "" {
webhooks[i].Secret = strings.Repeat(stringutil.PasswordDummy, 10)
}
}
return r.SendEnvelope(webhooks)
}
// handleGetWebhook returns a specific webhook by ID.
func handleGetWebhook(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
webhook, err := app.webhook.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Hide secret in the response.
if webhook.Secret != "" {
webhook.Secret = strings.Repeat(stringutil.PasswordDummy, 10)
}
return r.SendEnvelope(webhook)
}
// handleCreateWebhook creates a new webhook in the database.
func handleCreateWebhook(r *fastglue.Request) error {
var (
app = r.Context.(*App)
webhook = models.Webhook{}
)
if err := r.Decode(&webhook, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
// Validate webhook fields
if err := validateWebhook(app, webhook); err != nil {
return r.SendEnvelope(err)
}
webhook, err := app.webhook.Create(webhook)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Clear secret before returning
webhook.Secret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(webhook)
}
// handleUpdateWebhook updates an existing webhook in the database.
func handleUpdateWebhook(r *fastglue.Request) error {
var (
app = r.Context.(*App)
webhook = models.Webhook{}
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&webhook, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
// Validate webhook fields
if err := validateWebhook(app, webhook); err != nil {
return r.SendEnvelope(err)
}
// If secret is empty or contains dummy characters, fetch existing webhook and preserve the secret
if webhook.Secret == "" || strings.Contains(webhook.Secret, stringutil.PasswordDummy) {
existingWebhook, err := app.webhook.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
webhook.Secret = existingWebhook.Secret
}
updatedWebhook, err := app.webhook.Update(id, webhook)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Clear secret before returning
updatedWebhook.Secret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(updatedWebhook)
}
// handleDeleteWebhook deletes a webhook from the database.
func handleDeleteWebhook(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.webhook.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleToggleWebhook toggles the active status of a webhook.
func handleToggleWebhook(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
toggledWebhook, err := app.webhook.Toggle(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Clear secret before returning
toggledWebhook.Secret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(toggledWebhook)
}
// handleTestWebhook sends a test payload to a webhook.
func handleTestWebhook(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.webhook.SendTestWebhook(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// validateWebhook validates the webhook data.
func validateWebhook(app *App, webhook models.Webhook) error {
if webhook.Name == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
}
if webhook.URL == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`url`"), nil)
}
if len(webhook.Events) == 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`events`"), nil)
}
return nil
}

167
cmd/widget_middleware.go Normal file
View File

@@ -0,0 +1,167 @@
package main
import (
"fmt"
"strconv"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
const (
// Context keys for storing authenticated widget data
ctxWidgetClaims = "widget_claims"
ctxWidgetInboxID = "widget_inbox_id"
ctxWidgetContactID = "widget_contact_id"
ctxWidgetInbox = "widget_inbox"
// Header sent in every widget request to identify the inbox
hdrWidgetInboxID = "X-Libredesk-Inbox-ID"
)
// widgetAuth middleware authenticates widget requests using JWT and inbox validation.
// It always validates the inbox from X-Libredesk-Inbox-ID header, and conditionally validates JWT.
// For /conversations/init without JWT, it allows visitor creation while still validating inbox.
func widgetAuth(next func(*fastglue.Request) error) func(*fastglue.Request) error {
return func(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
// Always extract and validate inbox_id from custom header
inboxIDHeader := string(r.RequestCtx.Request.Header.Peek(hdrWidgetInboxID))
if inboxIDHeader == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
inboxID, err := strconv.Atoi(inboxIDHeader)
if err != nil || inboxID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Always fetch and validate inbox
inbox, err := app.inbox.GetDBRecord(inboxID)
if err != nil {
app.lo.Error("error fetching inbox", "inbox_id", inboxID, "error", err)
return sendErrorEnvelope(r, err)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Check if inbox is the correct type for widget requests
if inbox.Channel != livechat.ChannelLiveChat {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Always store inbox data in context
r.RequestCtx.SetUserValue(ctxWidgetInboxID, inboxID)
r.RequestCtx.SetUserValue(ctxWidgetInbox, inbox)
// Extract JWT from Authorization header (Bearer token)
authHeader := string(r.RequestCtx.Request.Header.Peek("Authorization"))
// For init endpoint, allow requests without JWT (visitor creation)
if authHeader == "" && strings.Contains(string(r.RequestCtx.Path()), "/conversations/init") {
return next(r)
}
// For all other requests, require JWT
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
}
jwtToken := strings.TrimPrefix(authHeader, "Bearer ")
// Verify JWT using inbox secret
claims, err := verifyStandardJWT(jwtToken, inbox.Secret.String)
if err != nil {
app.lo.Error("invalid JWT", "jwt", jwtToken, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
}
// Resolve user/contact ID from JWT claims
contactID, err := resolveUserIDFromClaims(app, claims)
if err != nil {
envErr, ok := err.(envelope.Error)
if ok && envErr.ErrorType != envelope.NotFoundError {
app.lo.Error("error resolving user ID from JWT claims", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError)
}
}
// Store authenticated data in request context for downstream handlers
r.RequestCtx.SetUserValue(ctxWidgetClaims, claims)
r.RequestCtx.SetUserValue(ctxWidgetContactID, contactID)
return next(r)
}
}
// Helper functions to extract authenticated data from request context
// getWidgetInboxID extracts inbox ID from request context
func getWidgetInboxID(r *fastglue.Request) (int, error) {
val := r.RequestCtx.UserValue(ctxWidgetInboxID)
if val == nil {
return 0, fmt.Errorf("widget middleware not applied: missing inbox ID in context")
}
inboxID, ok := val.(int)
if !ok {
return 0, fmt.Errorf("invalid inbox ID type in context")
}
return inboxID, nil
}
// getWidgetContactID extracts contact ID from request context
func getWidgetContactID(r *fastglue.Request) (int, error) {
val := r.RequestCtx.UserValue(ctxWidgetContactID)
if val == nil {
return 0, fmt.Errorf("widget middleware not applied: missing contact ID in context")
}
contactID, ok := val.(int)
if !ok {
return 0, fmt.Errorf("invalid contact ID type in context")
}
return contactID, nil
}
// getWidgetInbox extracts inbox model from request context
func getWidgetInbox(r *fastglue.Request) (imodels.Inbox, error) {
val := r.RequestCtx.UserValue(ctxWidgetInbox)
if val == nil {
return imodels.Inbox{}, fmt.Errorf("widget middleware not applied: missing inbox in context")
}
inbox, ok := val.(imodels.Inbox)
if !ok {
return imodels.Inbox{}, fmt.Errorf("invalid inbox type in context")
}
return inbox, nil
}
// getWidgetClaimsOptional extracts JWT claims from request context, returns nil if not set
func getWidgetClaimsOptional(r *fastglue.Request) *Claims {
val := r.RequestCtx.UserValue(ctxWidgetClaims)
if val == nil {
return nil
}
if claims, ok := val.(Claims); ok {
return &claims
}
return nil
}
// rateLimitWidget applies rate limiting to widget endpoints.
func rateLimitWidget(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
app := r.Context.(*App)
if err := app.rateLimit.CheckWidgetLimit(r.RequestCtx); err != nil {
return err
}
return handler(r)
}
}

288
cmd/widget_ws.go Normal file
View File

@@ -0,0 +1,288 @@
package main
import (
"encoding/json"
"fmt"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
"github.com/fasthttp/websocket"
"github.com/zerodha/fastglue"
)
// Widget WebSocket message types
const (
WidgetMsgTypeJoin = "join"
WidgetMsgTypeMessage = "message"
WidgetMsgTypeTyping = "typing"
WidgetMsgTypePing = "ping"
WidgetMsgTypePong = "pong"
WidgetMsgTypeError = "error"
WidgetMsgTypeNewMsg = "new_message"
WidgetMsgTypeStatus = "status"
WidgetMsgTypeJoined = "joined"
)
// WidgetMessage represents a message sent through the widget WebSocket
type WidgetMessage struct {
Type string `json:"type"`
JWT string `json:"jwt,omitempty"`
Data any `json:"data"`
}
type WidgetInboxJoinRequest struct {
InboxID int `json:"inbox_id"`
}
// WidgetMessageData represents a chat message through the widget
type WidgetMessageData struct {
ConversationUUID string `json:"conversation_uuid"`
Content string `json:"content"`
SenderName string `json:"sender_name,omitempty"`
SenderType string `json:"sender_type"`
Timestamp int64 `json:"timestamp"`
}
// WidgetTypingData represents typing indicator data
type WidgetTypingData struct {
ConversationUUID string `json:"conversation_uuid"`
IsTyping bool `json:"is_typing"`
}
// handleWidgetWS handles the widget WebSocket connection for live chat.
func handleWidgetWS(r *fastglue.Request) error {
var app = r.Context.(*App)
if err := upgrader.Upgrade(r.RequestCtx, func(conn *websocket.Conn) {
// To store client and live chat references for cleanup.
var client *livechat.Client
var liveChat *livechat.LiveChat
var inboxID int
// Clean up client when connection closes.
defer func() {
conn.Close()
if client != nil && liveChat != nil {
liveChat.RemoveClient(client)
close(client.Channel)
app.lo.Debug("cleaned up client on websocket disconnect", "client_id", client.ID)
}
}()
// Read messages from the WebSocket connection.
for {
var msg WidgetMessage
if err := conn.ReadJSON(&msg); err != nil {
app.lo.Debug("widget websocket connection closed", "error", err)
break
}
switch msg.Type {
// Inbox join request.
case WidgetMsgTypeJoin:
var joinedClient *livechat.Client
var joinedLiveChat *livechat.LiveChat
var joinedInboxID int
var err error
if joinedClient, joinedLiveChat, joinedInboxID, err = handleInboxJoin(app, conn, &msg); err != nil {
app.lo.Error("error handling widget join", "error", err)
sendWidgetError(conn, "Failed to join conversation")
continue
}
// Store the client, livechat, and inbox ID for cleanup and future use.
client = joinedClient
liveChat = joinedLiveChat
inboxID = joinedInboxID
// Typing.
case WidgetMsgTypeTyping:
if err := handleWidgetTyping(app, &msg); err != nil {
app.lo.Error("error handling widget typing", "error", err)
continue
}
// Ping.
case WidgetMsgTypePing:
// Update user's last active timestamp if JWT is provided and client has joined
if msg.JWT != "" && inboxID != 0 {
if claims, err := validateWidgetMessageJWT(app, msg.JWT, inboxID); err == nil {
if userID, err := resolveUserIDFromClaims(app, claims); err == nil {
if err := app.user.UpdateLastActive(userID); err != nil {
app.lo.Error("error updating user last active timestamp", "user_id", userID, "error", err)
} else {
app.lo.Debug("updated user last active timestamp", "user_id", userID)
}
}
}
}
if err := conn.WriteJSON(WidgetMessage{
Type: WidgetMsgTypePong,
}); err != nil {
app.lo.Error("error writing pong to widget client", "error", err)
}
}
}
}); err != nil {
app.lo.Error("error upgrading widget websocket connection", "error", err)
}
return nil
}
// handleInboxJoin handles a websocket join request for a live chat inbox.
func handleInboxJoin(app *App, conn *websocket.Conn, msg *WidgetMessage) (*livechat.Client, *livechat.LiveChat, int, error) {
joinDataBytes, err := json.Marshal(msg.Data)
if err != nil {
return nil, nil, 0, fmt.Errorf("invalid join data: %w", err)
}
var joinData WidgetInboxJoinRequest
if err := json.Unmarshal(joinDataBytes, &joinData); err != nil {
return nil, nil, 0, fmt.Errorf("invalid join data format: %w", err)
}
// Validate JWT with inbox secret
claims, err := validateWidgetMessageJWT(app, msg.JWT, joinData.InboxID)
if err != nil {
return nil, nil, 0, fmt.Errorf("JWT validation failed: %w", err)
}
// Resolve user ID.
userID, err := resolveUserIDFromClaims(app, claims)
if err != nil {
return nil, nil, 0, fmt.Errorf("failed to resolve user ID from claims: %w", err)
}
// Make sure inbox is active.
inbox, err := app.inbox.GetDBRecord(joinData.InboxID)
if err != nil {
return nil, nil, 0, fmt.Errorf("inbox not found: %w", err)
}
if !inbox.Enabled {
return nil, nil, 0, fmt.Errorf("inbox is not enabled")
}
// Get live chat inbox
lcInbox, err := app.inbox.Get(inbox.ID)
if err != nil {
return nil, nil, 0, fmt.Errorf("live chat inbox not found: %w", err)
}
// Assert type.
liveChat, ok := lcInbox.(*livechat.LiveChat)
if !ok {
return nil, nil, 0, fmt.Errorf("inbox is not a live chat inbox")
}
// Add client to live chat session
userIDStr := fmt.Sprintf("%d", userID)
client, err := liveChat.AddClient(userIDStr)
if err != nil {
app.lo.Error("error adding client to live chat", "error", err, "user_id", userIDStr)
return nil, nil, 0, err
}
// Start listening for messages from the live chat channel.
go func() {
for msgData := range client.Channel {
if err := conn.WriteMessage(websocket.TextMessage, msgData); err != nil {
app.lo.Error("error forwarding message to widget client", "error", err)
return
}
}
}()
// Send join confirmation
joinResp := WidgetMessage{
Type: WidgetMsgTypeJoined,
Data: map[string]string{
"message": "namaste!",
},
}
if err := conn.WriteJSON(joinResp); err != nil {
return nil, nil, 0, err
}
app.lo.Debug("widget client joined live chat", "user_id", userIDStr, "inbox_id", joinData.InboxID)
return client, liveChat, joinData.InboxID, nil
}
// handleWidgetTyping handles typing indicators
func handleWidgetTyping(app *App, msg *WidgetMessage) error {
typingDataBytes, err := json.Marshal(msg.Data)
if err != nil {
app.lo.Error("error marshalling typing data", "error", err)
return fmt.Errorf("invalid typing data: %w", err)
}
var typingData WidgetTypingData
if err := json.Unmarshal(typingDataBytes, &typingData); err != nil {
app.lo.Error("error unmarshalling typing data", "error", err)
return fmt.Errorf("invalid typing data format: %w", err)
}
// Get conversation to retrieve inbox ID for JWT validation
if typingData.ConversationUUID == "" {
return fmt.Errorf("conversation UUID is required for typing messages")
}
conversation, err := app.conversation.GetConversation(0, typingData.ConversationUUID)
if err != nil {
app.lo.Error("error fetching conversation for typing", "conversation_uuid", typingData.ConversationUUID, "error", err)
return fmt.Errorf("conversation not found: %w", err)
}
// Validate JWT with inbox secret
claims, err := validateWidgetMessageJWT(app, msg.JWT, conversation.InboxID)
if err != nil {
return fmt.Errorf("JWT validation failed: %w", err)
}
userID := claims.UserID
// Broadcast typing status to agents via conversation manager
// Set broadcastToWidgets=false to avoid echoing back to widget clients
app.conversation.BroadcastTypingToConversation(typingData.ConversationUUID, typingData.IsTyping, false)
app.lo.Debug("Broadcasted typing data from widget user to agents", "user_id", userID, "is_typing", typingData.IsTyping, "conversation_uuid", typingData.ConversationUUID)
return nil
}
// validateWidgetMessageJWT validates the incoming widget message JWT using inbox secret
func validateWidgetMessageJWT(app *App, jwtToken string, inboxID int) (Claims, error) {
if jwtToken == "" {
return Claims{}, fmt.Errorf("JWT token is empty")
}
if inboxID <= 0 {
return Claims{}, fmt.Errorf("inbox ID is required for JWT validation")
}
// Get inbox to retrieve secret for JWT verification
inbox, err := app.inbox.GetDBRecord(inboxID)
if err != nil {
return Claims{}, fmt.Errorf("inbox not found: %w", err)
}
if !inbox.Secret.Valid {
return Claims{}, fmt.Errorf("inbox secret not configured for JWT verification")
}
// Use the existing verifyStandardJWT function which properly validates with inbox secret
claims, err := verifyStandardJWT(jwtToken, inbox.Secret.String)
if err != nil {
return Claims{}, fmt.Errorf("JWT validation failed: %w", err)
}
return claims, nil
}
// sendWidgetError sends an error message to the widget client
func sendWidgetError(conn *websocket.Conn, message string) {
errorMsg := WidgetMessage{
Type: WidgetMsgTypeError,
Data: map[string]string{
"message": message,
},
}
conn.WriteJSON(errorMsg)
}

View File

@@ -1,75 +1,129 @@
# App.
[app]
# Log level: info, debug, warn, error, fatal
log_level = "debug"
# Environment: dev, prod.
# Setting to "dev" will enable color logging in terminal.
env = "dev"
# Whether to automatically check for application updates on start up, app updates are shown as a banner in the admin panel.
check_updates = true
# HTTP server.
[app.server]
# Address to bind the HTTP server to.
address = "0.0.0.0:9000"
# Unix socket path (leave empty to use TCP address instead)
socket = ""
# Do NOT disable secure cookies in production environment if you don't know exactly what you're doing!
disable_secure_cookies = false
# Request read and write timeouts.
read_timeout = "5s"
write_timeout = "5s"
max_body_size = 500000000
# Maximum request body size in bytes (100MB)
# If you are using proxy, you may need to configure them to allow larger request bodies.
max_body_size = 104857600
# Size of the read buffer for incoming requests
read_buffer_size = 4096
# Keepalive settings.
keepalive_timeout = "10s"
# File upload provider to use, either `fs` or `s3`.
[upload]
provider = "fs"
# Filesytem provider.
# Filesystem provider.
[upload.fs]
# Directory where uploaded files are stored, make sure this directory exists and is writable by the application.
upload_path = 'uploads'
# S3 provider.
[upload.s3]
# S3 endpoint URL (required only for non-AWS S3-compatible providers like MinIO).
# Leave empty to use default AWS endpoints.
url = ""
# AWS S3 credentials, keep empty to use attached IAM roles.
access_key = ""
secret_key = ""
# AWS region, e.g., "us-east-1", "eu-west-1", etc.
region = "ap-south-1"
bucket = "bucket"
# S3 bucket name where files will be stored.
bucket = "bucket-name"
# Optional prefix path within the S3 bucket where files will be stored.
# Example, if set to "uploads/media", files will be stored under that path.
# Useful for organizing files inside a shared bucket.
bucket_path = ""
expiry = "6h"
# S3 signed URL expiry duration (e.g., "30m", "1h")
expiry = "30m"
# Postgres.
[db]
# If using docker compose, use the service name as the host. e.g. db
host = "127.0.0.1"
# If running locally, use `localhost`.
host = "db"
# Database port, default is 5432.
port = 5432
user = "postgres"
password = "postgres"
# Update the following values with your database credentials.
user = "libredesk"
password = "libredesk"
database = "libredesk"
ssl_mode = "disable"
# Maximum number of open database connections
max_open = 30
# Maximum number of idle connections in the pool
max_idle = 30
# Maximum time a connection can be reused before being closed
max_lifetime = "300s"
# Redis.
[redis]
# If using docker compose, use the service name as the host. e.g. redis:6379
address = "127.0.0.1:6379"
# If running locally, use `localhost:6379`.
address = "redis:6379"
password = ""
db = 0
[message]
# Number of workers processing outgoing message queue
outgoing_queue_workers = 10
# Number of workers processing incoming message queue
incoming_queue_workers = 10
message_outoing_scan_interval = "50ms"
# How often to scan for outgoing messages to process, keep it low to process messages quickly.
message_outgoing_scan_interval = "50ms"
# Maximum number of messages that can be queued for incoming processing
incoming_queue_size = 5000
# Maximum number of messages that can be queued for outgoing processing
outgoing_queue_size = 5000
[notification]
# Number of concurrent notification workers
concurrency = 2
# Maximum number of notifications that can be queued
queue_size = 2000
[automation]
# Number of workers processing automation rules
worker_count = 10
[autoassigner]
# How often to run automatic conversation assignment
autoassign_interval = "5m"
[webhook]
# Number of webhook delivery workers
workers = 5
# Maximum number of webhook deliveries that can be queued
queue_size = 10000
# HTTP timeout for webhook requests
timeout = "15s"
[conversation]
# How often to check for conversations to unsnooze
unsnooze_interval = "5m"
[sla]
evaluation_interval = "5m"
# How often to evaluate SLA compliance for conversations
evaluation_interval = "5m"
[rate_limit]
[rate_limit.widget]
enabled = true
requests_per_minute = 100

View File

@@ -28,14 +28,15 @@ services:
networks:
- libredesk
ports:
- "5432:5432"
# Only bind on the local interface. To connect to Postgres externally, change this to 0.0.0.0
- "127.0.0.1:5432:5432"
environment:
# Set these environment variables to configure the database, defaults to libredesk.
POSTGRES_USER: ${POSTGRES_USER:-libredesk}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-libredesk}
POSTGRES_DB: ${POSTGRES_DB:-libredesk}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U libredesk"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-libredesk} -d ${POSTGRES_DB:-libredesk}"]
interval: 10s
timeout: 5s
retries: 6
@@ -48,7 +49,8 @@ services:
container_name: libredesk_redis
restart: unless-stopped
ports:
- "6379:6379"
# Only bind on the local interface.
- "127.0.0.1:6379:6379"
networks:
- libredesk
volumes:
@@ -59,4 +61,4 @@ networks:
volumes:
postgres-data:
redis-data:
redis-data:

View File

@@ -0,0 +1,30 @@
# API getting started
You can access the Libredesk API to interact with your instance programmatically.
## Generating API keys
1. **Edit agent**: Go to Admin → Teammate → Agent → Edit
2. **Generate new API key**: An API Key and API Secret will be generated for the agent
3. **Save the credentials**: Keep both the API Key and API Secret secure
4. **Key management**: You can revoke / regenerate API keys at any time from the same page
## Using the API
LibreDesk supports two authentication schemes:
### Basic authentication
```bash
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
-H "Authorization: Basic <base64_encoded_key:secret>"
```
### Token authentication
```bash
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
-H "Authorization: token your_api_key:your_api_secret"
```
## API Documentation
Complete API documentation with available endpoints and examples coming soon.

View File

@@ -4,9 +4,10 @@ Libredesk is a monorepo with a Go backend and a Vue.js frontend. The frontend us
### Pre-requisites
- `go`
- `nodejs` (if you are working on the frontend) and `pnpm`
- Postgres database (>= 13)
- go
- nodejs (if you are working on the frontend) and `pnpm`
- redis
- postgres database (>= 13)
### First time setup

BIN
docs/docs/images/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

View File

@@ -1,13 +1,17 @@
# Introduction
Libredesk is an open source, self-hosted customer support desk. Single binary app.
Libredesk is an open-source, self-hosted customer support desk — single binary app.
<div style="border: 1px solid #ccc; padding: 1px; border-radius:5px; box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.1); background-color: #fff;">
<a href="https://libredesk.io">
<img src="https://hebbkx1anhila5yf.public.blob.vercel-storage.com/Screenshot_20250220_231723-VxuEQgEiFfI9xhzJDOvgMK0yJ0TwR3.png" alt="libredesk screenshot" style="display: block; margin: 0 auto;">
</a>
<div style="border: 1px solid #ccc; padding: 2px; border-radius: 6px; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); background-color: #f9f9f9;">
<a href="https://libredesk.io">
<img src="images/hero.png" alt="libredesk UI screenshot" style="display: block; margin: 0 auto; max-width: 100%; border-radius: 4px;" />
</a>
</div>
## Developers
Libredesk is a free and open source software licensed under AGPLv3. If you are interested in contributing, check out the [GitHub repository](https://github.com/abhinavxd/libredesk) and refer to the [developer setup](developer-setup.md). The backend is written in Go and the frontend is Vue js 3 with Shadcn for UI components.
Libredesk is licensed under AGPLv3. Contributions are welcome.
- Source code: [GitHub](https://github.com/abhinavxd/libredesk)
- Setup guide: [Developer setup](developer-setup.md)
- Stack: Go backend, Vue 3 frontend (Shadcn UI)

View File

@@ -36,8 +36,6 @@ docker exec -it libredesk_app ./libredesk --set-system-user-password
Go to `http://localhost:9000` and login with the email `System` and the password you set using the `--set-system-user-password` command.
---
## Compiling from source
@@ -46,3 +44,22 @@ To compile the latest unreleased version (`main` branch):
1. Make sure `go`, `nodejs`, and `pnpm` are installed on your system.
2. `git clone git@github.com:abhinavxd/libredesk.git`
3. `cd libredesk && make`. This will generate the `libredesk` binary.
## Nginx
Libredesk uses websockets for real-time updates. If you are using Nginx, you need to add the following (or similar) configuration to your Nginx configuration file.
```nginx
client_max_body_size 100M;
location / {
proxy_pass http://localhost:9000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
}
```

57
docs/docs/sso.md Normal file
View File

@@ -0,0 +1,57 @@
# Setting up SSO
Libredesk supports external OpenID Connect providers (e.g., Google, Keycloak) for signing in users.
!!! note
User accounts must be created in Libredesk manually; signup is not supported.
## Generic Configuration Steps
Since each providers configuration might differ, consult your providers documentation for any additional or divergent settings.
1. Provider setup:
In your providers admin console, create a new OpenID Connect application/client. Retrieve:
- Client ID
- Client Secret
2. Libredesk configuration:
In Libredesk, navigate to Security > SSO and click New SSO and enter the following details:
- Provider URL (e.g., the URL of your OpenID provider)
- Client ID
- Client Secret
- A descriptive name for the connection
3. Redirect URL:
After saving, copy the generated Callback URL from Libredesk and add it as a valid redirect URI in your providers client settings.
## Provider Examples
#### Keycloak
1. Log in to your Keycloak Admin Console.
2. In Keycloak, navigate to Clients and click Create:
- Client ID (e.g., `libredesk-app`)
- Client Protocol: `openid-connect`
- Root URL and Web Origins: your app domain (e.g., `https://ticket.example.com`)
- Under Authentication flow, uncheck everything except the standard flow
- Click save
3. Go to the credentials tab:
- Ensure client authenticator is set to `Client Id and Secret`
- Note down the generated client secret
4. In Libredesk, go to Admin > Security > SSO and click New SSO:
- Provider URL (e.g., `https://keycloak.example.com/realms/yourrealm`)
- Name (e.g., `Keycloak`)
- Client ID
- Client secret
- Click save
5. After saving, click on the three dots and choose Edit to open the new SSO entry.
6. Copy the generated Callback URL from Libredesk.
7. Back in Keycloak, edit the client and add the Callback URL to Valid Redirect URIs:
- e.g., `https://ticket.example.com/api/v1/oidc/1/finish`

60
docs/docs/templating.md Normal file
View File

@@ -0,0 +1,60 @@
# Templating
Templating in outgoing emails allows you to personalize content by embedding dynamic expressions like `{{ .Recipient.FullName }}`. These expressions reference fields from the conversation, contact, recipient, and author objects.
## Outgoing Email Template Expressions
If you want to customize the look of outgoing emails, you can do so in the Admin > Templates -> Outgoing Email Templates section. This template will be used for all outgoing emails including replies to conversations, notifications, and other system-generated emails.
### Conversation Variables
| Variable | Value |
|---------------------------------|--------------------------------------------------------|
| {{ .Conversation.ReferenceNumber }} | The unique reference number of the conversation |
| {{ .Conversation.Subject }} | The subject of the conversation |
| {{ .Conversation.Priority }} | The priority level of the conversation |
| {{ .Conversation.UUID }} | The unique identifier of the conversation |
### Contact Variables
| Variable | Value |
|------------------------------|------------------------------------|
| {{ .Contact.FirstName }} | First name of the contact/customer |
| {{ .Contact.LastName }} | Last name of the contact/customer |
| {{ .Contact.FullName }} | Full name of the contact/customer |
| {{ .Contact.Email }} | Email address of the contact/customer |
### Recipient Variables
| Variable | Value |
|--------------------------------|-----------------------------------|
| {{ .Recipient.FirstName }} | First name of the recipient |
| {{ .Recipient.LastName }} | Last name of the recipient |
| {{ .Recipient.FullName }} | Full name of the recipient |
| {{ .Recipient.Email }} | Email address of the recipient |
### Author Variables
| Variable | Value |
|------------------------------|-----------------------------------|
| {{ .Author.FirstName }} | First name of the message author |
| {{ .Author.LastName }} | Last name of the message author |
| {{ .Author.FullName }} | Full name of the message author |
| {{ .Author.Email }} | Email address of the message author |
### Example outgoing email template
```html
Dear {{ .Recipient.FirstName }},
{{ template "content" . }}
Best regards,
{{ .Author.FullName }}
---
Reference: {{ .Conversation.ReferenceNumber }}
```
Here, the `{{ template "content" . }}` serves as a placeholder for the body of the outgoing email. It will be replaced with the actual email content at the time of sending.
Similarly, the `{{ .Recipient.FirstName }}` expression will dynamically insert the recipient's first name when the email is sent.

View File

@@ -0,0 +1,3 @@
# Translations / Internationalization
You can help translate libreDesk into different languages by contributing here: [Libredesk Translation Project](https://crowdin.com/project/libredesk)

View File

@@ -1,6 +1,6 @@
# Upgrade
!!! Warning
!!! warning "Warning"
Always take a backup of the Postgres database before upgrading Libredesk.
## Binary
@@ -15,4 +15,4 @@
docker compose down app
docker compose pull
docker compose up app -d
```
```

222
docs/docs/webhooks.md Normal file
View File

@@ -0,0 +1,222 @@
# Webhooks
Webhooks allow you to receive real-time HTTP notifications when specific events occur in your Libredesk instance. This enables you to integrate Libredesk with external systems and automate workflows based on conversation and message events.
## Overview
When a configured event occurs in Libredesk, a HTTP POST request is sent to the webhook URL you specify. The request contains a JSON payload with event details and relevant data.
## Webhook Configuration
1. Navigate to **Admin > Integrations > Webhooks** in your Libredesk dashboard
2. Click **Create Webhook**
3. Configure the following:
- **Name**: A descriptive name for your webhook
- **URL**: The endpoint URL where webhook payloads will be sent
- **Events**: Select which events you want to subscribe to
- **Secret**: Optional secret key for signature verification
- **Status**: Enable or disable the webhook
## Security
### Signature Verification
If you provide a secret key, webhook payloads will be signed using HMAC-SHA256. The signature is included in the `X-Signature-256` header in the format `sha256=<signature>`.
To verify the signature:
```python
import hmac
import hashlib
def verify_signature(payload, signature, secret):
expected_signature = hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(f"sha256={expected_signature}", signature)
```
### Headers
Each webhook request includes the following headers:
- `Content-Type`: `application/json`
- `User-Agent`: `Libredesk-Webhook/<libredesk_version_here>`
- `X-Signature-256`: HMAC signature (if secret is configured)
## Available Events
### Conversation Events
#### `conversation.created`
Triggered when a new conversation is created.
**Sample Payload:**
```json
{
"event": "conversation.created",
"timestamp": "2025-06-15T10:30:00Z",
"payload": {
"id": 123,
"created_at": "2025-06-15T10:30:00Z",
"updated_at": "2025-06-15T10:30:00Z",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"contact_id": 456,
"inbox_id": 1,
"reference_number": "100",
"priority": "Medium",
"priority_id": 2,
"status": "Open",
"status_id": 1,
"subject": "Help with account setup",
"inbox_name": "Support",
"inbox_channel": "email",
"contact": {
"id": 456,
"first_name": "John",
"last_name": "Doe",
"email": "john.doe@example.com",
"type": "contact"
},
"custom_attributes": {},
"tags": []
}
}
```
#### `conversation.status_changed`
Triggered when a conversation's status is updated.
**Sample Payload:**
```json
{
"event": "conversation.status_changed",
"timestamp": "2025-06-15T10:35:00Z",
"payload": {
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
"previous_status": "Open",
"new_status": "Resolved",
"snooze_until": "",
"actor_id": 789
}
}
```
#### `conversation.assigned`
Triggered when a conversation is assigned to a user.
**Sample Payload:**
```json
{
"event": "conversation.assigned",
"timestamp": "2025-06-15T10:32:00Z",
"payload": {
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
"assigned_to": 789,
"actor_id": 789
}
}
```
#### `conversation.unassigned`
Triggered when a conversation is unassigned from a user.
**Sample Payload:**
```json
{
"event": "conversation.unassigned",
"timestamp": "2025-06-15T10:40:00Z",
"payload": {
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
"actor_id": 789
}
}
```
#### `conversation.tags_changed`
Triggered when tags are added or removed from a conversation.
**Sample Payload:**
```json
{
"event": "conversation.tags_changed",
"timestamp": "2025-06-15T10:45:00Z",
"payload": {
"conversation_uuid": "550e8400-e29b-41d4-a716-446655440000",
"previous_tags": ["bug", "priority"],
"new_tags": ["bug", "priority", "resolved"],
"actor_id": 789
}
}
```
### Message Events
#### `message.created`
Triggered when a new message is created in a conversation.
**Sample Payload:**
```json
{
"event": "message.created",
"timestamp": "2025-06-15T10:33:00Z",
"payload": {
"id": 987,
"created_at": "2025-06-15T10:33:00Z",
"updated_at": "2025-06-15T10:33:00Z",
"uuid": "123e4567-e89b-12d3-a456-426614174000",
"type": "outgoing",
"status": "sent",
"conversation_id": 123,
"content": "<p>Hello! How can I help you today?</p>",
"text_content": "Hello! How can I help you today?",
"content_type": "html",
"private": false,
"sender_id": 789,
"sender_type": "agent",
"attachments": []
}
}
```
#### `message.updated`
Triggered when an existing message is updated.
**Sample Payload:**
```json
{
"event": "message.updated",
"timestamp": "2025-06-15T10:34:00Z",
"payload": {
"id": 987,
"created_at": "2025-06-15T10:33:00Z",
"updated_at": "2025-06-15T10:34:00Z",
"uuid": "123e4567-e89b-12d3-a456-426614174000",
"type": "outgoing",
"status": "sent",
"conversation_id": 123,
"content": "<p>Hello! How can I help you today? (Updated)</p>",
"text_content": "Hello! How can I help you today? (Updated)",
"content_type": "html",
"private": false,
"sender_id": 789,
"sender_type": "agent",
"attachments": []
}
}
```
## Delivery and Retries
- Webhooks requests timeout can be configured in the `config.toml` file
- Failed deliveries are not automatically retried
- Webhook delivery runs in a background worker pool for better performance
- If the webhook queue is full (configurable in config.toml file), new events may be dropped
## Testing Webhooks
You can test your webhook configuration using tools like:
- [Webhook.site](https://webhook.site) - Generate a temporary URL to inspect webhook payloads

View File

@@ -1,13 +1,11 @@
site_name: Libredesk Documentation
site_name: Libredesk Docs
theme:
name: material
language: en
font:
text: Source Sans Pro
code: Roboto Mono
weights:
- 400
- 700
weights: [400, 700]
direction: ltr
palette:
primary: white
@@ -16,9 +14,9 @@ theme:
- navigation.indexes
- navigation.sections
- content.code.copy
extra:
search:
language: en
extra:
search:
language: en
markdown_extensions:
- admonition
@@ -30,5 +28,11 @@ nav:
- Introduction: index.md
- Getting Started:
- Installation: installation.md
- Upgrade: upgrade.md
- Developer Setup: developer-setup.md
- Upgrade Guide: upgrade.md
- Email Templates: templating.md
- SSO Setup: sso.md
- Webhooks: webhooks.md
- API Getting Started: api-getting-started.md
- Contributions:
- Developer Setup: developer-setup.md
- Translate Libredesk: translations.md

59
frontend/README-SETUP.md Normal file
View File

@@ -0,0 +1,59 @@
# Libredesk Frontend - Multi-App Setup
This frontend supports both the main Libredesk application and a chat widget as separate Vue applications sharing common UI components.
## Project Structure
```
frontend/
├── apps/
│ ├── main/ # Main Libredesk application
│ │ ├── src/
│ │ └── index.html
│ └── widget/ # Chat widget application
│ ├── src/
│ └── index.html
├── shared-ui/ # Shared UI components (shadcn/ui)
│ ├── components/
│ │ └── ui/ # shadcn/ui components
│ ├── lib/ # Utility functions
│ └── assets/ # Shared styles
└── package.json
```
## Development
Check Makefile for available commands.
## Shared UI Components
The `shared-ui` directory contains all the shadcn/ui components that can be used in both apps.
### Using Shared Components
```vue
<script setup>
import { Button } from '@shared-ui/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@shared-ui/components/ui/card'
import { Input } from '@shared-ui/components/ui/input'
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Example Card</CardTitle>
</CardHeader>
<CardContent>
<Input placeholder="Type something..." />
<Button>Submit</Button>
</CardContent>
</Card>
</template>
```
### Path Aliases
- `@shared-ui` - Points to the shared-ui directory
- `@main` - Points to apps/main/src
- `@widget` - Points to apps/widget/src
- `@` - Points to the current app's src directory (context-dependent)

View File

@@ -6,8 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap"
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap"
rel="stylesheet">
</head>

View File

@@ -0,0 +1,254 @@
<template>
<div class="flex w-full h-screen text-foreground">
<!-- Icon sidebar always visible -->
<SidebarProvider style="--sidebar-width: 3rem" class="w-auto z-50">
<ShadcnSidebar collapsible="none" class="border-r">
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<Tooltip>
<TooltipTrigger as-child>
<SidebarMenuButton asChild :isActive="route.path.startsWith('/inboxes')">
<router-link :to="{ name: 'inboxes' }">
<Inbox />
</router-link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{{ t('globals.terms.inbox', 2) }}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
<SidebarMenuItem v-if="userStore.can('contacts:read_all')">
<Tooltip>
<TooltipTrigger as-child>
<SidebarMenuButton asChild :isActive="route.path.startsWith('/contacts')">
<router-link :to="{ name: 'contacts' }">
<BookUser />
</router-link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{{ t('globals.terms.contact', 2) }}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
<SidebarMenuItem v-if="userStore.hasReportTabPermissions">
<Tooltip>
<TooltipTrigger as-child>
<SidebarMenuButton asChild :isActive="route.path.startsWith('/reports')">
<router-link :to="{ name: 'reports' }">
<FileLineChart />
</router-link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{{ t('globals.terms.report', 2) }}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
<SidebarMenuItem v-if="userStore.hasAdminTabPermissions">
<Tooltip>
<TooltipTrigger as-child>
<SidebarMenuButton asChild :isActive="route.path.startsWith('/admin')">
<router-link
:to="{
name: userStore.can('general_settings:manage') ? 'general' : 'admin'
}"
>
<Shield />
</router-link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{{ t('globals.terms.admin') }}</p>
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarNavUser />
</SidebarFooter>
</ShadcnSidebar>
</SidebarProvider>
<!-- Main sidebar that collapses -->
<div class="flex-1">
<Sidebar
:userTeams="userStore.teams"
:userViews="userViews"
@create-view="openCreateViewForm = true"
@edit-view="editView"
@delete-view="deleteView"
@create-conversation="() => (openCreateConversationDialog = true)"
>
<div class="flex flex-col h-screen">
<!-- Show app update only in admin routes -->
<AppUpdate v-if="route.path.startsWith('/admin')" />
<!-- Common header for all pages -->
<PageHeader />
<!-- Main content -->
<RouterView class="flex-grow" />
</div>
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
</Sidebar>
</div>
</div>
<!-- Command box -->
<Command />
<!-- Create conversation dialog -->
<CreateConversation v-model="openCreateConversationDialog" v-if="openCreateConversationDialog" />
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { RouterView } from 'vue-router'
import { useUserStore } from './stores/user'
import { initWS } from './websocket.js'
import { EMITTER_EVENTS } from './constants/emitterEvents.js'
import { useEmitter } from './composables/useEmitter'
import { handleHTTPError } from './utils/http'
import { useConversationStore } from './stores/conversation'
import { useInboxStore } from './stores/inbox'
import { useUsersStore } from './stores/users'
import { useTeamStore } from './stores/team'
import { useSlaStore } from './stores/sla'
import { useMacroStore } from './stores/macro'
import { useTagStore } from './stores/tag'
import { useCustomAttributeStore } from './stores/customAttributes'
import { useIdleDetection } from './composables/useIdleDetection'
import PageHeader from './components/layout/PageHeader.vue'
import ViewForm from '@/features/view/ViewForm.vue'
import AppUpdate from '@main/components/update/AppUpdate.vue'
import api from './api'
import { toast as sooner } from 'vue-sonner'
import Sidebar from '@main/components/sidebar/Sidebar.vue'
import Command from '@/features/command/CommandBox.vue'
import CreateConversation from '@/features/conversation/CreateConversation.vue'
import { Inbox, Shield, FileLineChart, BookUser } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import {
Sidebar as ShadcnSidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarMenu,
SidebarGroupContent,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider
} from '@shared-ui/components/ui/sidebar'
import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/ui/tooltip'
import SidebarNavUser from '@main/components/sidebar/SidebarNavUser.vue'
const route = useRoute()
const emitter = useEmitter()
const userStore = useUserStore()
const conversationStore = useConversationStore()
const usersStore = useUsersStore()
const teamStore = useTeamStore()
const inboxStore = useInboxStore()
const slaStore = useSlaStore()
const macroStore = useMacroStore()
const tagStore = useTagStore()
const customAttributeStore = useCustomAttributeStore()
const userViews = ref([])
const view = ref({})
const openCreateViewForm = ref(false)
const openCreateConversationDialog = ref(false)
const { t } = useI18n()
initWS()
useIdleDetection()
onMounted(() => {
initToaster()
listenViewRefresh()
initStores()
})
// Initialize data stores
const initStores = async () => {
if (!userStore.userID) {
await userStore.getCurrentUser()
}
await Promise.allSettled([
getUserViews(),
conversationStore.fetchStatuses(),
conversationStore.fetchPriorities(),
usersStore.fetchUsers(),
teamStore.fetchTeams(),
inboxStore.fetchInboxes(),
slaStore.fetchSlas(),
macroStore.loadMacros(),
tagStore.fetchTags(),
customAttributeStore.fetchCustomAttributes()
])
}
const editView = (v) => {
view.value = { ...v }
openCreateViewForm.value = true
}
const deleteView = async (view) => {
try {
await api.deleteView(view.id)
emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'view' })
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.deletedSuccessfully', {
name: t('globals.terms.view')
})
})
} catch (err) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(err).message
})
}
}
const getUserViews = async () => {
try {
const response = await api.getCurrentUserViews()
userViews.value = response.data.data
} catch (err) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(err).message
})
}
}
const initToaster = () => {
emitter.on(EMITTER_EVENTS.SHOW_TOAST, (message) => {
if (message.variant === 'destructive') {
sooner.error(message.description)
} else {
sooner.success(message.description)
}
})
}
const listenViewRefresh = () => {
emitter.on(EMITTER_EVENTS.REFRESH_LIST, refreshViews)
}
const refreshViews = (data) => {
openCreateViewForm.value = false
// TODO: move model to constants.
if (data?.model === 'view') {
getUserViews()
}
}
</script>

View File

@@ -5,8 +5,8 @@
<script setup>
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from './constants/emitterEvents.js'
import { useEmitter } from './composables/useEmitter'
import { toast as sooner } from 'vue-sonner'
const emitter = useEmitter()

View File

@@ -0,0 +1,12 @@
<template>
<TooltipProvider :delay-duration="150">
<Toaster class="pointer-events-auto" position="top-center" richColors />
<RouterView />
</TooltipProvider>
</template>
<script setup>
import { RouterView } from 'vue-router'
import { Toaster } from '@shared-ui/components/ui/sonner'
import { TooltipProvider } from '@shared-ui/components/ui/tooltip'
</script>

View File

@@ -7,15 +7,15 @@ const http = axios.create({
})
function getCSRFToken () {
const name = 'csrf_token=';
const cookies = document.cookie.split(';');
const name = 'csrf_token='
const cookies = document.cookie.split(';')
for (let i = 0; i < cookies.length; i++) {
let c = cookies[i].trim();
let c = cookies[i].trim()
if (c.indexOf(name) === 0) {
return c.substring(name.length, c.length);
return c.substring(name.length, c.length)
}
}
return '';
return ''
}
// Request interceptor.
@@ -27,19 +27,40 @@ http.interceptors.request.use((request) => {
// Set content type for POST/PUT requests if the content type is not set.
if ((request.method === 'post' || request.method === 'put') && !request.headers['Content-Type']) {
request.headers['Content-Type'] = 'application/x-www-form-urlencoded'
request.headers['Content-Type'] = 'application/json'
}
if (request.headers['Content-Type'] === 'application/x-www-form-urlencoded') {
request.data = qs.stringify(request.data)
}
return request
})
const getCustomAttributes = (appliesTo) =>
http.get('/api/v1/custom-attributes', {
params: { applies_to: appliesTo }
})
const createCustomAttribute = (data) =>
http.post('/api/v1/custom-attributes', data, {
headers: {
'Content-Type': 'application/json'
}
})
const getCustomAttribute = (id) => http.get(`/api/v1/custom-attributes/${id}`)
const updateCustomAttribute = (id, data) =>
http.put(`/api/v1/custom-attributes/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteCustomAttribute = (id) => http.delete(`/api/v1/custom-attributes/${id}`)
const searchConversations = (params) => http.get('/api/v1/conversations/search', { params })
const searchMessages = (params) => http.get('/api/v1/messages/search', { params })
const resetPassword = (data) => http.post('/api/v1/users/reset-password', data)
const setPassword = (data) => http.post('/api/v1/users/set-password', data)
const deleteUser = (id) => http.delete(`/api/v1/users/${id}`)
const searchContacts = (params) => http.get('/api/v1/contacts/search', { params })
const getEmailNotificationSettings = () => http.get('/api/v1/settings/notifications/email')
const updateEmailNotificationSettings = (data) => http.put('/api/v1/settings/notifications/email', data)
const updateEmailNotificationSettings = (data) =>
http.put('/api/v1/settings/notifications/email', data)
const getPriorities = () => http.get('/api/v1/priorities')
const getStatuses = () => http.get('/api/v1/statuses')
const createStatus = (data) => http.post('/api/v1/statuses', data)
@@ -66,11 +87,12 @@ const updateTemplate = (id, data) =>
const getAllBusinessHours = () => http.get('/api/v1/business-hours')
const getBusinessHours = (id) => http.get(`/api/v1/business-hours/${id}`)
const createBusinessHours = (data) => http.post('/api/v1/business-hours', data, {
headers: {
'Content-Type': 'application/json'
}
})
const createBusinessHours = (data) =>
http.post('/api/v1/business-hours', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateBusinessHours = (id, data) =>
http.put(`/api/v1/business-hours/${id}`, data, {
headers: {
@@ -81,8 +103,18 @@ const deleteBusinessHours = (id) => http.delete(`/api/v1/business-hours/${id}`)
const getAllSLAs = () => http.get('/api/v1/sla')
const getSLA = (id) => http.get(`/api/v1/sla/${id}`)
const createSLA = (data) => http.post('/api/v1/sla', data)
const updateSLA = (id, data) => http.put(`/api/v1/sla/${id}`, data)
const createSLA = (data) =>
http.post('/api/v1/sla', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateSLA = (id, data) =>
http.put(`/api/v1/sla/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteSLA = (id) => http.delete(`/api/v1/sla/${id}`)
const createOIDC = (data) =>
http.post('/api/v1/oidc', data, {
@@ -90,7 +122,6 @@ const createOIDC = (data) =>
'Content-Type': 'application/json'
}
})
const testOIDC = (data) => http.post('/api/v1/oidc/test', data)
const getAllEnabledOIDC = () => http.get('/api/v1/oidc/enabled')
const getAllOIDC = () => http.get('/api/v1/oidc')
const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
@@ -108,33 +139,42 @@ const updateSettings = (key, data) =>
}
})
const getSettings = (key) => http.get(`/api/v1/settings/${key}`)
const login = (data) => http.post(`/api/v1/login`, data)
const login = (data) => http.post(`/api/v1/auth/login`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const getAutomationRules = (type) =>
http.get(`/api/v1/automation/rules`, {
http.get(`/api/v1/automations/rules`, {
params: { type: type }
})
const toggleAutomationRule = (id) => http.put(`/api/v1/automation/rules/${id}/toggle`)
const getAutomationRule = (id) => http.get(`/api/v1/automation/rules/${id}`)
const toggleAutomationRule = (id) => http.put(`/api/v1/automations/rules/${id}/toggle`)
const getAutomationRule = (id) => http.get(`/api/v1/automations/rules/${id}`)
const updateAutomationRule = (id, data) =>
http.put(`/api/v1/automation/rules/${id}`, data, {
http.put(`/api/v1/automations/rules/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const createAutomationRule = (data) =>
http.post(`/api/v1/automation/rules`, data, {
http.post(`/api/v1/automations/rules`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteAutomationRule = (id) => http.delete(`/api/v1/automation/rules/${id}`)
const deleteAutomationRule = (id) => http.delete(`/api/v1/automations/rules/${id}`)
const updateAutomationRuleWeights = (data) =>
http.put(`/api/v1/automation/rules/weights`, data, {
http.put(`/api/v1/automations/rules/weights`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateAutomationRulesExecutionMode = (data) =>
http.put(`/api/v1/automations/rules/execution-mode`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateAutomationRulesExecutionMode = (data) => http.put(`/api/v1/automation/rules/execution-mode`, data)
const getRoles = () => http.get('/api/v1/roles')
const getRole = (id) => http.get(`/api/v1/roles/${id}`)
const createRole = (data) =>
@@ -150,36 +190,124 @@ const updateRole = (id, data) =>
}
})
const deleteRole = (id) => http.delete(`/api/v1/roles/${id}`)
const getUser = (id) => http.get(`/api/v1/users/${id}`)
const getTeam = (id) => http.get(`/api/v1/teams/${id}`)
const getTeams = () => http.get('/api/v1/teams')
const updateTeam = (id, data) => http.put(`/api/v1/teams/${id}`, data)
const createTeam = (data) => http.post('/api/v1/teams', data)
const getTeamsCompact = () => http.get('/api/v1/teams/compact')
const deleteTeam = (id) => http.delete(`/api/v1/teams/${id}`)
const getUsers = () => http.get('/api/v1/users')
const getUsersCompact = () => http.get('/api/v1/users/compact')
const updateCurrentUser = (data) =>
http.put('/api/v1/users/me', data, {
const getContacts = (params) => http.get('/api/v1/contacts', { params })
const getContact = (id) => http.get(`/api/v1/contacts/${id}`)
const updateContact = (id, data) =>
http.put(`/api/v1/contacts/${id}`, data, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
const deleteUserAvatar = () => http.delete('/api/v1/users/me/avatar')
const getCurrentUser = () => http.get('/api/v1/users/me')
const getCurrentUserTeams = () => http.get('/api/v1/users/me/teams')
const updateCurrentUserAvailability = (data) => http.put('/api/v1/users/me/availability', data)
const blockContact = (id, data) => http.put(`/api/v1/contacts/${id}/block`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const getTeam = (id) => http.get(`/api/v1/teams/${id}`)
const getTeams = () => http.get('/api/v1/teams')
const updateTeam = (id, data) => http.put(`/api/v1/teams/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const createTeam = (data) => http.post('/api/v1/teams', data, {
headers: {
'Content-Type': 'application/json'
}
})
const getTeamsCompact = () => http.get('/api/v1/teams/compact')
const deleteTeam = (id) => http.delete(`/api/v1/teams/${id}`)
const updateUser = (id, data) =>
http.put(`/api/v1/agents/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const getUsers = () => http.get('/api/v1/agents')
const getUsersCompact = () => http.get('/api/v1/agents/compact')
const updateCurrentUser = (data) =>
http.put('/api/v1/agents/me', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
const getUser = (id) => http.get(`/api/v1/agents/${id}`)
const deleteUserAvatar = () => http.delete('/api/v1/agents/me/avatar')
const getCurrentUser = () => http.get('/api/v1/agents/me')
const getCurrentUserTeams = () => http.get('/api/v1/agents/me/teams')
const updateCurrentUserAvailability = (data) => http.put('/api/v1/agents/me/availability', data, {
headers: {
'Content-Type': 'application/json'
}
})
const resetPassword = (data) => http.post('/api/v1/agents/reset-password', data, {
headers: {
'Content-Type': 'application/json'
}
})
const setPassword = (data) => http.post('/api/v1/agents/set-password', data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteUser = (id) => http.delete(`/api/v1/agents/${id}`)
const createUser = (data) =>
http.post('/api/v1/agents', data, {
headers: {
'Content-Type': 'application/json'
}
})
const getTags = () => http.get('/api/v1/tags')
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data)
const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data)
const removeAssignee = (uuid, assignee_type) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}/remove`)
const updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data)
const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data)
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateAssignee = (uuid, assignee_type, data) =>
http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const removeAssignee = (uuid, assignee_type) =>
http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}/remove`)
const updateContactCustomAttribute = (uuid, data) =>
http.put(`/api/v1/conversations/${uuid}/contacts/custom-attributes`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateConversationCustomAttribute = (uuid, data) =>
http.put(`/api/v1/conversations/${uuid}/custom-attributes`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const createConversation = (data) =>
http.post('/api/v1/conversations', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateConversationStatus = (uuid, data) =>
http.put(`/api/v1/conversations/${uuid}/status`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateConversationPriority = (uuid, data) =>
http.put(`/api/v1/conversations/${uuid}/priority`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)
const getConversationMessage = (cuuid, uuid) => http.get(`/api/v1/conversations/${cuuid}/messages/${uuid}`)
const retryMessage = (cuuid, uuid) => http.put(`/api/v1/conversations/${cuuid}/messages/${uuid}/retry`)
const getConversationMessages = (uuid, params) => http.get(`/api/v1/conversations/${uuid}/messages`, { params })
const getConversationMessage = (cuuid, uuid) =>
http.get(`/api/v1/conversations/${cuuid}/messages/${uuid}`)
const retryMessage = (cuuid, uuid) =>
http.put(`/api/v1/conversations/${cuuid}/messages/${uuid}/retry`)
const getConversationMessages = (uuid, params) =>
http.get(`/api/v1/conversations/${uuid}/messages`, { params })
const sendMessage = (uuid, data) =>
http.post(`/api/v1/conversations/${uuid}/messages`, data, {
headers: {
@@ -190,28 +318,33 @@ const getConversation = (uuid) => http.get(`/api/v1/conversations/${uuid}`)
const getConversationParticipants = (uuid) => http.get(`/api/v1/conversations/${uuid}/participants`)
const getAllMacros = () => http.get('/api/v1/macros')
const getMacro = (id) => http.get(`/api/v1/macros/${id}`)
const createMacro = (data) => http.post('/api/v1/macros', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateMacro = (id, data) => http.put(`/api/v1/macros/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const createMacro = (data) =>
http.post('/api/v1/macros', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateMacro = (id, data) =>
http.put(`/api/v1/macros/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteMacro = (id) => http.delete(`/api/v1/macros/${id}`)
const applyMacro = (uuid, id, data) => http.post(`/api/v1/conversations/${uuid}/macros/${id}/apply`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const applyMacro = (uuid, id, data) =>
http.post(`/api/v1/conversations/${uuid}/macros/${id}/apply`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const getTeamUnassignedConversations = (teamID, params) =>
http.get(`/api/v1/teams/${teamID}/conversations/unassigned`, { params })
const getAssignedConversations = (params) => http.get('/api/v1/conversations/assigned', { params })
const getUnassignedConversations = (params) => http.get('/api/v1/conversations/unassigned', { params })
const getUnassignedConversations = (params) =>
http.get('/api/v1/conversations/unassigned', { params })
const getAllConversations = (params) => http.get('/api/v1/conversations/all', { params })
const getViewConversations = (id, params) => http.get(`/api/v1/views/${id}/conversations`, { params })
const getViewConversations = (id, params) =>
http.get(`/api/v1/views/${id}/conversations`, { params })
const uploadMedia = (data) =>
http.post('/api/v1/media', data, {
headers: {
@@ -219,20 +352,9 @@ const uploadMedia = (data) =>
}
})
const getOverviewCounts = () => http.get('/api/v1/reports/overview/counts')
const getOverviewCharts = () => http.get('/api/v1/reports/overview/charts')
const getOverviewCharts = (params) => http.get('/api/v1/reports/overview/charts', { params })
const getOverviewSLA = (params) => http.get('/api/v1/reports/overview/sla', { params })
const getLanguage = (lang) => http.get(`/api/v1/lang/${lang}`)
const createUser = (data) =>
http.post('/api/v1/users', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateUser = (id, data) =>
http.put(`/api/v1/users/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const createInbox = (data) =>
http.post('/api/v1/inboxes', data, {
headers: {
@@ -264,7 +386,50 @@ const updateView = (id, data) =>
})
const deleteView = (id) => http.delete(`/api/v1/views/me/${id}`)
const getAiPrompts = () => http.get('/api/v1/ai/prompts')
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data)
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateAIProvider = (data) => http.put('/api/v1/ai/provider', data, {
headers: {
'Content-Type': 'application/json'
}
})
const getContactNotes = (id) => http.get(`/api/v1/contacts/${id}/notes`)
const createContactNote = (id, data) => http.post(`/api/v1/contacts/${id}/notes`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteContactNote = (id, noteId) => http.delete(`/api/v1/contacts/${id}/notes/${noteId}`)
const getActivityLogs = (params) => http.get('/api/v1/activity-logs', { params })
const getWebhooks = () => http.get('/api/v1/webhooks')
const getWebhook = (id) => http.get(`/api/v1/webhooks/${id}`)
const createWebhook = (data) =>
http.post('/api/v1/webhooks', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateWebhook = (id, data) =>
http.put(`/api/v1/webhooks/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteWebhook = (id) => http.delete(`/api/v1/webhooks/${id}`)
const toggleWebhook = (id) => http.put(`/api/v1/webhooks/${id}/toggle`)
const testWebhook = (id) => http.post(`/api/v1/webhooks/${id}/test`)
const generateAPIKey = (id) =>
http.post(`/api/v1/agents/${id}/api-key`, {}, {
headers: {
'Content-Type': 'application/json'
}
})
const revokeAPIKey = (id) => http.delete(`/api/v1/agents/${id}/api-key`)
export default {
login,
@@ -305,6 +470,7 @@ export default {
getViewConversations,
getOverviewCharts,
getOverviewCounts,
getOverviewSLA,
getConversationParticipants,
getConversationMessage,
getConversationMessages,
@@ -321,6 +487,8 @@ export default {
updateConversationStatus,
updateConversationPriority,
upsertTags,
updateConversationCustomAttribute,
updateContactCustomAttribute,
uploadMedia,
updateAssigneeLastSeen,
updateUser,
@@ -328,9 +496,11 @@ export default {
updateAutomationRule,
updateAutomationRuleWeights,
updateAutomationRulesExecutionMode,
updateAIProvider,
createAutomationRule,
toggleAutomationRule,
deleteAutomationRule,
createConversation,
sendMessage,
retryMessage,
createUser,
@@ -347,7 +517,6 @@ export default {
getAllEnabledOIDC,
getOIDC,
updateOIDC,
testOIDC,
deleteOIDC,
getTemplate,
getTemplates,
@@ -375,5 +544,28 @@ export default {
aiCompletion,
searchConversations,
searchMessages,
searchContacts,
removeAssignee,
getContacts,
getContact,
updateContact,
blockContact,
getCustomAttributes,
createCustomAttribute,
updateCustomAttribute,
deleteCustomAttribute,
getCustomAttribute,
getContactNotes,
createContactNote,
deleteContactNote,
getActivityLogs,
getWebhooks,
getWebhook,
createWebhook,
updateWebhook,
deleteWebhook,
toggleWebhook,
testWebhook,
generateAPIKey,
revokeAPIKey
}

View File

@@ -0,0 +1,24 @@
<template>
<Button
variant="ghost"
@click.prevent="onClose"
size="xs"
class="text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 w-6 h-6 p-0"
>
<slot>
<X size="16" />
</slot>
</Button>
</template>
<script setup>
import { Button } from '@shared-ui/components/ui/button'
import { X } from 'lucide-vue-next'
defineProps({
onClose: {
type: Function,
required: true
}
})
</script>

View File

@@ -0,0 +1,61 @@
<template>
<ComboBox
:model-value="normalizedValue"
@update:model-value="$emit('update:modelValue', $event)"
:items="items"
:placeholder="placeholder"
>
<!-- Items -->
<template #item="{ item }">
<div class="flex items-center gap-2">
<!--USER -->
<Avatar v-if="type === 'user'" class="w-7 h-7">
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
<AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
</Avatar>
<!-- Others -->
<span v-else-if="item.emoji">{{ item.emoji }}</span>
<span>{{ item.label }}</span>
</div>
</template>
<!-- Selected -->
<template #selected="{ selected }">
<div class="flex items-center gap-2">
<div v-if="selected" class="flex items-center gap-2">
<!--USER -->
<Avatar v-if="type === 'user'" class="w-7 h-7">
<AvatarImage :src="selected.avatar_url || ''" :alt="selected.label.slice(0, 2)" />
<AvatarFallback>{{ selected.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
</Avatar>
<!-- Others -->
<span v-else-if="selected.emoji">{{ selected.emoji }}</span>
<span>{{ selected.label }}</span>
</div>
<span v-else>{{ placeholder }}</span>
</div>
</template>
</ComboBox>
</template>
<script setup>
import { computed } from 'vue'
import { Avatar, AvatarImage, AvatarFallback } from '@shared-ui/components/ui/avatar'
import ComboBox from '@shared-ui/components/ui/combobox/ComboBox.vue'
const props = defineProps({
modelValue: [String, Number, Object],
placeholder: String,
items: Array,
type: {
type: String
}
})
// Convert to str.
const normalizedValue = computed(() => String(props.modelValue || ''))
defineEmits(['update:modelValue'])
</script>

View File

@@ -1,19 +1,26 @@
<template>
<div class="w-full">
<div class="rounded-md border shadow">
<div class="rounded border shadow">
<Table>
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead v-for="header in headerGroup.headers" :key="header.id" class="font-semibold">
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header"
:props="header.getContext()" />
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<template v-if="table.getRowModel().rows?.length">
<TableRow v-for="row in table.getRowModel().rows" :key="row.id"
:data-state="row.getIsSelected() ? 'selected' : undefined" class="hover:bg-muted/50">
<TableRow
v-for="row in table.getRowModel().rows"
:key="row.id"
:data-state="row.getIsSelected() ? 'selected' : undefined"
class="hover:bg-muted/50"
>
<TableCell v-for="cell in row.getVisibleCells()" :key="cell.id">
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</TableCell>
@@ -32,9 +39,10 @@
</div>
</template>
<script setup>
import { FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table'
import { useI18n } from 'vue-i18n'
import { computed } from 'vue'
import {
Table,
@@ -43,22 +51,32 @@ import {
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table'
} from '@shared-ui/components/ui/table'
const { t } = useI18n()
const props = defineProps({
columns: Array,
data: Array,
emptyText: {
type: String,
default: 'No results.'
default: ''
}
})
// Set the default value for emptyText if it's empty
const emptyText = computed(
() =>
props.emptyText ||
t('globals.messages.noResults', {
name: t('globals.terms.result', 2).toLowerCase()
})
)
const table = useVueTable({
get data () {
get data() {
return props.data
},
get columns () {
get columns() {
return props.columns
},
getCoreRowModel: getCoreRowModel()

View File

@@ -0,0 +1,65 @@
<template>
<div ref="codeEditor" @click="editorView?.focus()" class="w-full h-[28rem] border rounded-md" />
</template>
<script setup>
import { ref, onMounted, watch, nextTick, useTemplateRef } from 'vue'
import { EditorView, basicSetup } from 'codemirror'
import { html } from '@codemirror/lang-html'
import { oneDark } from '@codemirror/theme-one-dark'
import { useColorMode } from '@vueuse/core'
const props = defineProps({
modelValue: { type: String, default: '' },
language: { type: String, default: 'html' },
disabled: Boolean
})
const emit = defineEmits(['update:modelValue'])
const data = ref('')
let editorView = null
const codeEditor = useTemplateRef('codeEditor')
const initCodeEditor = (body) => {
const isDark = useColorMode().value === 'dark'
editorView = new EditorView({
doc: body,
extensions: [
basicSetup,
html(),
...(isDark ? [oneDark] : []),
EditorView.editable.of(!props.disabled),
EditorView.theme({
'&': { height: '100%' },
'.cm-editor': { height: '100%' },
'.cm-scroller': { overflow: 'auto' }
}),
EditorView.updateListener.of((update) => {
if (!update.docChanged) return
const v = update.state.doc.toString()
emit('update:modelValue', v)
data.value = v
})
],
parent: codeEditor.value
})
nextTick(() => {
editorView?.focus()
})
}
onMounted(() => {
initCodeEditor(props.modelValue || '')
})
watch(() => props.modelValue, (newVal) => {
if (newVal !== data.value) {
editorView?.dispatch({
changes: { from: 0, to: editorView.state.doc.length, insert: newVal }
})
}
})
</script>

View File

@@ -0,0 +1,324 @@
<template>
<div class="editor-wrapper h-full overflow-y-auto">
<BubbleMenu
:editor="editor"
:tippy-options="{ duration: 100 }"
v-if="editor"
class="bg-background p-1 box will-change-transform"
>
<div class="flex space-x-1 items-center">
<DropdownMenu v-if="aiPrompts.length > 0">
<DropdownMenuTrigger>
<Button size="sm" variant="ghost" class="flex items-center justify-center">
<span class="flex items-center">
<span class="text-medium">AI</span>
<Bot size="14" class="ml-1" />
<ChevronDown class="w-4 h-4 ml-2" />
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
v-for="prompt in aiPrompts"
:key="prompt.key"
@select="emitPrompt(prompt.key)"
>
{{ prompt.title }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
size="sm"
variant="ghost"
@click.prevent="editor?.chain().focus().toggleBold().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bold') }"
>
<Bold size="14" />
</Button>
<Button
size="sm"
variant="ghost"
@click.prevent="editor?.chain().focus().toggleItalic().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('italic') }"
>
<Italic size="14" />
</Button>
<Button
size="sm"
variant="ghost"
@click.prevent="editor?.chain().focus().toggleBulletList().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bulletList') }"
>
<List size="14" />
</Button>
<Button
size="sm"
variant="ghost"
@click.prevent="editor?.chain().focus().toggleOrderedList().run()"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('orderedList') }"
>
<ListOrdered size="14" />
</Button>
<Button
size="sm"
variant="ghost"
@click.prevent="openLinkModal"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('link') }"
>
<LinkIcon size="14" />
</Button>
<div v-if="showLinkInput" class="flex space-x-2 p-2 bg-background border rounded">
<Input
v-model="linkUrl"
type="text"
placeholder="Enter link URL"
class="border p-1 text-sm w-[200px]"
/>
<Button size="sm" @click="setLink">
<Check size="14" />
</Button>
<Button size="sm" @click="unsetLink">
<X size="14" />
</Button>
</div>
</div>
</BubbleMenu>
<EditorContent :editor="editor" class="native-html" />
</div>
</template>
<script setup>
import { ref, watch, onUnmounted } from 'vue'
import { useEditor, EditorContent, BubbleMenu } from '@tiptap/vue-3'
import {
ChevronDown,
Bold,
Italic,
Bot,
List,
ListOrdered,
Link as LinkIcon,
Check,
X
} from 'lucide-vue-next'
import { Button } from '@shared-ui/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@shared-ui/components/ui/dropdown-menu'
import { Input } from '@shared-ui/components/ui/input'
import Placeholder from '@tiptap/extension-placeholder'
import Image from '@tiptap/extension-image'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Table from '@tiptap/extension-table'
import TableRow from '@tiptap/extension-table-row'
import TableCell from '@tiptap/extension-table-cell'
import TableHeader from '@tiptap/extension-table-header'
import { useTypingIndicator } from '@shared-ui/composables'
import { useConversationStore } from '@main/stores/conversation'
const textContent = defineModel('textContent', { default: '' })
const htmlContent = defineModel('htmlContent', { default: '' })
const showLinkInput = ref(false)
const linkUrl = ref('')
const props = defineProps({
placeholder: String,
insertContent: String,
autoFocus: {
type: Boolean,
default: true
},
aiPrompts: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['send', 'aiPromptSelected'])
const emitPrompt = (key) => emit('aiPromptSelected', key)
// Set up typing indicator
const conversationStore = useConversationStore()
const { startTyping, stopTyping } = useTypingIndicator(conversationStore.sendTyping)
// To preseve the table styling in emails, need to set the table style inline.
// Created these custom extensions to set the table style inline.
const CustomTable = Table.extend({
addAttributes() {
return {
...this.parent?.(),
style: {
parseHTML: (element) =>
(element.getAttribute('style') || '') + '; border: 1px solid #dee2e6 !important; width: 100%; margin:0; table-layout: fixed; border-collapse: collapse; position:relative; border-radius: 0.25rem;'
}
}
}
})
const CustomTableCell = TableCell.extend({
addAttributes() {
return {
...this.parent?.(),
style: {
parseHTML: (element) =>
(element.getAttribute('style') || '') +
'; border: 1px solid #dee2e6 !important; box-sizing: border-box !important; min-width: 1em !important; padding: 6px 8px !important; vertical-align: top !important;'
}
}
}
})
const CustomTableHeader = TableHeader.extend({
addAttributes() {
return {
...this.parent?.(),
style: {
parseHTML: (element) =>
(element.getAttribute('style') || '') +
'; background-color: #f8f9fa !important; color: #212529 !important; font-weight: bold !important; text-align: left !important; border: 1px solid #dee2e6 !important; padding: 6px 8px !important;'
}
}
}
})
const isInternalUpdate = ref(false)
const editor = useEditor({
extensions: [
StarterKit.configure(),
Image.configure({ HTMLAttributes: { class: 'inline-image' } }),
Placeholder.configure({ placeholder: () => props.placeholder }),
Link,
CustomTable.configure({ resizable: false }),
TableRow,
CustomTableCell,
CustomTableHeader
],
autofocus: props.autoFocus,
content: htmlContent.value,
editorProps: {
attributes: { class: 'outline-none' },
handleKeyDown: (view, event) => {
if (event.ctrlKey && event.key === 'Enter') {
emit('send')
// Stop typing when sending
stopTyping()
return true
}
}
},
// To update state when user types.
onUpdate: ({ editor }) => {
isInternalUpdate.value = true
htmlContent.value = editor.getHTML()
textContent.value = editor.getText()
isInternalUpdate.value = false
// Trigger typing indicator when user types
startTyping()
},
onBlur: () => {
// Stop typing when editor loses focus
stopTyping()
}
})
watch(
htmlContent,
(newContent) => {
if (!isInternalUpdate.value && editor.value && newContent !== editor.value.getHTML()) {
editor.value.commands.setContent(newContent || '', false)
textContent.value = editor.value.getText()
editor.value.commands.focus()
}
},
{ immediate: true }
)
// Insert content at cursor position when insertContent prop changes.
watch(
() => props.insertContent,
(val) => {
if (val) editor.value?.commands.insertContent(val)
}
)
onUnmounted(() => {
editor.value?.destroy()
})
const openLinkModal = () => {
if (editor.value?.isActive('link')) {
linkUrl.value = editor.value.getAttributes('link').href
} else {
linkUrl.value = ''
}
showLinkInput.value = true
}
const setLink = () => {
if (linkUrl.value) {
editor.value?.chain().focus().extendMarkRange('link').setLink({ href: linkUrl.value }).run()
}
showLinkInput.value = false
}
const unsetLink = () => {
editor.value?.chain().focus().unsetLink().run()
showLinkInput.value = false
}
</script>
<style lang="scss">
// Moving placeholder to the top.
.tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: #adb5bd;
pointer-events: none;
height: 0;
}
// Ensure the parent div has a proper height
.editor-wrapper div[aria-expanded='false'] {
display: flex;
flex-direction: column;
height: 100%;
}
// Ensure the editor content has a proper height and breaks words
.tiptap.ProseMirror {
flex: 1;
min-height: 70px;
overflow-y: auto;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
word-break: break-word;
white-space: pre-wrap;
max-width: 100%;
}
.tiptap {
// Table styling
.tableWrapper {
margin: 1.5rem 0;
overflow-x: auto;
}
// Anchor tag styling
a {
color: #0066cc;
cursor: pointer;
&:hover {
color: #003d7a;
}
}
}
</style>

View File

@@ -0,0 +1,212 @@
<template>
<div class="space-y-4">
<div class="w-[27rem]" v-if="modelValue.length === 0"></div>
<div
v-for="(modelFilter, index) in modelValue"
:key="index"
class="group flex items-center gap-3"
>
<div class="flex gap-2 w-full">
<!-- Field -->
<div class="flex-1">
<Select v-model="modelFilter.field">
<SelectTrigger>
<SelectValue
:placeholder="
t('globals.messages.select', { name: t('globals.terms.field').toLowerCase() })
"
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="field in fields" :key="field.field" :value="field.field">
{{ field.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<!-- Operator -->
<div class="flex-1">
<Select v-model="modelFilter.operator" v-if="modelFilter.field">
<SelectTrigger>
<SelectValue
:placeholder="
t('globals.messages.select', { name: t('globals.terms.operator').toLowerCase() })
"
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="op in getFieldOperators(modelFilter)" :key="op" :value="op">
{{ op }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<!-- Value -->
<div class="flex-1">
<div v-if="modelFilter.field && modelFilter.operator">
<template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
<SelectComboBox
v-if="
getFieldOptions(modelFilter).length > 0 &&
modelFilter.field === 'assigned_user_id'
"
v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)"
:placeholder="t('globals.messages.select', { name: '' })"
type="user"
/>
<SelectComboBox
v-else-if="
getFieldOptions(modelFilter).length > 0 &&
modelFilter.field === 'assigned_team_id'
"
v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)"
:placeholder="t('globals.messages.select', { name: '' })"
type="team"
/>
<SelectComboBox
v-else-if="getFieldOptions(modelFilter).length > 0"
v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)"
:placeholder="t('globals.messages.select', { name: '' })"
/>
<Input
v-else
v-model="modelFilter.value"
:placeholder="t('globals.terms.value')"
type="text"
/>
</template>
</div>
</div>
</div>
<CloseButton :onClose="() => removeFilter(index)" />
</div>
<div class="flex items-center justify-between pt-3">
<Button variant="ghost" size="sm" @click="addFilter" class="text-slate-600">
<Plus class="w-3 h-3 mr-1" />
{{
$t('globals.messages.add', {
name: $t('globals.terms.filter')
})
}}
</Button>
<div class="flex gap-2" v-if="showButtons">
<Button variant="ghost" @click="clearFilters">{{ $t('globals.messages.reset') }}</Button>
<Button @click="applyFilters">{{ $t('globals.messages.apply') }}</Button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, watch } from 'vue'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@shared-ui/components/ui/select'
import { Plus } from 'lucide-vue-next'
import { Button } from '@shared-ui/components/ui/button'
import { Input } from '@shared-ui/components/ui/input'
import { useI18n } from 'vue-i18n'
import CloseButton from '@main/components/button/CloseButton.vue'
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
const props = defineProps({
fields: {
type: Array,
required: true
},
showButtons: {
type: Boolean,
default: true
}
})
const { t } = useI18n()
const emit = defineEmits(['apply', 'clear'])
const modelValue = defineModel('modelValue', { required: false, default: () => [] })
const createFilter = () => ({ field: '', operator: '', value: '' })
onMounted(() => {
if (modelValue.value.length === 0) {
modelValue.value = [createFilter()]
}
})
const getModel = (field) => {
const fieldConfig = props.fields.find((f) => f.field === field)
return fieldConfig?.model || ''
}
// Set model for each filter
watch(
() => modelValue.value,
(filters) => {
filters.forEach((filter) => {
if (filter.field && !filter.model) {
filter.model = getModel(filter.field)
}
})
},
{ deep: true }
)
// Reset operator and value when field changes for a filter at a given index
watch(
() => modelValue.value.map((f) => f.field),
(newFields, oldFields) => {
newFields.forEach((field, index) => {
if (field !== oldFields[index]) {
modelValue.value[index].operator = ''
modelValue.value[index].value = ''
}
})
}
)
const addFilter = () => {
modelValue.value = [...modelValue.value, createFilter()]
}
const removeFilter = (index) => {
modelValue.value = modelValue.value.filter((_, i) => i !== index)
}
const applyFilters = () => {
modelValue.value = validFilters.value
emit('apply', modelValue.value)
}
const clearFilters = () => {
modelValue.value = []
emit('clear')
}
const validFilters = computed(() => {
return modelValue.value.filter((filter) => filter.field && filter.operator && filter.value)
})
const getFieldOptions = (fieldValue) => {
const field = props.fields.find((f) => f.field === fieldValue.field)
return field?.options || []
}
const getFieldOperators = (modelFilter) => {
const field = props.fields.find((f) => f.field === modelFilter.field)
return field?.operators || []
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex flex-col p-4 border rounded-lg shadow-sm hover:shadow transition-colors cursor-pointer max-w-xs"
class="flex flex-col p-4 border rounded shadow-sm hover:shadow transition-colors cursor-pointer max-w-xs"
@click="handleClick">
<div class="flex items-center mb-2">
<component :is="icon" size="24" class="mr-2 text-primary" />
@@ -11,7 +11,7 @@
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
import { defineEmits } from 'vue'
const props = defineProps({
title: String,

View File

@@ -1,8 +1,8 @@
<template>
<div v-if="!isHidden">
<div class="flex items-center space-x-4 h-12 px-2">
<SidebarTrigger class="cursor-pointer w-4 h-4" />
<span class="text-xl font-semibold text-gray-800">
<SidebarTrigger class="cursor-pointer" />
<span class="text-xl font-semibold">
{{ title }}
</span>
</div>
@@ -12,8 +12,8 @@
<script setup>
import { computed } from 'vue'
import { Separator } from '@/components/ui/separator'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { Separator } from '@shared-ui/components/ui/separator'
import { SidebarTrigger } from '@shared-ui/components/ui/sidebar'
import { useRoute } from 'vue-router'
const route = useRoute()

View File

@@ -0,0 +1,516 @@
<script setup>
import {
adminNavItems,
reportsNavItems,
accountNavItems,
contactNavItems
} from '../../constants/navigation'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@shared-ui/components/ui/collapsible'
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail
} from '@shared-ui/components/ui/sidebar'
import { useAppSettingsStore } from '../../stores/appSettings'
import {
ChevronRight,
EllipsisVertical,
User,
Search,
Plus,
CircleDashed,
List
} from 'lucide-vue-next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@shared-ui/components/ui/dropdown-menu'
import { filterNavItems } from '../../utils/nav-permissions'
import { useStorage } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '../../stores/user'
import { useConversationStore } from '../../stores/conversation'
defineProps({
userTeams: { type: Array, default: () => [] },
userViews: { type: Array, default: () => [] }
})
const userStore = useUserStore()
const conversationStore = useConversationStore()
const settingsStore = useAppSettingsStore()
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const emit = defineEmits(['createView', 'editView', 'deleteView', 'createConversation'])
const isActiveParent = (parentHref) => {
return route.path.startsWith(parentHref)
}
const isInboxRoute = (path) => {
return path.startsWith('/inboxes')
}
const openCreateViewDialog = () => {
emit('createView')
}
const editView = (view) => {
emit('editView', view)
}
const deleteView = (view) => {
emit('deleteView', view)
}
// Navigation methods with conversation retention
const navigateToInbox = (type) => {
if (conversationStore.hasConversationOpen && conversationStore.conversation.data?.uuid) {
router.push({
name: 'inbox-conversation',
params: {
type,
uuid: conversationStore.conversation.data.uuid
}
})
} else {
router.push({
name: 'inbox',
params: { type }
})
}
}
const navigateToTeamInbox = (teamID) => {
if (conversationStore.hasConversationOpen && conversationStore.conversation.data?.uuid) {
router.push({
name: 'team-inbox-conversation',
params: {
teamID,
uuid: conversationStore.conversation.data.uuid
}
})
} else {
router.push({
name: 'team-inbox',
params: { teamID }
})
}
}
const navigateToViewInbox = (viewID) => {
if (conversationStore.hasConversationOpen && conversationStore.conversation.data?.uuid) {
router.push({
name: 'view-inbox-conversation',
params: {
viewID,
uuid: conversationStore.conversation.data.uuid
}
})
} else {
router.push({
name: 'view-inbox',
params: { viewID }
})
}
}
const filteredAdminNavItems = computed(() => filterNavItems(adminNavItems, userStore.can))
const filteredReportsNavItems = computed(() => filterNavItems(reportsNavItems, userStore.can))
const filteredContactsNavItems = computed(() => filterNavItems(contactNavItems, userStore.can))
// For auto opening admin collapsibles when a child route is active
const openAdminCollapsible = ref(null)
const toggleAdminCollapsible = (titleKey) => {
openAdminCollapsible.value = openAdminCollapsible.value === titleKey ? null : titleKey
}
// Watch for route changes and update the active collapsible
watch(
[() => route.path, filteredAdminNavItems],
() => {
const activeItem = filteredAdminNavItems.value.find((item) => {
if (!item.children) return isActiveParent(item.href)
return item.children.some((child) => isActiveParent(child.href))
})
if (activeItem) {
openAdminCollapsible.value = activeItem.titleKey
}
},
{ immediate: true }
)
// Sidebar open state in local storage
const sidebarOpen = useStorage('mainSidebarOpen', true)
const teamInboxOpen = useStorage('teamInboxOpen', true)
const viewInboxOpen = useStorage('viewInboxOpen', true)
</script>
<template>
<SidebarProvider
style="--sidebar-width: 14rem"
:default-open="sidebarOpen"
v-on:update:open="sidebarOpen = $event"
>
<!-- Contacts sidebar -->
<template
v-if="route.matched.some((record) => record.name && record.name.startsWith('contact'))"
>
<Sidebar collapsible="offcanvas" class="border-r ml-12">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<div class="px-1">
<span class="font-semibold text-xl">
{{ t('globals.terms.contact', 2) }}
</span>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem v-for="item in filteredContactsNavItems" :key="item.titleKey">
<SidebarMenuButton :isActive="isActiveParent(item.href)" asChild>
<router-link :to="item.href">
<span>{{
t('globals.messages.all', {
name: t(item.titleKey, 2).toLowerCase()
})
}}</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarRail />
</Sidebar>
</template>
<!-- Reports sidebar -->
<template
v-if="
userStore.hasReportTabPermissions &&
route.matched.some((record) => record.name && record.name.startsWith('reports'))
"
>
<Sidebar collapsible="offcanvas" class="border-r ml-12">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<div class="px-1">
<span class="font-semibold text-xl">
{{ t('globals.terms.report', 2) }}
</span>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem v-for="item in filteredReportsNavItems" :key="item.titleKey">
<SidebarMenuButton :isActive="isActiveParent(item.href)" asChild>
<router-link :to="item.href">
<span>{{ t(item.titleKey) }}</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarRail />
</Sidebar>
</template>
<!-- Admin Sidebar -->
<template v-if="route.matched.some((record) => record.name && record.name.startsWith('admin'))">
<Sidebar collapsible="offcanvas" class="border-r ml-12">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<div class="flex flex-col items-start justify-between w-full px-1">
<span class="font-semibold text-xl">
{{ t('globals.terms.admin') }}
</span>
<!-- App version -->
<div class="text-xs text-muted-foreground">
({{ settingsStore.settings['app.version'] }})
</div>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem v-for="item in filteredAdminNavItems" :key="item.titleKey">
<SidebarMenuButton
v-if="!item.children"
:isActive="isActiveParent(item.href)"
asChild
>
<router-link :to="item.href">
<span>{{ t(item.titleKey) }}</span>
</router-link>
</SidebarMenuButton>
<Collapsible
v-else
class="group/collapsible"
:open="openAdminCollapsible === item.titleKey"
@update:open="toggleAdminCollapsible(item.titleKey)"
>
<CollapsibleTrigger as-child>
<SidebarMenuButton :isActive="isActiveParent(item.href)">
<span>{{ t(item.titleKey, item.isTitleKeyPlural === true ? 2 : 1) }}</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem v-for="child in item.children" :key="child.titleKey">
<SidebarMenuButton size="sm" :isActive="isActiveParent(child.href)" asChild>
<router-link :to="child.href">
<span>{{ t(child.titleKey) }}</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarRail />
</Sidebar>
</template>
<!-- Account sidebar -->
<template v-if="isActiveParent('/account')">
<Sidebar collapsible="offcanvas" class="border-r ml-12">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<div class="px-1">
<span class="font-semibold text-xl">
{{ t('globals.terms.account') }}
</span>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem v-for="item in accountNavItems" :key="item.titleKey">
<SidebarMenuButton :isActive="isActiveParent(item.href)" asChild>
<router-link :to="item.href">
<span>{{ t(item.titleKey) }}</span>
</router-link>
</SidebarMenuButton>
<SidebarMenuAction>
<span class="sr-only">{{ item.description }}</span>
</SidebarMenuAction>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarRail />
</Sidebar>
</template>
<!-- Inbox sidebar -->
<template v-if="route.path && isInboxRoute(route.path)">
<Sidebar collapsible="offcanvas" class="border-r ml-12">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<div class="flex items-center justify-between w-full px-1">
<div class="font-semibold text-xl">
<span>{{ t('globals.terms.inbox') }}</span>
</div>
<div class="mr-1 mt-1 hover:scale-110 transition-transform">
<router-link :to="{ name: 'search' }">
<Search size="18" stroke-width="2.5" />
</router-link>
</div>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<a href="#" @click="emit('createConversation')">
<Plus />
<span
>{{
t('globals.messages.new', {
name: t('globals.terms.conversation').toLowerCase()
})
}}
</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/assigned')">
<a href="#" @click.prevent="navigateToInbox('assigned')">
<User />
<span>{{ t('globals.terms.myInbox') }}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/unassigned')">
<a href="#" @click.prevent="navigateToInbox('unassigned')">
<CircleDashed />
<span>
{{ t('globals.terms.unassigned') }}
</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/all')">
<a href="#" @click.prevent="navigateToInbox('all')">
<List />
<span>
{{ t('globals.messages.all') }}
</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
<!-- Team Inboxes -->
<Collapsible
defaultOpen
class="group/collapsible"
v-if="userTeams.length"
v-model:open="teamInboxOpen"
>
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<SidebarMenuButton asChild>
<router-link to="#">
<!-- <Users /> -->
<span>
{{ t('globals.terms.teamInbox', 2) }}
</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
</router-link>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub v-for="team in userTeams" :key="team.id">
<SidebarMenuSubItem>
<SidebarMenuButton
size="sm"
:is-active="route.params.teamID == team.id"
asChild
>
<a href="#" @click.prevent="navigateToTeamInbox(team.id)">
{{ team.emoji }}<span>{{ team.name }}</span>
</a>
</SidebarMenuButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
<!-- Views -->
<Collapsible class="group/collapsible" defaultOpen v-model:open="viewInboxOpen">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton asChild>
<router-link to="#" class="group/item !p-2">
<!-- <SlidersHorizontal /> -->
<span>
{{ t('globals.terms.view', 2) }}
</span>
<div>
<Plus
size="18"
@click.stop="openCreateViewDialog"
class="rounded cursor-pointer opacity-0 transition-all duration-200 group-hover/item:opacity-100 hover:bg-gray-200 hover:shadow-sm text-gray-600 hover:text-gray-800 transform hover:scale-105 active:scale-100 p-1"
/>
</div>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
v-if="userViews.length"
/>
</router-link>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub v-for="view in userViews" :key="view.id">
<SidebarMenuSubItem>
<SidebarMenuButton
size="sm"
:isActive="route.params.viewID == view.id"
asChild
>
<a href="#" @click.prevent="navigateToViewInbox(view.id)">
<span class="break-words w-32 truncate">{{ view.name }}</span>
<SidebarMenuAction :showOnHover="true" class="mr-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<EllipsisVertical />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="() => editView(view)">
<span>{{ t('globals.messages.edit') }}</span>
</DropdownMenuItem>
<DropdownMenuItem @click="() => deleteView(view)">
<span>{{ t('globals.messages.delete') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuAction>
</a>
</SidebarMenuButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</template>
<!-- Main Content Area -->
<SidebarInset>
<slot></slot>
</SidebarInset>
</SidebarProvider>
</template>

View File

@@ -0,0 +1,139 @@
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="md"
class="p-0"
>
<Avatar class="h-8 w-8 rounded relative overflow-visible">
<AvatarImage :src="userStore.avatar" alt="U" class="rounded" />
<AvatarFallback class="rounded">
{{ userStore.getInitials }}
</AvatarFallback>
<div
class="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border border-background"
:class="{
'bg-green-500': userStore.user.availability_status === 'online',
'bg-amber-500':
userStore.user.availability_status === 'away' ||
userStore.user.availability_status === 'away_manual' ||
userStore.user.availability_status === 'away_and_reassigning',
'bg-gray-400': userStore.user.availability_status === 'offline'
}"
></div>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<span class="truncate text-xs">{{ userStore.email }}</span>
</div>
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-[--radix-dropdown-menu-trigger-width] min-w-56"
side="bottom"
:side-offset="4"
>
<DropdownMenuLabel class="font-normal space-y-2 px-2">
<!-- User header -->
<div class="flex items-center gap-2 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded">
<AvatarImage :src="userStore.avatar" alt="U" />
<AvatarFallback class="rounded">
{{ userStore.getInitials }}
</AvatarFallback>
</Avatar>
<div class="flex-1 flex flex-col leading-tight">
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<span class="truncate text-xs text-muted-foreground">{{ userStore.email }}</span>
</div>
</div>
<div class="space-y-2">
<!-- Dark-mode toggle -->
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<Moon v-if="mode === 'dark'" size="16" class="text-muted-foreground" />
<Sun v-else size="16" class="text-muted-foreground" />
<span class="text-muted-foreground">{{ t('navigation.darkMode') }}</span>
</div>
<Switch
:checked="mode === 'dark'"
@update:checked="(val) => (mode = val ? 'dark' : 'light')"
/>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 space-y-3">
<!-- Away toggle -->
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('navigation.away') }}</span>
<Switch
:checked="
['away_manual', 'away_and_reassigning'].includes(
userStore.user.availability_status
)
"
@update:checked="
(val) => userStore.updateUserAvailability(val ? 'away_manual' : 'online')
"
/>
</div>
<!-- Reassign toggle -->
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('navigation.reassignReplies') }}</span>
<Switch
:checked="userStore.user.availability_status === 'away_and_reassigning'"
@update:checked="
(val) =>
userStore.updateUserAvailability(val ? 'away_and_reassigning' : 'away_manual')
"
/>
</div>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem @click.prevent="router.push({ name: 'account' })">
<CircleUserRound size="18" class="mr-2" />
{{ t('globals.terms.account') }}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem @click="logout">
<LogOut size="18" class="mr-2" />
{{ t('navigation.logout') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@shared-ui/components/ui/dropdown-menu'
import { SidebarMenuButton } from '@shared-ui/components/ui/sidebar'
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar'
import { Switch } from '@shared-ui/components/ui/switch'
import { ChevronsUpDown, CircleUserRound, LogOut, Moon, Sun } from 'lucide-vue-next'
import { useUserStore } from '../../stores/user'
import { useRouter } from 'vue-router'
import { useColorMode } from '@vueuse/core'
const mode = useColorMode()
const userStore = useUserStore()
const router = useRouter()
const { t } = useI18n()
const logout = () => {
window.location.href = '/logout'
}
</script>

View File

@@ -0,0 +1,112 @@
<template>
<table class="min-w-full table-fixed divide-y divide-border">
<thead class="bg-muted">
<tr>
<th
v-for="(header, index) in headers"
:key="index"
scope="col"
class="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
{{ header }}
</th>
<th v-if="showDelete" scope="col" class="relative px-6 py-3"></th>
</tr>
</thead>
<tbody class="bg-background divide-y divide-border">
<!-- Loading State -->
<template v-if="loading">
<tr v-for="i in skeletonRows" :key="`skeleton-${i}`" class="hover:bg-accent">
<td
v-for="(header, index) in headers"
:key="`skeleton-cell-${index}`"
class="px-6 py-3 text-sm font-medium text-foreground whitespace-normal break-words"
>
<Skeleton class="h-4 w-[85%]" />
</td>
<td v-if="showDelete" class="px-6 py-4 text-sm text-muted-foreground">
<Skeleton class="h-8 w-8 rounded" />
</td>
</tr>
</template>
<!-- No Results State -->
<template v-else-if="data.length === 0">
<tr>
<td :colspan="headers.length + (showDelete ? 1 : 0)" class="px-6 py-12 text-center">
<div class="flex flex-col items-center space-y-4">
<span class="text-md text-muted-foreground">
{{
$t('globals.messages.noResults', {
name: $t('globals.terms.result', 2).toLowerCase()
})
}}
</span>
</div>
</td>
</tr>
</template>
<!-- Data Rows -->
<template v-else>
<tr v-for="(item, index) in data" :key="index" class="hover:bg-accent">
<td
v-for="key in keys"
:key="key"
class="px-6 py-4 text-sm font-medium text-foreground whitespace-normal break-words"
>
{{ item[key] }}
</td>
<td v-if="showDelete" class="px-6 py-4 text-sm text-muted-foreground">
<Button size="xs" variant="ghost" @click.prevent="deleteItem(item)">
<Trash2 class="h-4 w-4" />
</Button>
</td>
</tr>
</template>
</tbody>
</table>
</template>
<script setup>
import { Trash2 } from 'lucide-vue-next'
import { defineEmits } from 'vue'
import { Button } from '@shared-ui/components/ui/button'
import { Skeleton } from '@shared-ui/components/ui/skeleton'
defineProps({
headers: {
type: Array,
required: true,
default: () => []
},
keys: {
type: Array,
required: true,
default: () => []
},
data: {
type: Array,
required: true,
default: () => []
},
showDelete: {
type: Boolean,
default: true
},
loading: {
type: Boolean,
default: false
},
skeletonRows: {
type: Number,
default: 5
}
})
const emit = defineEmits(['deleteItem'])
function deleteItem(item) {
emit('deleteItem', item)
}
</script>

View File

@@ -3,7 +3,7 @@
v-if="appSettingsStore.settings['app.update']?.update?.is_new"
class="p-2 mb-2 border-b bg-secondary text-secondary-foreground"
>
A new update is available:
{{ $t('update.newUpdateAvailable') }}:
{{ appSettingsStore.settings['app.update'].update.release_version }} ({{
appSettingsStore.settings['app.update'].update.release_date
}})
@@ -14,12 +14,12 @@
noreferrer
class="underline ml-2"
>
View details
{{ $t('globals.messages.viewDetails') }}
</a>
</div>
</template>
<script setup>
import { useAppSettingsStore } from '@/stores/appSettings'
import { useAppSettingsStore } from '../../stores/appSettings'
const appSettingsStore = useAppSettingsStore()
</script>

View File

@@ -0,0 +1,43 @@
import { computed } from 'vue'
import { useUsersStore } from '../stores/users'
import { FIELD_TYPE, FIELD_OPERATORS } from '../constants/filterConfig'
import { useI18n } from 'vue-i18n'
export function useActivityLogFilters () {
const uStore = useUsersStore()
const { t } = useI18n()
const activityLogListFilters = computed(() => ({
actor_id: {
label: t('globals.terms.actor'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: uStore.options
},
activity_type: {
label: t('globals.messages.type', {
name: t('globals.terms.activityLog')
}),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: [{
label: 'Agent login',
value: 'agent_login'
}, {
label: 'Agent logout',
value: 'agent_logout'
}, {
label: 'Agent away',
value: 'agent_away'
}, {
label: 'Agent away reassigned',
value: 'agent_away_reassigned'
}, {
label: 'Agent online',
value: 'agent_online'
}]
},
}))
return {
activityLogListFilters
}
}

View File

@@ -0,0 +1,332 @@
import { computed } from 'vue'
import { useConversationStore } from '../stores/conversation'
import { useInboxStore } from '../stores/inbox'
import { useUsersStore } from '../stores/users'
import { useTeamStore } from '../stores/team'
import { useSlaStore } from '../stores/sla'
import { useCustomAttributeStore } from '../stores/customAttributes'
import { FIELD_TYPE, FIELD_OPERATORS } from '../constants/filterConfig'
import { useI18n } from 'vue-i18n'
export function useConversationFilters () {
const cStore = useConversationStore()
const iStore = useInboxStore()
const uStore = useUsersStore()
const tStore = useTeamStore()
const slaStore = useSlaStore()
const customAttributeStore = useCustomAttributeStore()
const { t } = useI18n()
const customAttributeDataTypeToFieldType = {
'text': FIELD_TYPE.TEXT,
'number': FIELD_TYPE.NUMBER,
'checkbox': FIELD_TYPE.BOOLEAN,
'date': FIELD_TYPE.DATE,
'link': FIELD_TYPE.TEXT,
'list': FIELD_TYPE.SELECT,
}
const customAttributeDataTypeToFieldOperators = {
'text': FIELD_OPERATORS.TEXT,
'number': FIELD_OPERATORS.NUMBER,
'checkbox': FIELD_OPERATORS.BOOLEAN,
'date': FIELD_OPERATORS.DATE,
'link': FIELD_OPERATORS.TEXT,
'list': FIELD_OPERATORS.SELECT,
}
const conversationsListFilters = computed(() => ({
status_id: {
label: t('globals.terms.status'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: cStore.statusOptions
},
priority_id: {
label: t('globals.terms.priority'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: cStore.priorityOptions
},
assigned_team_id: {
label: t('globals.messages.assign', {
name: t('globals.terms.team').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: tStore.options
},
assigned_user_id: {
label: t('globals.messages.assign', {
name: t('globals.terms.agent').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: uStore.options
},
inbox_id: {
label: t('globals.terms.inbox'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: iStore.options
}
}))
const contactCustomAttributes = computed(() => {
return customAttributeStore.contactAttributeOptions
.filter(attribute => attribute.applies_to === 'contact')
.reduce((acc, attribute) => {
acc[attribute.key] = {
label: attribute.label,
type: customAttributeDataTypeToFieldType[attribute.data_type] || FIELD_TYPE.TEXT,
operators: customAttributeDataTypeToFieldOperators[attribute.data_type] || FIELD_OPERATORS.TEXT,
options: attribute.values.map(value => ({
label: value,
value: value
})) || [],
}
return acc
}, {})
})
const newConversationFilters = computed(() => ({
contact_email: {
label: t('globals.terms.email'),
type: FIELD_TYPE.TEXT,
operators: FIELD_OPERATORS.TEXT
},
content: {
label: t('globals.terms.content'),
type: FIELD_TYPE.TEXT,
operators: FIELD_OPERATORS.TEXT
},
subject: {
label: t('globals.terms.subject'),
type: FIELD_TYPE.TEXT,
operators: FIELD_OPERATORS.TEXT
},
status: {
label: t('globals.terms.status'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: cStore.statusOptions
},
priority: {
label: t('globals.terms.priority'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: cStore.priorityOptions
},
assigned_team: {
label: t('globals.messages.assign', {
name: t('globals.terms.team').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: tStore.options
},
assigned_user: {
label: t('globals.messages.assign', {
name: t('globals.terms.agent').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: uStore.options
},
inbox: {
label: t('globals.terms.inbox'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: iStore.options
}
}))
const conversationFilters = computed(() => ({
status: {
label: t('globals.terms.status'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: cStore.statusOptions
},
priority: {
label: t('globals.terms.priority'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: cStore.priorityOptions
},
assigned_team: {
label: t('globals.messages.assign', {
name: t('globals.terms.team').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: tStore.options
},
assigned_user: {
label: t('globals.messages.assign', {
name: t('globals.terms.agent').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: uStore.options
},
hours_since_created: {
label: t('globals.messages.hoursSinceCreated'),
type: FIELD_TYPE.NUMBER,
operators: FIELD_OPERATORS.NUMBER
},
hours_since_first_reply: {
label: t('globals.messages.hoursSinceFirstReply'),
type: FIELD_TYPE.NUMBER,
operators: FIELD_OPERATORS.NUMBER
},
hours_since_last_reply: {
label: t('globals.messages.hoursSinceLastReply'),
type: FIELD_TYPE.NUMBER,
operators: FIELD_OPERATORS.NUMBER
},
hours_since_resolved: {
label: t('globals.messages.hoursSinceResolved'),
type: FIELD_TYPE.NUMBER,
operators: FIELD_OPERATORS.NUMBER
},
inbox: {
label: t('globals.terms.inbox'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: iStore.options
}
}))
const conversationActions = computed(() => ({
assign_team: {
label: t('globals.messages.assign', {
name: t('globals.terms.team').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
options: tStore.options
},
assign_user: {
label: t('globals.messages.assign', {
name: t('globals.terms.agent').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
options: uStore.options
},
set_status: {
label: t('globals.messages.set', {
name: t('globals.terms.status').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
options: cStore.statusOptionsNoSnooze
},
set_priority: {
label: t('globals.messages.set', {
name: t('globals.terms.priority').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
options: cStore.priorityOptions
},
send_private_note: {
label: t('globals.messages.send', {
name: t('globals.terms.privateNote').toLowerCase()
}),
type: FIELD_TYPE.RICHTEXT
},
send_reply: {
label: t('globals.messages.send', {
name: t('globals.terms.reply').toLowerCase()
}),
type: FIELD_TYPE.RICHTEXT
},
send_csat: {
label: t('globals.messages.send', {
name: t('globals.terms.csat').toLowerCase()
}),
},
set_sla: {
label: t('globals.messages.set', {
name: t('globals.terms.sla').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
options: slaStore.options
},
add_tags: {
label: t('globals.messages.add', {
name: t('globals.terms.tag', 2).toLowerCase()
}),
type: FIELD_TYPE.TAG
},
set_tags: {
label: t('globals.messages.set', {
name: t('globals.terms.tag', 2).toLowerCase()
}),
type: FIELD_TYPE.TAG
},
remove_tags: {
label: t('globals.messages.remove', {
name: t('globals.terms.tag', 2).toLowerCase()
}),
type: FIELD_TYPE.TAG
}
}))
const macroActions = computed(() => ({
assign_team: {
label: t('globals.messages.assign', {
name: t('globals.terms.team').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
options: tStore.options
},
assign_user: {
label: t('globals.messages.assign', {
name: t('globals.terms.agent').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
options: uStore.options
},
set_status: {
label: t('globals.messages.set', {
name: t('globals.terms.status').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
options: cStore.statusOptionsNoSnooze
},
set_priority: {
label: t('globals.messages.set', {
name: t('globals.terms.priority').toLowerCase()
}),
type: FIELD_TYPE.SELECT,
options: cStore.priorityOptions
},
add_tags: {
label: t('globals.messages.add', {
name: t('globals.terms.tag', 2).toLowerCase()
}),
type: FIELD_TYPE.TAG
},
set_tags: {
label: t('globals.messages.set', {
name: t('globals.terms.tag', 2).toLowerCase()
}),
type: FIELD_TYPE.TAG
},
remove_tags: {
label: t('globals.messages.remove', {
name: t('globals.terms.tag', 2).toLowerCase()
}),
type: FIELD_TYPE.TAG
}
}))
return {
conversationsListFilters,
conversationFilters,
newConversationFilters,
conversationActions,
macroActions,
contactCustomAttributes,
}
}

View File

@@ -0,0 +1,142 @@
import { ref, readonly } from 'vue'
import { useEmitter } from './useEmitter'
import { EMITTER_EVENTS } from '../constants/emitterEvents.js'
import { handleHTTPError } from '../utils/http'
import api from '../api'
/**
* Composable for handling file uploads
* @param {Object} options - Configuration options
* @param {Function} options.onFileUploadSuccess - Callback when file upload succeeds (uploadedFile)
* @param {Function} options.onUploadError - Optional callback when file upload fails (file, error)
* @param {string} options.linkedModel - The linked model for the upload
* @param {Array} options.mediaFiles - Optional external array to manage files (if not provided, internal array is used)
*/
export function useFileUpload (options = {}) {
const {
onFileUploadSuccess,
onUploadError,
linkedModel,
mediaFiles: externalMediaFiles
} = options
const emitter = useEmitter()
const uploadingFiles = ref([])
const isUploading = ref(false)
const internalMediaFiles = ref([])
// Use external mediaFiles if provided, otherwise use internal
const mediaFiles = externalMediaFiles || internalMediaFiles
/**
* Handles the file upload process when files are selected.
* Uploads each file to the server and adds them to the mediaFiles array.
* @param {Event} event - The file input change event containing selected files
*/
const handleFileUpload = (event) => {
const files = Array.from(event.target.files)
uploadingFiles.value = files
isUploading.value = true
for (const file of files) {
api
.uploadMedia({
files: file,
inline: false,
linked_model: linkedModel
})
.then((resp) => {
const uploadedFile = resp.data.data
// Add to media files array
if (Array.isArray(mediaFiles.value)) {
mediaFiles.value.push(uploadedFile)
} else {
mediaFiles.push(uploadedFile)
}
// Remove from uploading list
uploadingFiles.value = uploadingFiles.value.filter((f) => f.name !== file.name)
// Call success callback
if (onFileUploadSuccess) {
onFileUploadSuccess(uploadedFile)
}
// Update uploading state
if (uploadingFiles.value.length === 0) {
isUploading.value = false
}
})
.catch((error) => {
uploadingFiles.value = uploadingFiles.value.filter((f) => f.name !== file.name)
// Call error callback or show default toast
if (onUploadError) {
onUploadError(file, error)
} else {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
// Update uploading state
if (uploadingFiles.value.length === 0) {
isUploading.value = false
}
})
}
}
/**
* Handles the file delete event.
* Removes the file from the mediaFiles array.
* @param {String} uuid - The UUID of the file to delete
*/
const handleFileDelete = (uuid) => {
if (Array.isArray(mediaFiles.value)) {
mediaFiles.value = [
...mediaFiles.value.filter((item) => item.uuid !== uuid)
]
} else {
const index = mediaFiles.findIndex((item) => item.uuid === uuid)
if (index > -1) {
mediaFiles.splice(index, 1)
}
}
}
/**
* Upload files programmatically (without event)
* @param {File[]} files - Array of files to upload
*/
const uploadFiles = (files) => {
const mockEvent = { target: { files } }
handleFileUpload(mockEvent)
}
/**
* Clear all media files
*/
const clearMediaFiles = () => {
if (Array.isArray(mediaFiles.value)) {
mediaFiles.value = []
} else {
mediaFiles.length = 0
}
}
return {
// State
uploadingFiles: readonly(uploadingFiles),
isUploading: readonly(isUploading),
mediaFiles: externalMediaFiles ? readonly(mediaFiles) : readonly(internalMediaFiles),
// Methods
handleFileUpload,
handleFileDelete,
uploadFiles,
clearMediaFiles
}
}

View File

@@ -0,0 +1,56 @@
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useUserStore } from '../stores/user'
import { debounce } from '../utils/debounce'
import { useStorage } from '@vueuse/core'
export function useIdleDetection () {
const userStore = useUserStore()
const AWAY_THRESHOLD = 4 * 60 * 1000
const CHECK_INTERVAL = 30 * 1000
const lastActivity = useStorage('last_active', Date.now())
const timer = ref(null)
// Debounce the goOnline to prevent it from being called too frequently
const goOnline = debounce(() => {
if (userStore.user.availability_status === 'away' || userStore.user.availability_status === 'offline') {
userStore.updateUserAvailability('online', false)
}
}, 200)
function resetTimer () {
lastActivity.value = Date.now()
}
function checkIdle () {
if (
Date.now() - lastActivity.value > AWAY_THRESHOLD &&
userStore.user.availability_status === 'online'
) {
userStore.updateUserAvailability('away', false)
}
}
onMounted(() => {
['mousemove', 'keypress', 'click'].forEach(evt =>
window.addEventListener(evt, resetTimer)
)
timer.value = setInterval(checkIdle, CHECK_INTERVAL)
})
onBeforeUnmount(() => {
['mousemove', 'keypress', 'click'].forEach(evt =>
window.removeEventListener(evt, resetTimer)
)
clearInterval(timer.value)
})
watch(lastActivity, (newVal, oldVal) => {
if (
newVal > oldVal &&
document.visibilityState === 'visible'
) {
goOnline()
}
})
}

View File

@@ -1,5 +1,5 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { calculateSla } from '@/utils/sla'
import { calculateSla } from '../utils/sla'
export function useSla (dueAt, actualAt) {
const sla = ref(null)

View File

@@ -0,0 +1,242 @@
const countries = [
{ calling_code: '+93', name: 'Afghanistan', emoji: '🇦🇫', iso_2: 'AF' },
{ calling_code: '+355', name: 'Albania', emoji: '🇦🇱', iso_2: 'AL' },
{ calling_code: '+213', name: 'Algeria', emoji: '🇩🇿', iso_2: 'DZ' },
{ calling_code: '+1-684', name: 'American Samoa', emoji: '🇦🇸', iso_2: 'AS' },
{ calling_code: '+376', name: 'Andorra', emoji: '🇦🇩', iso_2: 'AD' },
{ calling_code: '+244', name: 'Angola', emoji: '🇦🇴', iso_2: 'AO' },
{ calling_code: '+1-264', name: 'Anguilla', emoji: '🇦🇮', iso_2: 'AI' },
{ calling_code: '+1-268', name: 'Antigua and Barbuda', emoji: '🇦🇬', iso_2: 'AG' },
{ calling_code: '+54', name: 'Argentina', emoji: '🇦🇷', iso_2: 'AR' },
{ calling_code: '+374', name: 'Armenia', emoji: '🇦🇲', iso_2: 'AM' },
{ calling_code: '+297', name: 'Aruba', emoji: '🇦🇼', iso_2: 'AW' },
{ calling_code: '+61', name: 'Australia', emoji: '🇦🇺', iso_2: 'AU' },
{ calling_code: '+43', name: 'Austria', emoji: '🇦🇹', iso_2: 'AT' },
{ calling_code: '+994', name: 'Azerbaijan', emoji: '🇦🇿', iso_2: 'AZ' },
{ calling_code: '+1-242', name: 'Bahamas', emoji: '🇧🇸', iso_2: 'BS' },
{ calling_code: '+973', name: 'Bahrain', emoji: '🇧🇭', iso_2: 'BH' },
{ calling_code: '+880', name: 'Bangladesh', emoji: '🇧🇩', iso_2: 'BD' },
{ calling_code: '+1-246', name: 'Barbados', emoji: '🇧🇧', iso_2: 'BB' },
{ calling_code: '+375', name: 'Belarus', emoji: '🇧🇾', iso_2: 'BY' },
{ calling_code: '+32', name: 'Belgium', emoji: '🇧🇪', iso_2: 'BE' },
{ calling_code: '+501', name: 'Belize', emoji: '🇧🇿', iso_2: 'BZ' },
{ calling_code: '+229', name: 'Benin', emoji: '🇧🇯', iso_2: 'BJ' },
{ calling_code: '+1-441', name: 'Bermuda', emoji: '🇧🇲', iso_2: 'BM' },
{ calling_code: '+975', name: 'Bhutan', emoji: '🇧🇹', iso_2: 'BT' },
{ calling_code: '+591', name: 'Bolivia', emoji: '🇧🇴', iso_2: 'BO' },
{ calling_code: '+387', name: 'Bosnia and Herzegovina', emoji: '🇧🇦', iso_2: 'BA' },
{ calling_code: '+267', name: 'Botswana', emoji: '🇧🇼', iso_2: 'BW' },
{ calling_code: '+55', name: 'Brazil', emoji: '🇧🇷', iso_2: 'BR' },
{ calling_code: '+246', name: 'British Indian Ocean Territory', emoji: '🇮🇴', iso_2: 'IO' },
{ calling_code: '+673', name: 'Brunei', emoji: '🇧🇳', iso_2: 'BN' },
{ calling_code: '+359', name: 'Bulgaria', emoji: '🇧🇬', iso_2: 'BG' },
{ calling_code: '+226', name: 'Burkina Faso', emoji: '🇧🇫', iso_2: 'BF' },
{ calling_code: '+257', name: 'Burundi', emoji: '🇧🇮', iso_2: 'BI' },
{ calling_code: '+855', name: 'Cambodia', emoji: '🇰🇭', iso_2: 'KH' },
{ calling_code: '+237', name: 'Cameroon', emoji: '🇨🇲', iso_2: 'CM' },
{ calling_code: '+1', name: 'Canada', emoji: '🇨🇦', iso_2: 'CA' },
{ calling_code: '+238', name: 'Cape Verde', emoji: '🇨🇻', iso_2: 'CV' },
{ calling_code: '+1-345', name: 'Cayman Islands', emoji: '🇰🇾', iso_2: 'KY' },
{ calling_code: '+236', name: 'Central African Republic', emoji: '🇨🇫', iso_2: 'CF' },
{ calling_code: '+235', name: 'Chad', emoji: '🇹🇩', iso_2: 'TD' },
{ calling_code: '+56', name: 'Chile', emoji: '🇨🇱', iso_2: 'CL' },
{ calling_code: '+86', name: 'China', emoji: '🇨🇳', iso_2: 'CN' },
{ calling_code: '+61', name: 'Christmas Island', emoji: '🇨🇽', iso_2: 'CX' },
{ calling_code: '+61', name: 'Cocos (Keeling) Islands', emoji: '🇨🇨', iso_2: 'CC' },
{ calling_code: '+57', name: 'Colombia', emoji: '🇨🇴', iso_2: 'CO' },
{ calling_code: '+269', name: 'Comoros', emoji: '🇰🇲', iso_2: 'KM' },
{ calling_code: '+242', name: 'Congo', emoji: '🇨🇬', iso_2: 'CG' },
{ calling_code: '+243', name: 'Congo, Democratic Republic of the', emoji: '🇨🇩', iso_2: 'CD' },
{ calling_code: '+682', name: 'Cook Islands', emoji: '🇨🇰', iso_2: 'CK' },
{ calling_code: '+506', name: 'Costa Rica', emoji: '🇨🇷', iso_2: 'CR' },
{ calling_code: '+225', name: "Côte d'Ivoire", emoji: '🇨🇮', iso_2: 'CI' },
{ calling_code: '+385', name: 'Croatia', emoji: '🇭🇷', iso_2: 'HR' },
{ calling_code: '+53', name: 'Cuba', emoji: '🇨🇺', iso_2: 'CU' },
{ calling_code: '+599', name: 'Curaçao', emoji: '🇨🇼', iso_2: 'CW' },
{ calling_code: '+357', name: 'Cyprus', emoji: '🇨🇾', iso_2: 'CY' },
{ calling_code: '+420', name: 'Czech Republic', emoji: '🇨🇿', iso_2: 'CZ' },
{ calling_code: '+45', name: 'Denmark', emoji: '🇩🇰', iso_2: 'DK' },
{ calling_code: '+253', name: 'Djibouti', emoji: '🇩🇯', iso_2: 'DJ' },
{ calling_code: '+1-767', name: 'Dominica', emoji: '🇩🇲', iso_2: 'DM' },
{ calling_code: '+1-809', name: 'Dominican Republic', emoji: '🇩🇴', iso_2: 'DO' },
{ calling_code: '+593', name: 'Ecuador', emoji: '🇪🇨', iso_2: 'EC' },
{ calling_code: '+20', name: 'Egypt', emoji: '🇪🇬', iso_2: 'EG' },
{ calling_code: '+503', name: 'El Salvador', emoji: '🇸🇻', iso_2: 'SV' },
{ calling_code: '+240', name: 'Equatorial Guinea', emoji: '🇬🇶', iso_2: 'GQ' },
{ calling_code: '+291', name: 'Eritrea', emoji: '🇪🇷', iso_2: 'ER' },
{ calling_code: '+372', name: 'Estonia', emoji: '🇪🇪', iso_2: 'EE' },
{ calling_code: '+268', name: 'Eswatini', emoji: '🇸🇿', iso_2: 'SZ' },
{ calling_code: '+251', name: 'Ethiopia', emoji: '🇪🇹', iso_2: 'ET' },
{ calling_code: '+500', name: 'Falkland Islands', emoji: '🇫🇰', iso_2: 'FK' },
{ calling_code: '+298', name: 'Faroe Islands', emoji: '🇫🇴', iso_2: 'FO' },
{ calling_code: '+679', name: 'Fiji', emoji: '🇫🇯', iso_2: 'FJ' },
{ calling_code: '+358', name: 'Finland', emoji: '🇫🇮', iso_2: 'FI' },
{ calling_code: '+33', name: 'France', emoji: '🇫🇷', iso_2: 'FR' },
{ calling_code: '+594', name: 'French Guiana', emoji: '🇬🇫', iso_2: 'GF' },
{ calling_code: '+689', name: 'French Polynesia', emoji: '🇵🇫', iso_2: 'PF' },
{ calling_code: '+241', name: 'Gabon', emoji: '🇬🇦', iso_2: 'GA' },
{ calling_code: '+220', name: 'Gambia', emoji: '🇬🇲', iso_2: 'GM' },
{ calling_code: '+995', name: 'Georgia', emoji: '🇬🇪', iso_2: 'GE' },
{ calling_code: '+49', name: 'Germany', emoji: '🇩🇪', iso_2: 'DE' },
{ calling_code: '+233', name: 'Ghana', emoji: '🇬🇭', iso_2: 'GH' },
{ calling_code: '+350', name: 'Gibraltar', emoji: '🇬🇮', iso_2: 'GI' },
{ calling_code: '+30', name: 'Greece', emoji: '🇬🇷', iso_2: 'GR' },
{ calling_code: '+299', name: 'Greenland', emoji: '🇬🇱', iso_2: 'GL' },
{ calling_code: '+1-473', name: 'Grenada', emoji: '🇬🇩', iso_2: 'GD' },
{ calling_code: '+590', name: 'Guadeloupe', emoji: '🇬🇵', iso_2: 'GP' },
{ calling_code: '+1-671', name: 'Guam', emoji: '🇬🇺', iso_2: 'GU' },
{ calling_code: '+502', name: 'Guatemala', emoji: '🇬🇹', iso_2: 'GT' },
{ calling_code: '+44-1481', name: 'Guernsey', emoji: '🇬🇬', iso_2: 'GG' },
{ calling_code: '+224', name: 'Guinea', emoji: '🇬🇳', iso_2: 'GN' },
{ calling_code: '+245', name: 'Guinea-Bissau', emoji: '🇬🇼', iso_2: 'GW' },
{ calling_code: '+592', name: 'Guyana', emoji: '🇬🇾', iso_2: 'GY' },
{ calling_code: '+509', name: 'Haiti', emoji: '🇭🇹', iso_2: 'HT' },
{ calling_code: '+379', name: 'Vatican City', emoji: '🇻🇦', iso_2: 'VA' },
{ calling_code: '+504', name: 'Honduras', emoji: '🇭🇳', iso_2: 'HN' },
{ calling_code: '+852', name: 'Hong Kong', emoji: '🇭🇰', iso_2: 'HK' },
{ calling_code: '+36', name: 'Hungary', emoji: '🇭🇺', iso_2: 'HU' },
{ calling_code: '+354', name: 'Iceland', emoji: '🇮🇸', iso_2: 'IS' },
{ calling_code: '+91', name: 'India', emoji: '🇮🇳', iso_2: 'IN' },
{ calling_code: '+62', name: 'Indonesia', emoji: '🇮🇩', iso_2: 'ID' },
{ calling_code: '+98', name: 'Iran', emoji: '🇮🇷', iso_2: 'IR' },
{ calling_code: '+964', name: 'Iraq', emoji: '🇮🇶', iso_2: 'IQ' },
{ calling_code: '+353', name: 'Ireland', emoji: '🇮🇪', iso_2: 'IE' },
{ calling_code: '+44-1624', name: 'Isle of Man', emoji: '🇮🇲', iso_2: 'IM' },
{ calling_code: '+972', name: 'Israel', emoji: '🇮🇱', iso_2: 'IL' },
{ calling_code: '+39', name: 'Italy', emoji: '🇮🇹', iso_2: 'IT' },
{ calling_code: '+1-876', name: 'Jamaica', emoji: '🇯🇲', iso_2: 'JM' },
{ calling_code: '+81', name: 'Japan', emoji: '🇯🇵', iso_2: 'JP' },
{ calling_code: '+44-1534', name: 'Jersey', emoji: '🇯🇪', iso_2: 'JE' },
{ calling_code: '+962', name: 'Jordan', emoji: '🇯🇴', iso_2: 'JO' },
{ calling_code: '+7', name: 'Kazakhstan', emoji: '🇰🇿', iso_2: 'KZ' },
{ calling_code: '+254', name: 'Kenya', emoji: '🇰🇪', iso_2: 'KE' },
{ calling_code: '+686', name: 'Kiribati', emoji: '🇰🇮', iso_2: 'KI' },
{ calling_code: '+383', name: 'Kosovo', emoji: '🇽🇰', iso_2: 'XK' },
{ calling_code: '+965', name: 'Kuwait', emoji: '🇰🇼', iso_2: 'KW' },
{ calling_code: '+996', name: 'Kyrgyzstan', emoji: '🇰🇬', iso_2: 'KG' },
{ calling_code: '+856', name: 'Laos', emoji: '🇱🇦', iso_2: 'LA' },
{ calling_code: '+371', name: 'Latvia', emoji: '🇱🇻', iso_2: 'LV' },
{ calling_code: '+961', name: 'Lebanon', emoji: '🇱🇧', iso_2: 'LB' },
{ calling_code: '+266', name: 'Lesotho', emoji: '🇱🇸', iso_2: 'LS' },
{ calling_code: '+231', name: 'Liberia', emoji: '🇱🇷', iso_2: 'LR' },
{ calling_code: '+218', name: 'Libya', emoji: '🇱🇾', iso_2: 'LY' },
{ calling_code: '+423', name: 'Liechtenstein', emoji: '🇱🇮', iso_2: 'LI' },
{ calling_code: '+370', name: 'Lithuania', emoji: '🇱🇹', iso_2: 'LT' },
{ calling_code: '+352', name: 'Luxembourg', emoji: '🇱🇺', iso_2: 'LU' },
{ calling_code: '+853', name: 'Macao', emoji: '🇲🇴', iso_2: 'MO' },
{ calling_code: '+389', name: 'North Macedonia', emoji: '🇲🇰', iso_2: 'MK' },
{ calling_code: '+261', name: 'Madagascar', emoji: '🇲🇬', iso_2: 'MG' },
{ calling_code: '+265', name: 'Malawi', emoji: '🇲🇼', iso_2: 'MW' },
{ calling_code: '+60', name: 'Malaysia', emoji: '🇲🇾', iso_2: 'MY' },
{ calling_code: '+960', name: 'Maldives', emoji: '🇲🇻', iso_2: 'MV' },
{ calling_code: '+223', name: 'Mali', emoji: '🇲🇱', iso_2: 'ML' },
{ calling_code: '+356', name: 'Malta', emoji: '🇲🇹', iso_2: 'MT' },
{ calling_code: '+692', name: 'Marshall Islands', emoji: '🇲🇭', iso_2: 'MH' },
{ calling_code: '+596', name: 'Martinique', emoji: '🇲🇶', iso_2: 'MQ' },
{ calling_code: '+222', name: 'Mauritania', emoji: '🇲🇷', iso_2: 'MR' },
{ calling_code: '+230', name: 'Mauritius', emoji: '🇲🇺', iso_2: 'MU' },
{ calling_code: '+262', name: 'Mayotte', emoji: '🇾🇹', iso_2: 'YT' },
{ calling_code: '+52', name: 'Mexico', emoji: '🇲🇽', iso_2: 'MX' },
{ calling_code: '+691', name: 'Micronesia', emoji: '🇫🇲', iso_2: 'FM' },
{ calling_code: '+373', name: 'Moldova', emoji: '🇲🇩', iso_2: 'MD' },
{ calling_code: '+377', name: 'Monaco', emoji: '🇲🇨', iso_2: 'MC' },
{ calling_code: '+976', name: 'Mongolia', emoji: '🇲🇳', iso_2: 'MN' },
{ calling_code: '+382', name: 'Montenegro', emoji: '🇲🇪', iso_2: 'ME' },
{ calling_code: '+1-664', name: 'Montserrat', emoji: '🇲🇸', iso_2: 'MS' },
{ calling_code: '+212', name: 'Morocco', emoji: '🇲🇦', iso_2: 'MA' },
{ calling_code: '+258', name: 'Mozambique', emoji: '🇲🇿', iso_2: 'MZ' },
{ calling_code: '+95', name: 'Myanmar', emoji: '🇲🇲', iso_2: 'MM' },
{ calling_code: '+264', name: 'Namibia', emoji: '🇳🇦', iso_2: 'NA' },
{ calling_code: '+674', name: 'Nauru', emoji: '🇳🇷', iso_2: 'NR' },
{ calling_code: '+977', name: 'Nepal', emoji: '🇳🇵', iso_2: 'NP' },
{ calling_code: '+31', name: 'Netherlands', emoji: '🇳🇱', iso_2: 'NL' },
{ calling_code: '+687', name: 'New Caledonia', emoji: '🇳🇨', iso_2: 'NC' },
{ calling_code: '+64', name: 'New Zealand', emoji: '🇳🇿', iso_2: 'NZ' },
{ calling_code: '+505', name: 'Nicaragua', emoji: '🇳🇮', iso_2: 'NI' },
{ calling_code: '+227', name: 'Niger', emoji: '🇳🇪', iso_2: 'NE' },
{ calling_code: '+234', name: 'Nigeria', emoji: '🇳🇬', iso_2: 'NG' },
{ calling_code: '+683', name: 'Niue', emoji: '🇳🇺', iso_2: 'NU' },
{ calling_code: '+672', name: 'Norfolk Island', emoji: '🇳🇫', iso_2: 'NF' },
{ calling_code: '+850', name: 'North Korea', emoji: '🇰🇵', iso_2: 'KP' },
{ calling_code: '+47', name: 'Norway', emoji: '🇳🇴', iso_2: 'NO' },
{ calling_code: '+968', name: 'Oman', emoji: '🇴🇲', iso_2: 'OM' },
{ calling_code: '+92', name: 'Pakistan', emoji: '🇵🇰', iso_2: 'PK' },
{ calling_code: '+680', name: 'Palau', emoji: '🇵🇼', iso_2: 'PW' },
{ calling_code: '+970', name: 'Palestine', emoji: '🇵🇸', iso_2: 'PS' },
{ calling_code: '+507', name: 'Panama', emoji: '🇵🇦', iso_2: 'PA' },
{ calling_code: '+675', name: 'Papua New Guinea', emoji: '🇵🇬', iso_2: 'PG' },
{ calling_code: '+595', name: 'Paraguay', emoji: '🇵🇾', iso_2: 'PY' },
{ calling_code: '+51', name: 'Peru', emoji: '🇵🇪', iso_2: 'PE' },
{ calling_code: '+63', name: 'Philippines', emoji: '🇵🇭', iso_2: 'PH' },
{ calling_code: '+64', name: 'Pitcairn Islands', emoji: '🇵🇳', iso_2: 'PN' },
{ calling_code: '+48', name: 'Poland', emoji: '🇵🇱', iso_2: 'PL' },
{ calling_code: '+351', name: 'Portugal', emoji: '🇵🇹', iso_2: 'PT' },
{ calling_code: '+1-787', name: 'Puerto Rico', emoji: '🇵🇷', iso_2: 'PR' },
{ calling_code: '+974', name: 'Qatar', emoji: '🇶🇦', iso_2: 'QA' },
{ calling_code: '+40', name: 'Romania', emoji: '🇷🇴', iso_2: 'RO' },
{ calling_code: '+7', name: 'Russia', emoji: '🇷🇺', iso_2: 'RU' },
{ calling_code: '+250', name: 'Rwanda', emoji: '🇷🇼', iso_2: 'RW' },
{ calling_code: '+590', name: 'Saint Barthélemy', emoji: '🇧🇱', iso_2: 'BL' },
{ calling_code: '+290', name: 'Saint Helena, Ascension and Tristan da Cunha', emoji: '🇸🇭', iso_2: 'SH' },
{ calling_code: '+1-869', name: 'Saint Kitts and Nevis', emoji: '🇰🇳', iso_2: 'KN' },
{ calling_code: '+1-758', name: 'Saint Lucia', emoji: '🇱🇨', iso_2: 'LC' },
{ calling_code: '+590', name: 'Saint Martin', emoji: '🇲🇫', iso_2: 'MF' },
{ calling_code: '+508', name: 'Saint Pierre and Miquelon', emoji: '🇵🇲', iso_2: 'PM' },
{ calling_code: '+1-784', name: 'Saint Vincent and the Grenadines', emoji: '🇻🇨', iso_2: 'VC' },
{ calling_code: '+685', name: 'Samoa', emoji: '🇼🇸', iso_2: 'WS' },
{ calling_code: '+378', name: 'San Marino', emoji: '🇸🇲', iso_2: 'SM' },
{ calling_code: '+239', name: 'Sao Tome and Principe', emoji: '🇸🇹', iso_2: 'ST' },
{ calling_code: '+966', name: 'Saudi Arabia', emoji: '🇸🇦', iso_2: 'SA' },
{ calling_code: '+221', name: 'Senegal', emoji: '🇸🇳', iso_2: 'SN' },
{ calling_code: '+381', name: 'Serbia', emoji: '🇷🇸', iso_2: 'RS' },
{ calling_code: '+248', name: 'Seychelles', emoji: '🇸🇨', iso_2: 'SC' },
{ calling_code: '+232', name: 'Sierra Leone', emoji: '🇸🇱', iso_2: 'SL' },
{ calling_code: '+65', name: 'Singapore', emoji: '🇸🇬', iso_2: 'SG' },
{ calling_code: '+1-721', name: 'Sint Maarten', emoji: '🇸🇽', iso_2: 'SX' },
{ calling_code: '+421', name: 'Slovakia', emoji: '🇸🇰', iso_2: 'SK' },
{ calling_code: '+386', name: 'Slovenia', emoji: '🇸🇮', iso_2: 'SI' },
{ calling_code: '+677', name: 'Solomon Islands', emoji: '🇸🇧', iso_2: 'SB' },
{ calling_code: '+252', name: 'Somalia', emoji: '🇸🇴', iso_2: 'SO' },
{ calling_code: '+27', name: 'South Africa', emoji: '🇿🇦', iso_2: 'ZA' },
{ calling_code: '+82', name: 'South Korea', emoji: '🇰🇷', iso_2: 'KR' },
{ calling_code: '+211', name: 'South Sudan', emoji: '🇸🇸', iso_2: 'SS' },
{ calling_code: '+34', name: 'Spain', emoji: '🇪🇸', iso_2: 'ES' },
{ calling_code: '+94', name: 'Sri Lanka', emoji: '🇱🇰', iso_2: 'LK' },
{ calling_code: '+249', name: 'Sudan', emoji: '🇸🇩', iso_2: 'SD' },
{ calling_code: '+597', name: 'Suriname', emoji: '🇸🇷', iso_2: 'SR' },
{ calling_code: '+47', name: 'Svalbard and Jan Mayen', emoji: '🇸🇯', iso_2: 'SJ' },
{ calling_code: '+46', name: 'Sweden', emoji: '🇸🇪', iso_2: 'SE' },
{ calling_code: '+41', name: 'Switzerland', emoji: '🇨🇭', iso_2: 'CH' },
{ calling_code: '+963', name: 'Syria', emoji: '🇸🇾', iso_2: 'SY' },
{ calling_code: '+886', name: 'Taiwan', emoji: '🇹🇼', iso_2: 'TW' },
{ calling_code: '+992', name: 'Tajikistan', emoji: '🇹🇯', iso_2: 'TJ' },
{ calling_code: '+255', name: 'Tanzania', emoji: '🇹🇿', iso_2: 'TZ' },
{ calling_code: '+66', name: 'Thailand', emoji: '🇹🇭', iso_2: 'TH' },
{ calling_code: '+670', name: 'Timor-Leste', emoji: '🇹🇱', iso_2: 'TL' },
{ calling_code: '+228', name: 'Togo', emoji: '🇹🇬', iso_2: 'TG' },
{ calling_code: '+690', name: 'Tokelau', emoji: '🇹🇰', iso_2: 'TK' },
{ calling_code: '+676', name: 'Tonga', emoji: '🇹🇴', iso_2: 'TO' },
{ calling_code: '+1-868', name: 'Trinidad and Tobago', emoji: '🇹🇹', iso_2: 'TT' },
{ calling_code: '+216', name: 'Tunisia', emoji: '🇹🇳', iso_2: 'TN' },
{ calling_code: '+90', name: 'Turkey', emoji: '🇹🇷', iso_2: 'TR' },
{ calling_code: '+993', name: 'Turkmenistan', emoji: '🇹🇲', iso_2: 'TM' },
{ calling_code: '+1-649', name: 'Turks and Caicos Islands', emoji: '🇹🇨', iso_2: 'TC' },
{ calling_code: '+688', name: 'Tuvalu', emoji: '🇹🇻', iso_2: 'TV' },
{ calling_code: '+256', name: 'Uganda', emoji: '🇺🇬', iso_2: 'UG' },
{ calling_code: '+380', name: 'Ukraine', emoji: '🇺🇦', iso_2: 'UA' },
{ calling_code: '+971', name: 'United Arab Emirates', emoji: '🇦🇪', iso_2: 'AE' },
{ calling_code: '+44', name: 'United Kingdom', emoji: '🇬🇧', iso_2: 'GB' },
{ calling_code: '+1', name: 'United States', emoji: '🇺🇸', iso_2: 'US' },
{ calling_code: '+598', name: 'Uruguay', emoji: '🇺🇾', iso_2: 'UY' },
{ calling_code: '+998', name: 'Uzbekistan', emoji: '🇺🇿', iso_2: 'UZ' },
{ calling_code: '+678', name: 'Vanuatu', emoji: '🇻🇺', iso_2: 'VU' },
{ calling_code: '+58', name: 'Venezuela', emoji: '🇻🇪', iso_2: 'VE' },
{ calling_code: '+84', name: 'Vietnam', emoji: '🇻🇳', iso_2: 'VN' },
{ calling_code: '+681', name: 'Wallis and Futuna', emoji: '🇼🇫', iso_2: 'WF' },
{ calling_code: '+212', name: 'Western Sahara', emoji: '🇪🇭', iso_2: 'EH' },
{ calling_code: '+967', name: 'Yemen', emoji: '🇾🇪', iso_2: 'YE' },
{ calling_code: '+260', name: 'Zambia', emoji: '🇿🇲', iso_2: 'ZM' },
{ calling_code: '+263', name: 'Zimbabwe', emoji: '🇿🇼', iso_2: 'ZW' }
]
export default countries;

View File

@@ -1,4 +1,5 @@
export const EMITTER_EVENTS = {
EDIT_MODEL: 'edit-model',
REFRESH_LIST: 'refresh-list',
SHOW_TOAST: 'show-toast',
SHOW_SOONER: 'show-sooner',

View File

@@ -3,7 +3,9 @@ export const FIELD_TYPE = {
TAG: 'tag',
TEXT: 'text',
NUMBER: 'number',
RICHTEXT: 'richtext'
RICHTEXT: 'richtext',
BOOLEAN: 'boolean',
DATE: 'date',
}
export const OPERATOR = {
@@ -19,6 +21,7 @@ export const OPERATOR = {
export const FIELD_OPERATORS = {
SELECT: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, OPERATOR.SET, OPERATOR.NOT_SET],
BOOLEAN: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS],
TEXT: [
OPERATOR.EQUALS,
OPERATOR.NOT_EQUALS,
@@ -27,5 +30,13 @@ export const FIELD_OPERATORS = {
OPERATOR.CONTAINS,
OPERATOR.NOT_CONTAINS
],
NUMBER: [OPERATOR.GREATER_THAN, OPERATOR.LESS_THAN]
DATE: [
OPERATOR.EQUALS,
OPERATOR.NOT_EQUALS,
OPERATOR.SET,
OPERATOR.NOT_SET,
OPERATOR.GREATER_THAN,
OPERATOR.LESS_THAN
],
NUMBER: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, OPERATOR.GREATER_THAN, OPERATOR.LESS_THAN],
}

View File

@@ -0,0 +1,160 @@
export const reportsNavItems = [
{
titleKey: 'globals.terms.overview',
href: '/reports/overview',
permission: 'reports:manage'
}
]
export const adminNavItems = [
{
titleKey: 'globals.terms.workspace',
children: [
{
titleKey: 'globals.terms.general',
href: '/admin/general',
permission: 'general_settings:manage'
},
{
titleKey: 'globals.terms.businessHour',
href: '/admin/business-hours',
permission: 'business_hours:manage'
},
{
titleKey: 'globals.terms.slaPolicy',
href: '/admin/sla',
permission: 'sla:manage'
}
]
},
{
titleKey: 'globals.terms.conversation',
children: [
{
titleKey: 'globals.terms.tag',
href: '/admin/conversations/tags',
permission: 'tags:manage'
},
{
titleKey: 'globals.terms.macro',
href: '/admin/conversations/macros',
permission: 'macros:manage'
},
{
titleKey: 'globals.terms.status',
href: '/admin/conversations/statuses',
permission: 'status:manage'
}
]
},
{
titleKey: 'globals.terms.inbox',
children: [
{
titleKey: 'globals.terms.inbox',
href: '/admin/inboxes',
permission: 'inboxes:manage'
}
]
},
{
titleKey: 'globals.terms.teammate',
children: [
{
titleKey: 'globals.terms.agent',
href: '/admin/teams/agents',
permission: 'users:manage'
},
{
titleKey: 'globals.terms.team',
href: '/admin/teams/teams',
permission: 'teams:manage'
},
{
titleKey: 'globals.terms.role',
href: '/admin/teams/roles',
permission: 'roles:manage'
},
{
titleKey: 'globals.terms.activityLog',
href: '/admin/teams/activity-log',
permission: 'activity_logs:manage'
}
]
},
{
titleKey: 'globals.terms.automation',
children: [
{
titleKey: 'globals.terms.automation',
href: '/admin/automations',
permission: 'automations:manage'
}
]
},
{
titleKey: 'globals.terms.customAttribute',
children: [
{
titleKey: 'globals.terms.customAttribute',
href: '/admin/custom-attributes',
permission: 'custom_attributes:manage'
}
]
},
{
titleKey: 'globals.terms.notification',
children: [
{
titleKey: 'globals.terms.email',
href: '/admin/notification',
permission: 'notification_settings:manage'
}
]
},
{
titleKey: 'globals.terms.template',
children: [
{
titleKey: 'globals.terms.template',
href: '/admin/templates',
permission: 'templates:manage'
}
]
},
{
titleKey: 'globals.terms.security',
children: [
{
titleKey: 'globals.terms.sso',
href: '/admin/sso',
permission: 'oidc:manage'
}
]
},
{
titleKey: 'globals.terms.integration',
isTitleKeyPlural: true,
children: [
{
titleKey: 'globals.terms.webhook',
href: '/admin/webhooks',
permission: 'webhooks:manage'
}
]
}
]
export const accountNavItems = [
{
titleKey: 'globals.terms.profile',
href: '/account/profile'
}
]
export const contactNavItems = [
{
titleKey: 'globals.terms.contact',
href: '/contacts'
}
]

View File

@@ -0,0 +1,42 @@
export const permissions = {
CONVERSATIONS_READ: 'conversations:read',
CONVERSATIONS_WRITE: 'conversations:write',
CONVERSATIONS_READ_ASSIGNED: 'conversations:read_assigned',
CONVERSATIONS_READ_ALL: 'conversations:read_all',
CONVERSATIONS_READ_UNASSIGNED: 'conversations:read_unassigned',
CONVERSATIONS_READ_TEAM_INBOX: 'conversations:read_team_inbox',
CONVERSATIONS_UPDATE_USER_ASSIGNEE: 'conversations:update_user_assignee',
CONVERSATIONS_UPDATE_TEAM_ASSIGNEE: 'conversations:update_team_assignee',
CONVERSATIONS_UPDATE_PRIORITY: 'conversations:update_priority',
CONVERSATIONS_UPDATE_STATUS: 'conversations:update_status',
CONVERSATIONS_UPDATE_TAGS: 'conversations:update_tags',
MESSAGES_READ: 'messages:read',
MESSAGES_WRITE: 'messages:write',
VIEW_MANAGE: 'view:manage',
GENERAL_SETTINGS_MANAGE: 'general_settings:manage',
NOTIFICATION_SETTINGS_MANAGE: 'notification_settings:manage',
STATUS_MANAGE: 'status:manage',
OIDC_MANAGE: 'oidc:manage',
TAGS_MANAGE: 'tags:manage',
MACROS_MANAGE: 'macros:manage',
USERS_MANAGE: 'users:manage',
TEAMS_MANAGE: 'teams:manage',
AUTOMATIONS_MANAGE: 'automations:manage',
INBOXES_MANAGE: 'inboxes:manage',
ROLES_MANAGE: 'roles:manage',
TEMPLATES_MANAGE: 'templates:manage',
REPORTS_MANAGE: 'reports:manage',
BUSINESS_HOURS_MANAGE: 'business_hours:manage',
SLA_MANAGE: 'sla:manage',
AI_MANAGE: 'ai:manage',
CUSTOM_ATTRIBUTES_MANAGE: 'custom_attributes:manage',
CONTACTS_READ_ALL: 'contacts:read_all',
CONTACTS_READ: 'contacts:read',
CONTACTS_WRITE: 'contacts:write',
CONTACTS_BLOCK: 'contacts:block',
CONTACT_NOTES_READ: 'contact_notes:read',
CONTACT_NOTES_WRITE: 'contact_notes:write',
CONTACT_NOTES_DELETE: 'contact_notes:delete',
ACTIVITY_LOGS_MANAGE: 'activity_logs:manage',
WEBHOOKS_MANAGE: 'webhooks:manage'
}

View File

@@ -0,0 +1,36 @@
export const timeZones = {
"UTC (UTC+00:00)": "UTC",
"New York, America (UTC-05:00)": "America/New_York",
"Chicago, America (UTC-06:00)": "America/Chicago",
"Denver, America (UTC-07:00)": "America/Denver",
"Los Angeles, America (UTC-08:00)": "America/Los_Angeles",
"Toronto, America (UTC-05:00)": "America/Toronto",
"Mexico City, America (UTC-06:00)": "America/Mexico_City",
"Bogotá, America (UTC-05:00)": "America/Bogota",
"São Paulo, America (UTC-03:00)": "America/Sao_Paulo",
"Buenos Aires, America (UTC-03:00)": "America/Buenos_Aires",
"Santiago, America (UTC-04:00)": "America/Santiago",
"London, Europe (UTC+00:00)": "Europe/London",
"Berlin, Europe (UTC+01:00)": "Europe/Berlin",
"Paris, Europe (UTC+01:00)": "Europe/Paris",
"Rome, Europe (UTC+01:00)": "Europe/Rome",
"Madrid, Europe (UTC+01:00)": "Europe/Madrid",
"Moscow, Europe (UTC+03:00)": "Europe/Moscow",
"Istanbul, Europe (UTC+03:00)": "Europe/Istanbul",
"Dubai, Asia (UTC+04:00)": "Asia/Dubai",
"Kolkata, Asia (UTC+05:30)": "Asia/Kolkata",
"Bangkok, Asia (UTC+07:00)": "Asia/Bangkok",
"Singapore, Asia (UTC+08:00)": "Asia/Singapore",
"Shanghai, Asia (UTC+08:00)": "Asia/Shanghai",
"Seoul, Asia (UTC+09:00)": "Asia/Seoul",
"Tokyo, Asia (UTC+09:00)": "Asia/Tokyo",
"Sydney, Australia (UTC+10:00)": "Australia/Sydney",
"Melbourne, Australia (UTC+10:00)": "Australia/Melbourne",
"Perth, Australia (UTC+08:00)": "Australia/Perth",
"Auckland, Pacific (UTC+12:00)": "Pacific/Auckland",
"Honolulu, Pacific (UTC-10:00)": "Pacific/Honolulu",
"Cairo, Africa (UTC+02:00)": "Africa/Cairo",
"Lagos, Africa (UTC+01:00)": "Africa/Lagos",
"Nairobi, Africa (UTC+03:00)": "Africa/Nairobi",
"Johannesburg, Africa (UTC+02:00)": "Africa/Johannesburg"
}

View File

@@ -0,0 +1,13 @@
export const WS_EVENT = {
NEW_MESSAGE: 'new_message',
MESSAGE_PROP_UPDATE: 'message_prop_update',
CONVERSATION_PROP_UPDATE: 'conversation_prop_update',
CONVERSATION_SUBSCRIBE: 'conversation_subscribe',
CONVERSATION_SUBSCRIBED: 'conversation_subscribed',
TYPING: 'typing',
}
// Message types that should not be queued because they become stale quickly
export const WS_EPHEMERAL_TYPES = [
WS_EVENT.TYPING,
]

View File

@@ -0,0 +1,248 @@
<template>
<div class="min-h-screen flex flex-col">
<div class="flex flex-wrap gap-4 pb-4">
<div class="flex items-center gap-2 mb-4">
<!-- Filter Popover -->
<Popover :open="filtersOpen" @update:open="filtersOpen = $event">
<PopoverTrigger @click="filtersOpen = !filtersOpen">
<Button variant="outline" size="sm" class="flex items-center gap-2 h-8">
<ListFilter size="14" />
<span>Filter</span>
<span
v-if="filters.length > 0"
class="flex items-center justify-center bg-primary text-primary-foreground rounded-full size-4 text-xs"
>
{{ filters.length }}
</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-full p-4 flex flex-col gap-4">
<div class="w-[32rem]">
<FilterBuilder
:fields="filterFields"
:showButtons="true"
v-model="filters"
@apply="fetchActivityLogs"
@clear="fetchActivityLogs"
/>
</div>
</PopoverContent>
</Popover>
<!-- Order By Popover -->
<Popover>
<PopoverTrigger>
<Button variant="outline" size="sm" class="flex items-center h-8">
<ArrowDownWideNarrow size="18" class="text-muted-foreground cursor-pointer" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-4 flex flex-col gap-4">
<!-- order by field -->
<Select v-model="orderByField" @update:model-value="fetchActivityLogs">
<SelectTrigger class="h-8 w-full">
<SelectValue :placeholder="orderByField" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="'activity_logs.created_at'">
{{ t('globals.terms.createdAt') }}
</SelectItem>
</SelectContent>
</Select>
<!-- order by direction -->
<Select v-model="orderByDirection" @update:model-value="fetchActivityLogs">
<SelectTrigger class="h-8 w-full">
<SelectValue :placeholder="orderByDirection" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="'asc'">Ascending</SelectItem>
<SelectItem :value="'desc'">Descending</SelectItem>
</SelectContent>
</Select>
</PopoverContent>
</Popover>
</div>
<div class="w-full overflow-x-auto">
<SimpleTable
:headers="[
t('globals.terms.name'),
t('globals.terms.timestamp'),
t('globals.terms.ipAddress')
]"
:keys="['activity_description', 'created_at', 'ip']"
:data="activityLogs"
:showDelete="false"
:loading="loading"
:skeletonRows="15"
/>
</div>
</div>
<!-- TODO: deduplicate this code, copied from contacts list -->
<div class="sticky bottom-0 bg-background p-4 mt-auto">
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">
{{ t('globals.terms.page') }} {{ page }} of {{ totalPages }}
</span>
<Select v-model="perPage" @update:model-value="handlePerPageChange">
<SelectTrigger class="h-8 w-[70px]">
<SelectValue :placeholder="perPage" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="15">15</SelectItem>
<SelectItem :value="30">30</SelectItem>
<SelectItem :value="50">50</SelectItem>
<SelectItem :value="100">100</SelectItem>
</SelectContent>
</Select>
</div>
<Pagination>
<PaginationList class="flex items-center gap-1">
<PaginationListItem>
<PaginationFirst
:class="{ 'cursor-not-allowed opacity-50': page === 1 }"
@click.prevent="page > 1 ? goToPage(1) : null"
/>
</PaginationListItem>
<PaginationListItem>
<PaginationPrev
:class="{ 'cursor-not-allowed opacity-50': page === 1 }"
@click.prevent="page > 1 ? goToPage(page - 1) : null"
/>
</PaginationListItem>
<template v-for="pageNumber in visiblePages" :key="pageNumber">
<PaginationListItem v-if="pageNumber === '...'">
<PaginationEllipsis />
</PaginationListItem>
<PaginationListItem v-else>
<Button
:is-active="pageNumber === page"
@click.prevent="goToPage(pageNumber)"
:variant="pageNumber === page ? 'default' : 'outline'"
>
{{ pageNumber }}
</Button>
</PaginationListItem>
</template>
<PaginationListItem>
<PaginationNext
:class="{ 'cursor-not-allowed opacity-50': page === totalPages }"
@click.prevent="page < totalPages ? goToPage(page + 1) : null"
/>
</PaginationListItem>
<PaginationListItem>
<PaginationLast
:class="{ 'cursor-not-allowed opacity-50': page === totalPages }"
@click.prevent="page < totalPages ? goToPage(totalPages) : null"
/>
</PaginationListItem>
</PaginationList>
</Pagination>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import SimpleTable from '@main/components/table/SimpleTable.vue'
import {
Pagination,
PaginationEllipsis,
PaginationFirst,
PaginationLast,
PaginationList,
PaginationListItem,
PaginationNext,
PaginationPrev
} from '@shared-ui/components/ui/pagination'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@shared-ui/components/ui/select'
import FilterBuilder from '@main/components/filter/FilterBuilder.vue'
import { Button } from '@shared-ui/components/ui/button'
import { ListFilter, ArrowDownWideNarrow } from 'lucide-vue-next'
import { Popover, PopoverContent, PopoverTrigger } from '@shared-ui/components/ui/popover'
import { useActivityLogFilters } from '../../../composables/useActivityLogFilters'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { getVisiblePages } from '../../../utils/pagination'
import api from '../../../api'
const activityLogs = ref([])
const { t } = useI18n()
const loading = ref(true)
const page = ref(1)
const perPage = ref(15)
const orderByField = ref('activity_logs.created_at')
const orderByDirection = ref('desc')
const totalCount = ref(0)
const totalPages = ref(0)
const filters = ref([])
const filtersOpen = ref(false)
const { activityLogListFilters } = useActivityLogFilters()
const filterFields = computed(() =>
Object.entries(activityLogListFilters.value).map(([field, value]) => ({
model: 'activity_logs',
label: value.label,
field,
type: value.type,
operators: value.operators,
options: value.options ?? []
}))
)
const visiblePages = computed(() => getVisiblePages(page.value, totalPages.value))
async function fetchActivityLogs() {
filtersOpen.value = false
loading.value = true
try {
const resp = await api.getActivityLogs({
page: page.value,
page_size: perPage.value,
filters: JSON.stringify(filters.value),
order: orderByDirection.value,
order_by: orderByField.value
})
activityLogs.value = resp.data.data.results
totalCount.value = resp.data.data.count
totalPages.value = resp.data.data.total_pages
// Format the created_at field
activityLogs.value = activityLogs.value.map((log) => ({
...log,
created_at: format(new Date(log.created_at), 'PPpp')
}))
} catch (err) {
console.error('Error fetching activity logs:', err)
activityLogs.value = []
totalCount.value = 0
} finally {
loading.value = false
}
}
function goToPage(p) {
if (p >= 1 && p <= totalPages.value && p !== page.value) {
page.value = p
}
}
function handlePerPageChange() {
page.value = 1
fetchActivityLogs()
}
watch([page, perPage, orderByField, orderByDirection], fetchActivityLogs)
onMounted(fetchActivityLogs)
</script>

View File

@@ -0,0 +1,540 @@
<template>
<form @submit.prevent="onSubmit" class="space-y-8">
<!-- Summary Section -->
<div class="bg-muted/30 box py-6 px-3" v-if="!isNewForm">
<div class="flex items-start gap-6">
<Avatar class="w-20 h-20">
<AvatarImage :src="props.initialValues.avatar_url || ''" :alt="Avatar" />
<AvatarFallback>
{{ getInitials(props.initialValues.first_name, props.initialValues.last_name) }}
</AvatarFallback>
</Avatar>
<div class="space-y-4 flex-2">
<div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-gray-900 dark:text-foreground">
{{ props.initialValues.first_name }} {{ props.initialValues.last_name }}
</h3>
<Badge :class="['px-2 rounded-full text-xs font-medium', availabilityStatus.color]">
{{ availabilityStatus.text }}
</Badge>
</div>
<div class="flex flex-wrap items-center gap-6">
<div class="flex items-center gap-2">
<Clock class="w-5 h-5 text-gray-400" />
<div>
<p class="text-sm text-gray-500">{{ $t('globals.terms.lastActive') }}</p>
<p class="text-sm font-medium text-gray-700 dark:text-foreground">
{{
props.initialValues.last_active_at
? format(new Date(props.initialValues.last_active_at), 'PPpp')
: 'N/A'
}}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<LogIn class="w-5 h-5 text-gray-400" />
<div>
<p class="text-sm text-gray-500">{{ $t('globals.terms.lastLogin') }}</p>
<p class="text-sm font-medium text-gray-700 dark:text-foreground">
{{
props.initialValues.last_login_at
? format(new Date(props.initialValues.last_login_at), 'PPpp')
: 'N/A'
}}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- API Key Management Section -->
<div class="bg-muted/30 box p-4 space-y-4" v-if="!isNewForm">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<p class="text-base font-semibold text-gray-900 dark:text-foreground">
{{ $t('globals.terms.apiKey', 2) }}
</p>
<p class="text-sm text-gray-500">
{{ $t('admin.agent.apiKey.description') }}
</p>
</div>
</div>
<!-- API Key Display -->
<div v-if="apiKeyData.api_key" class="space-y-3">
<div class="flex items-center justify-between p-3 bg-background border rounded-md">
<div class="flex items-center gap-3">
<Key class="w-4 h-4 text-gray-400" />
<div>
<p class="text-sm font-medium">{{ $t('globals.terms.apiKey') }}</p>
<p class="text-xs text-gray-500 font-mono">{{ apiKeyData.api_key }}</p>
</div>
</div>
<div class="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
@click="regenerateAPIKey"
:disabled="isAPIKeyLoading"
>
<RotateCcw class="w-4 h-4 mr-1" />
{{ $t('globals.messages.regenerate') }}
</Button>
<Button
type="button"
variant="destructive"
size="sm"
@click="revokeAPIKey"
:disabled="isAPIKeyLoading"
>
<Trash2 class="w-4 h-4 mr-1" />
{{ $t('globals.messages.revoke') }}
</Button>
</div>
</div>
<!-- Last Used Info -->
<div v-if="apiKeyLastUsedAt" class="text-xs text-gray-500">
{{ $t('globals.messages.lastUsed') }}:
{{ format(new Date(apiKeyLastUsedAt), 'PPpp') }}
</div>
</div>
<!-- No API Key State -->
<div v-else class="text-center py-6">
<Key class="w-8 h-8 text-gray-400 mx-auto mb-2" />
<p class="text-sm text-gray-500 mb-3">{{ $t('admin.agent.apiKey.noKey') }}</p>
<Button type="button" @click="generateAPIKey" :disabled="isAPIKeyLoading">
<Plus class="w-4 h-4 mr-1" />
{{ $t('globals.messages.generate', { name: $t('globals.terms.apiKey') }) }}
</Button>
</div>
</div>
<!-- API Key Display Dialog -->
<Dialog v-model:open="showAPIKeyDialog">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{{ $t('globals.messages.generated', { name: $t('globals.terms.apiKey') }) }}
</DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div>
<Label class="text-sm font-medium">{{ $t('globals.terms.apiKey') }}</Label>
<div class="flex items-center gap-2 mt-1">
<Input v-model="newAPIKeyData.api_key" readonly class="font-mono text-sm" />
<Button
type="button"
variant="outline"
size="sm"
@click="copyToClipboard(newAPIKeyData.api_key)"
>
<Copy class="w-4 h-4" />
</Button>
</div>
</div>
<div>
<Label class="text-sm font-medium">{{ $t('globals.terms.secret') }}</Label>
<div class="flex items-center gap-2 mt-1">
<Input v-model="newAPIKeyData.api_secret" readonly class="font-mono text-sm" />
<Button
type="button"
variant="outline"
size="sm"
@click="copyToClipboard(newAPIKeyData.api_secret)"
>
<Copy class="w-4 h-4" />
</Button>
</div>
</div>
<Alert>
<AlertTriangle class="h-4 w-4" />
<AlertTitle>{{ $t('globals.terms.warning') }}</AlertTitle>
<AlertDescription>
{{ $t('admin.agent.apiKey.warningMessage') }}
</AlertDescription>
</Alert>
</div>
<DialogFooter>
<Button @click="closeAPIKeyModal">{{ $t('globals.messages.close') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Form Fields -->
<FormField v-slot="{ field }" name="first_name">
<FormItem v-auto-animate>
<FormLabel>{{ $t('globals.terms.firstName') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="field" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ field }" name="last_name">
<FormItem>
<FormLabel>{{ $t('globals.terms.lastName') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="field" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ field }" name="email">
<FormItem v-auto-animate>
<FormLabel>{{ $t('globals.terms.email') }}</FormLabel>
<FormControl>
<Input type="email" placeholder="" v-bind="field" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField, handleChange }" name="teams">
<FormItem v-auto-animate>
<FormLabel>{{ $t('globals.terms.team', 2) }}</FormLabel>
<FormControl>
<SelectTag
:items="teamOptions"
:placeholder="t('globals.messages.select', { name: t('globals.terms.team', 2) })"
v-model="componentField.modelValue"
@update:modelValue="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField, handleChange }" name="roles">
<FormItem v-auto-animate>
<FormLabel>{{ $t('globals.terms.role', 2) }}</FormLabel>
<FormControl>
<SelectTag
:items="roleOptions"
:placeholder="
t('globals.messages.select', {
name: $t('globals.terms.role', 2)
})
"
v-model="componentField.modelValue"
@update:modelValue="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="availability_status" v-if="!isNewForm">
<FormItem>
<FormLabel>{{ t('globals.terms.availabilityStatus') }}</FormLabel>
<FormControl>
<Select v-bind="componentField" v-model="componentField.modelValue">
<SelectTrigger>
<SelectValue
:placeholder="
t('globals.messages.select', {
name: t('globals.terms.availabilityStatus')
})
"
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="active_group">{{ t('globals.terms.active') }}</SelectItem>
<SelectItem value="away_manual">{{ t('globals.terms.away') }}</SelectItem>
<SelectItem value="away_and_reassigning">
{{ t('globals.terms.awayReassigning') }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ field }" name="new_password" v-if="!isNewForm">
<FormItem v-auto-animate>
<FormLabel>{{ t('globals.terms.setPassword') }}</FormLabel>
<FormControl>
<Input type="password" placeholder="" v-bind="field" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField name="send_welcome_email" v-slot="{ value, handleChange }" v-if="isNewForm">
<FormItem>
<FormControl>
<div class="flex items-center space-x-2">
<Checkbox :checked="value" @update:checked="handleChange" />
<Label>{{ $t('globals.terms.sendWelcomeEmail') }}</Label>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ value, handleChange }" type="checkbox" name="enabled" v-if="!isNewForm">
<FormItem class="flex flex-row items-start gap-x-3 space-y-0">
<FormControl>
<Checkbox :checked="value" @update:checked="handleChange" />
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel> {{ $t('globals.terms.enabled') }} </FormLabel>
<FormMessage />
</div>
</FormItem>
</FormField>
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
</form>
</template>
<script setup>
import { watch, onMounted, ref, computed } from 'vue'
import { Button } from '@shared-ui/components/ui/button/index.js'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { createFormSchema } from './formSchema.js'
import { Checkbox } from '@shared-ui/components/ui/checkbox/index.js'
import { Label } from '@shared-ui/components/ui/label/index.js'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import { Badge } from '@shared-ui/components/ui/badge/index.js'
import { Clock, LogIn, Key, RotateCcw, Trash2, Plus, Copy, AlertTriangle } from 'lucide-vue-next'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shared-ui/components/ui/form/index.js'
import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/avatar/index.js'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@shared-ui/components/ui/select/index.js'
import { SelectTag } from '@shared-ui/components/ui/select/index.js'
import { Input } from '@shared-ui/components/ui/input/index.js'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@shared-ui/components/ui/dialog/index.js'
import { Alert, AlertDescription, AlertTitle } from '@shared-ui/components/ui/alert/index.js'
import { useI18n } from 'vue-i18n'
import { useEmitter } from '../../../composables/useEmitter.js'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import { format } from 'date-fns'
import api from '../../../api/index.js'
const props = defineProps({
initialValues: {
type: Object,
required: false
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
required: false,
default: 'Submit'
},
isNewForm: {
type: Boolean,
required: false,
default: false
},
isLoading: {
Type: Boolean,
required: false
}
})
const { t } = useI18n()
const teams = ref([])
const roles = ref([])
const emitter = useEmitter()
const apiKeyData = ref({
api_key: props.initialValues?.api_key || '',
api_secret: ''
})
const apiKeyLastUsedAt = ref(props.initialValues?.api_key_last_used_at || null)
const newAPIKeyData = ref({
api_key: '',
api_secret: ''
})
const showAPIKeyDialog = ref(false)
const isAPIKeyLoading = ref(false)
onMounted(async () => {
try {
const [teamsResp, rolesResp] = await Promise.allSettled([api.getTeams(), api.getRoles()])
teams.value = teamsResp.value.data.data
roles.value = rolesResp.value.data.data
} catch (err) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: t('globals.messages.errorFetching')
})
}
})
const availabilityStatus = computed(() => {
const status = form.values.availability_status
if (status === 'active_group') return { text: t('globals.terms.active'), color: 'bg-green-500' }
if (status === 'away_manual') return { text: t('globals.terms.away'), color: 'bg-yellow-500' }
if (status === 'away_and_reassigning')
return { text: t('globals.terms.awayReassigning'), color: 'bg-orange-500' }
return { text: t('globals.terms.offline'), color: 'bg-gray-400' }
})
const teamOptions = computed(() =>
teams.value.map((team) => ({ label: team.name, value: team.name }))
)
const roleOptions = computed(() =>
roles.value.map((role) => ({ label: role.name, value: role.name }))
)
const form = useForm({
validationSchema: toTypedSchema(createFormSchema(t))
})
const onSubmit = form.handleSubmit((values) => {
if (values.availability_status === 'active_group') {
values.availability_status = 'online'
}
values.teams = values.teams.map((team) => ({ name: team }))
props.submitForm(values)
})
const getInitials = (firstName, lastName) => {
if (!firstName && !lastName) return ''
if (!firstName) return lastName.charAt(0).toUpperCase()
if (!lastName) return firstName.charAt(0).toUpperCase()
return `${firstName.charAt(0).toUpperCase()}${lastName.charAt(0).toUpperCase()}`
}
const generateAPIKey = async () => {
if (!props.initialValues?.id) return
try {
isAPIKeyLoading.value = true
const response = await api.generateAPIKey(props.initialValues.id)
if (response.data) {
const responseData = response.data.data
newAPIKeyData.value = {
api_key: responseData.api_key,
api_secret: responseData.api_secret
}
apiKeyData.value.api_key = responseData.api_key
// Clear the last used timestamp since this is a new API key
apiKeyLastUsedAt.value = null
showAPIKeyDialog.value = true
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.generatedSuccessfully', {
name: t('globals.terms.apiKey')
})
})
}
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: t('globals.messages.errorGenerating', {
name: t('globals.terms.apiKey')
})
})
} finally {
isAPIKeyLoading.value = false
}
}
const regenerateAPIKey = async () => {
await generateAPIKey()
}
const revokeAPIKey = async () => {
if (!props.initialValues?.id) return
try {
isAPIKeyLoading.value = true
await api.revokeAPIKey(props.initialValues.id)
apiKeyData.value.api_key = ''
apiKeyData.value.api_secret = ''
apiKeyLastUsedAt.value = null
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.revokedSuccessfully', {
name: t('globals.terms.apiKey')
})
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: t('globals.messages.errorRevoking', {
name: t('globals.terms.apiKey')
})
})
} finally {
isAPIKeyLoading.value = false
}
}
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.copied')
})
} catch (error) {
console.error('Error copying to clipboard:', error)
}
}
const closeAPIKeyModal = () => {
showAPIKeyDialog.value = false
newAPIKeyData.value = { api_key: '', api_secret: '' }
}
watch(
() => props.initialValues,
(newValues) => {
// Hack.
if (Object.keys(newValues).length > 0) {
setTimeout(() => {
if (
newValues.availability_status === 'away' ||
newValues.availability_status === 'offline' ||
newValues.availability_status === 'online'
) {
newValues.availability_status = 'active_group'
}
form.setValues(newValues)
form.setFieldValue(
'teams',
newValues.teams.map((team) => team.name)
)
// Update API key data
apiKeyData.value.api_key = newValues.api_key || ''
apiKeyLastUsedAt.value = newValues.api_key_last_used_at || null
}, 0)
}
},
{ deep: true, immediate: true }
)
</script>

View File

@@ -1,12 +1,12 @@
import { h } from 'vue'
import UserDataTableDropDown from '@/features/admin/users/dataTableDropdown.vue'
import UserDataTableDropDown from '@/features/admin/agents/dataTableDropdown.vue'
import { format } from 'date-fns'
export const columns = [
export const createColumns = (t) => [
{
accessorKey: 'first_name',
header: function () {
return h('div', { class: 'text-center' }, 'First name')
return h('div', { class: 'text-center' }, t('globals.terms.firstName'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('first_name'))
@@ -15,7 +15,7 @@ export const columns = [
{
accessorKey: 'last_name',
header: function () {
return h('div', { class: 'text-center' }, 'Last name')
return h('div', { class: 'text-center' }, t('globals.terms.lastName'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('last_name'))
@@ -24,16 +24,16 @@ export const columns = [
{
accessorKey: 'enabled',
header: function () {
return h('div', { class: 'text-center' }, 'Enabled')
return h('div', { class: 'text-center' }, t('globals.terms.enabled'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('enabled') ? 'Yes' : 'No')
return h('div', { class: 'text-center font-medium' }, row.getValue('enabled') ? t('globals.messages.yes') : t('globals.messages.no'))
}
},
{
accessorKey: 'email',
header: function () {
return h('div', { class: 'text-center' }, 'Email')
return h('div', { class: 'text-center' }, t('globals.terms.email'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('email'))
@@ -42,7 +42,7 @@ export const columns = [
{
accessorKey: 'created_at',
header: function () {
return h('div', { class: 'text-center' }, 'Created at')
return h('div', { class: 'text-center' }, t('globals.terms.createdAt'))
},
cell: function ({ row }) {
return h(
@@ -55,7 +55,7 @@ export const columns = [
{
accessorKey: 'updated_at',
header: function () {
return h('div', { class: 'text-center' }, 'Updated at')
return h('div', { class: 'text-center' }, t('globals.terms.updatedAt'))
},
cell: function ({ row }) {
return h(

View File

@@ -2,27 +2,31 @@
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="w-8 h-8 p-0">
<span class="sr-only">Open menu</span>
<span class="sr-only"></span>
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editUser(props.user.id)">Edit</DropdownMenuItem>
<DropdownMenuItem @click="() => (alertOpen = true)">Delete</DropdownMenuItem>
<DropdownMenuItem @click="editUser(props.user.id)">{{
$t('globals.messages.edit')
}}</DropdownMenuItem>
<DropdownMenuItem @click="() => (alertOpen = true)">{{
$t('globals.messages.delete')
}}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog :open="alertOpen" @update:open="alertOpen = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete User</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the user.
</AlertDialogDescription>
<AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription>{{ $t('admin.agent.deleteConfirmation') }}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction @click="handleDelete">Delete</AlertDialogAction>
<AlertDialogCancel>{{ $t('globals.messages.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="handleDelete">{{
$t('globals.messages.delete')
}}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
@@ -36,7 +40,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
} from '@shared-ui/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
@@ -46,13 +50,13 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
} from '@shared-ui/components/ui/alert-dialog'
import { Button } from '@shared-ui/components/ui/button'
import { useRouter } from 'vue-router'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api'
import { useEmitter } from '../../../composables/useEmitter'
import { handleHTTPError } from '../../../utils/http'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import api from '../../../api'
const alertOpen = ref(false)
const emit = useEmitter()
@@ -69,7 +73,7 @@ const props = defineProps({
})
function editUser(id) {
router.push({ path: `/admin/teams/users/${id}/edit` })
router.push({ path: `/admin/teams/agents/${id}/edit` })
}
async function handleDelete() {
@@ -79,7 +83,6 @@ async function handleDelete() {
emitRefreshUserList()
} catch (error) {
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
@@ -88,7 +91,7 @@ async function handleDelete() {
const emitRefreshUserList = () => {
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
model: 'user'
model: 'agent'
})
}
</script>

View File

@@ -0,0 +1,54 @@
import * as z from 'zod'
export const createFormSchema = (t) => z.object({
first_name: z
.string({
required_error: t('globals.messages.required'),
})
.min(2, {
message: t('form.error.minmax', {
min: 2,
max: 50,
})
})
.max(50, {
message: t('form.error.minmax', {
min: 2,
max: 50,
})
}),
last_name: z.string().optional(),
email: z
.string({
required_error: t('globals.messages.required'),
})
.email({
message: t('globals.messages.invalidEmailAddress'),
}),
send_welcome_email: z.boolean().optional(),
teams: z.array(z.string()).default([]),
roles: z.array(z.string()).min(1, t('globals.messages.selectAtLeastOne', {
name: t('globals.terms.role')
})),
new_password: z
.string()
.min(10, {
message: t('globals.messages.strongPassword', { min: 10, max: 72 })
})
.max(72, {
message: t('globals.messages.strongPassword', { min: 10, max: 72 })
})
.refine(val => /[a-z]/.test(val), t('globals.messages.strongPassword', { min: 10, max: 72 }))
.refine(val => /[A-Z]/.test(val), t('globals.messages.strongPassword', { min: 10, max: 72 }))
.refine(val => /\d/.test(val), t('globals.messages.strongPassword', { min: 10, max: 72 }))
.refine(val => /[\W_]/.test(val), t('globals.messages.strongPassword', { min: 10, max: 72 }))
.optional(),
enabled: z.boolean().optional().default(true),
availability_status: z.string().optional().default('offline'),
})

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