Compare commits

...

457 Commits

Author SHA1 Message Date
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
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
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
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
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
415 changed files with 26285 additions and 8912 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

@@ -38,7 +38,7 @@ frontend-build: install-deps
.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.
.PHONY: run-frontend
@@ -52,8 +52,8 @@ run-frontend:
.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 +71,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)
}

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)
}
@@ -482,7 +476,7 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
// 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))
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("conversation.resolveWithoutAssignee"), nil))
}
// Update conversation status.
@@ -490,9 +484,6 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
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 +497,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.UpdateCustomAttributes(conversation.ContactID, attributes); 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 +599,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 +607,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 +614,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 +622,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 +635,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 +643,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 +658,113 @@ 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),
SourceChannelID: null.StringFrom(req.Email),
FirstName: req.FirstName,
LastName: req.LastName,
InboxID: req.InboxID,
}
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,
contact.ContactChannelID,
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 /**sender_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)
}

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

@@ -12,18 +12,17 @@ import (
"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 +37,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 +61,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 +83,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 +97,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 +134,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 +152,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,22 +181,33 @@ 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"))
// WebSocket.
g.GET("/ws", auth(func(r *fastglue.Request) error {
@@ -186,6 +220,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
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))
@@ -215,7 +250,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,7 +258,7 @@ 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
}
@@ -237,7 +272,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 +297,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.

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,6 +1,7 @@
package main
import (
"net/mail"
"strconv"
"github.com/abhinavxd/libredesk/internal/envelope"
@@ -9,6 +10,7 @@ import (
"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 +20,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 +28,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 +78,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 +115,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 +144,28 @@ 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.
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)
}
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,6 +24,7 @@ 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"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
@@ -33,6 +35,7 @@ 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/report"
"github.com/abhinavxd/libredesk/internal/role"
"github.com/abhinavxd/libredesk/internal/search"
"github.com/abhinavxd/libredesk/internal/setting"
@@ -42,6 +45,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"
@@ -217,8 +221,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 +236,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 +263,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 +277,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 +291,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 +305,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 +324,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 +337,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 +408,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 +422,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
@@ -452,6 +463,7 @@ func initMedia(db *sqlx.DB) *media.Manager {
Store: store,
Lo: lo,
DB: db,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing media: %v", err)
@@ -460,9 +472,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 +482,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 +509,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 +531,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 +557,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"),
@@ -560,10 +573,10 @@ func initEmailInbox(inboxRecord imodels.Inbox, store inbox.MessageStore) (inbox.
}
// 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)
default:
return nil, fmt.Errorf("unknown inbox channel: %s", inboxR.Channel)
}
@@ -576,8 +589,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 +603,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 +612,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 +620,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 +668,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 +683,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 +731,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 +745,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 +758,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 +771,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 +785,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 +798,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")

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

@@ -4,22 +4,44 @@ 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)
}
// Check if user is enabled.
if !user.Enabled {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.accountDisabled"), nil))
}
// Set user availability status to online.
if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil {
return sendErrorEnvelope(r, err)
@@ -33,25 +55,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"
@@ -36,6 +41,7 @@ import (
"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"
@@ -57,33 +63,37 @@ 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
// Global state that stores data on an available app update.
update *AppUpdate
@@ -106,7 +116,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 +161,93 @@ 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)
)
automation.SetConversationStore(conversation)
startInboxes(ctx, inbox, conversation)
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,
}
app.consts.Store(constants)
@@ -235,7 +261,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 +276,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 +291,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,7 +137,7 @@ 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)
}
@@ -150,13 +150,13 @@ func handleServeMedia(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Fetch media from DB.
media, err := app.media.GetByUUID(strings.TrimPrefix(uuid, thumbPrefix))
media, err := app.media.Get(0, strings.TrimPrefix(uuid, thumbPrefix))
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -164,7 +164,6 @@ func handleServeMedia(r *fastglue.Request) error {
// 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)
}
@@ -181,7 +180,7 @@ func handleServeMedia(r *fastglue.Request) error {
}
if !allowed {
return r.SendErrorEnvelope(http.StatusUnauthorized, "Permission denied", nil, envelope.PermissionError)
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.UnauthorizedError)
}
consts := app.consts.Load().(*constants)
switch consts.UploadProvider {
@@ -193,6 +192,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,6 @@ import (
"strconv"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/automation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
medModels "github.com/abhinavxd/libredesk/internal/media/models"
"github.com/valyala/fasthttp"
@@ -15,6 +14,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 +30,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)
}
@@ -48,11 +48,14 @@ func handleGetMessages(r *fastglue.Request) error {
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)
}
// Redact CSAT survey link
messages[i].CensorCSATContent()
}
return r.SendEnvelope(envelope.PageResults{
Total: total,
Results: messages,
@@ -70,7 +73,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)
}
@@ -105,7 +108,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 +119,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 +131,46 @@ 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)
}
// 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, 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,25 @@ 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)
// 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 +121,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 +173,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 +192,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 +207,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,10 @@ 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},
}
// 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
}

View File

@@ -1,75 +1,124 @@
# 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"

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

View File

@@ -8,7 +8,6 @@
"baseColor": "gray",
"cssVariables": true
},
"framework": "vite",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"

View File

@@ -3,7 +3,7 @@ import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}',
baseUrl: 'http://localhost:4173'
baseUrl: 'http://localhost:9000'
},
component: {
specPattern: 'src/**/__tests__/*.{cy,spec}.{js,ts,jsx,tsx}',

View File

@@ -1,8 +0,0 @@
// https://on.cypress.io/api
describe('My First Test', () => {
it('visits the app root url', () => {
cy.visit('/')
cy.contains('h1', 'You did it!')
})
})

View File

@@ -0,0 +1,140 @@
// cypress/e2e/login.cy.js
describe('Login Component', () => {
beforeEach(() => {
// Visit the login page
cy.visit('/')
// Mock the API response for OIDC providers
cy.intercept('GET', '**/api/v1/oidc/enabled', {
statusCode: 200,
body: {
data: [
{
id: 1,
name: 'Google',
logo_url: 'https://example.com/google-logo.png',
disabled: false
}
]
}
}).as('getOIDCProviders')
})
it('should display login form', () => {
cy.contains('h3', 'Libredesk').should('be.visible')
cy.contains('p', 'Sign in to your account').should('be.visible')
cy.get('#email').should('be.visible')
cy.get('#password').should('be.visible')
cy.contains('a', 'Forgot password?').should('be.visible')
cy.contains('button', 'Sign in').should('be.visible')
})
it('should display OIDC providers when loaded', () => {
cy.wait('@getOIDCProviders')
cy.contains('button', 'Google').should('be.visible')
cy.contains('div', 'Or continue with').should('be.visible')
})
it('should show error for invalid login attempt', () => {
// Mock failed login API call
cy.intercept('POST', '**/api/v1/auth/login', {
statusCode: 401,
body: {
message: 'Invalid credentials'
}
}).as('loginFailure')
// Enter System username and wrong password
cy.get('#email').type('System')
cy.get('#password').type('WrongPassword')
// Submit form
cy.contains('button', 'Sign in').click()
// Wait for API call
cy.wait('@loginFailure')
// Verify error message appears
cy.contains('Invalid credentials').should('be.visible')
})
it('should login successfully with correct credentials', () => {
// Mock successful login API call
cy.intercept('POST', '**/api/v1/auth/login', {
statusCode: 200,
body: {
data: {
id: 1,
email: 'System',
name: 'System User'
}
}
}).as('loginSuccess')
// Enter System username and correct password
cy.get('#email').type('System')
cy.get('#password').type('StrongPass!123')
// Submit form
cy.contains('button', 'Sign in').click()
// Wait for API call
cy.wait('@loginSuccess')
// Verify redirection to inboxes page
cy.url().should('include', '/inboxes/assigned')
})
it('should validate email format', () => {
// Enter invalid email and a password
cy.get('#email').type('invalid-email')
cy.get('#password').type('password')
// Submit form
cy.contains('button', 'Sign in').click()
// Check for validation error (matching the error message with a trailing period)
cy.contains('Invalid email address').should('be.visible')
})
it('should validate empty password', () => {
// Enter email but no password
cy.get('#email').type('valid@example.com')
// Submit form
cy.contains('button', 'Sign in').click()
// Check for validation error (matching the error message with a trailing period)
cy.contains('Password cannot be empty').should('be.visible')
})
it('should show loading state during login', () => {
// Mock slow API response
cy.intercept('POST', '**/api/v1/auth/login', {
statusCode: 200,
body: {
data: {
id: 1,
email: 'System',
name: 'System User'
}
},
delay: 1000
}).as('slowLogin')
// Enter credentials
cy.get('#email').type('System')
cy.get('#password').type('StrongPass!123')
// Submit form
cy.contains('button', 'Sign in').click()
// Check if loading state is shown
cy.contains('Logging in...').should('be.visible')
cy.get('.animate-spin').should('be.visible')
// Wait for API call to finish
cy.wait('@slowLogin')
})
})

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

@@ -1,13 +1,16 @@
{
"name": "libredesk",
"version": "0.3.0",
"version": "0.6.0-alpha",
"private": true,
"type": "module",
"scripts": {
"dev": "pnpm exec vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:e2e": "start-server-and-test preview http://localhost:4173 'cypress run --e2e'",
"test:e2e:ci": "cypress run --e2e --headless",
"test:e2e:dev": "start-server-and-test 'vite dev --port 4173' http://localhost:4173 'cypress open --e2e'",
"test:unit": "cypress run --component",
"test:unit:dev": "cypress open --component",
@@ -15,34 +18,40 @@
"format": "prettier --write src/"
},
"dependencies": {
"@codemirror/lang-html": "^6.4.9",
"@codemirror/theme-one-dark": "^6.1.3",
"@formkit/auto-animate": "^0.8.2",
"@internationalized/date": "^3.5.5",
"@radix-icons/vue": "^1.0.0",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/vue-table": "^8.19.2",
"@tiptap/extension-image": "^2.5.9",
"@tiptap/extension-link": "^2.9.1",
"@tiptap/extension-link": "^2.11.2",
"@tiptap/extension-placeholder": "^2.4.0",
"@tiptap/extension-table": "^2.11.5",
"@tiptap/extension-table-cell": "^2.11.5",
"@tiptap/extension-table-header": "^2.11.5",
"@tiptap/extension-table-row": "^2.11.5",
"@tiptap/pm": "^2.4.0",
"@tiptap/starter-kit": "^2.4.0",
"@tiptap/vue-3": "^2.4.0",
"@unovis/ts": "^1.4.4",
"@unovis/vue": "^1.4.4",
"@vee-validate/zod": "^4.13.2",
"@vueup/vue-quill": "^1.2.0",
"@vee-validate/zod": "^4.15.0",
"@vueuse/core": "^12.4.0",
"axios": "^1.7.9",
"axios": "^1.8.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"codeflask": "^1.4.1",
"codemirror": "^6.0.2",
"date-fns": "^3.6.0",
"lucide-vue-next": "^0.378.0",
"mitt": "^3.0.1",
"pinia": "^2.1.7",
"qs": "^6.12.1",
"radix-vue": "latest",
"radix-vue": "^1.9.17",
"reka-ui": "^2.2.0",
"tailwind-merge": "^2.3.0",
"vee-validate": "^4.13.2",
"vee-validate": "^4.15.0",
"vue": "^3.4.37",
"vue-dompurify-html": "^5.2.0",
"vue-i18n": "9",
@@ -52,7 +61,7 @@
"vue-sonner": "^1.3.0",
"vue3-emoji-picker": "^1.1.8",
"vuedraggable": "^4.1.0",
"zod": "^3.23.8"
"zod": "^3.24.1"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.3.3",
@@ -67,9 +76,10 @@
"prettier": "^3.0.3",
"sass": "^1.70.0",
"start-server-and-test": "^2.0.3",
"tailwindcss": "latest",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"vite": "^5.4.9"
"vite": "^5.4.19",
"vitest": "^3.2.2"
},
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
}
}

1224
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex w-full h-screen">
<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">
@@ -8,25 +8,64 @@
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="route.path.startsWith('/inboxes')">
<router-link :to="{ name: 'inboxes' }">
<Inbox />
</router-link>
</SidebarMenuButton>
<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.hasAdminTabPermissions">
<SidebarMenuButton asChild :isActive="route.path.startsWith('/admin')">
<router-link :to="{ name: 'admin' }">
<Shield />
</router-link>
</SidebarMenuButton>
<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">
<SidebarMenuButton asChild :isActive="route.path.startsWith('/reports')">
<router-link :to="{ name: 'reports' }">
<FileLineChart />
</router-link>
</SidebarMenuButton>
<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>
@@ -46,6 +85,7 @@
@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 -->
@@ -64,6 +104,9 @@
<!-- Command box -->
<Command />
<!-- Create conversation dialog -->
<CreateConversation v-model="openCreateConversationDialog" v-if="openCreateConversationDialog" />
</template>
<script setup>
@@ -81,6 +124,7 @@ 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'
@@ -89,7 +133,9 @@ import api from '@/api'
import { toast as sooner } from 'vue-sonner'
import Sidebar from '@/components/sidebar/Sidebar.vue'
import Command from '@/features/command/CommandBox.vue'
import { Inbox, Shield, FileLineChart } from 'lucide-vue-next'
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,
@@ -102,6 +148,7 @@ import {
SidebarMenuItem,
SidebarProvider
} from '@/components/ui/sidebar'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import SidebarNavUser from '@/components/sidebar/SidebarNavUser.vue'
const route = useRoute()
@@ -114,9 +161,12 @@ 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()
@@ -127,7 +177,7 @@ onMounted(() => {
initStores()
})
// initialize data stores
// Initialize data stores
const initStores = async () => {
if (!userStore.userID) {
await userStore.getCurrentUser()
@@ -141,7 +191,8 @@ const initStores = async () => {
inboxStore.fetchInboxes(),
slaStore.fetchSlas(),
macroStore.loadMacros(),
tagStore.fetchTags()
tagStore.fetchTags(),
customAttributeStore.fetchCustomAttributes()
])
}
@@ -155,12 +206,12 @@ const deleteView = async (view) => {
await api.deleteView(view.id)
emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'view' })
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Success',
description: 'View deleted successfully'
description: t('globals.messages.deletedSuccessfully', {
name: t('globals.terms.view')
})
})
} catch (err) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(err).message
})
@@ -173,7 +224,6 @@ const getUserViews = async () => {
userViews.value = response.data.data
} catch (err) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(err).message
})

View File

@@ -1,9 +1,7 @@
<template>
<TooltipProvider :delay-duration="150">
<div class="!font-jakarta">
<Toaster class="pointer-events-auto" position="top-center" richColors />
<RouterView />
</div>
<Toaster class="pointer-events-auto" position="top-center" richColors />
<RouterView />
</TooltipProvider>
</template>

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

@@ -13,15 +13,95 @@
min-height: 100%;
overflow: hidden;
margin: 0;
@apply bg-background text-foreground;
}
@media (max-width: 768px) {
@media (max-width: 768px) {
html,
body {
overflow-x: auto;
}
}
}
// Theme.
@layer base {
* {
@apply border-border;
}
.native-html {
p {
margin-bottom: 0.5rem;
}
ul {
list-style-type: disc;
margin-left: 1.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
ol {
list-style-type: decimal;
margin-left: 1.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
li {
padding-left: 0.25rem;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: 1.25rem;
font-weight: 700;
}
a {
color: #0066cc;
cursor: pointer;
&:hover {
color: #003d7a;
}
}
}
:root {
--sidebar-background: 0 0% 100%;
--sidebar-foreground: 240 5.9% 10%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
:root {
--vis-tooltip-background-color: none !important;
--vis-tooltip-border-color: none !important;
--vis-tooltip-text-color: none !important;
--vis-tooltip-shadow-color: none !important;
--vis-tooltip-backdrop-filter: none !important;
--vis-tooltip-padding: none !important;
--vis-primary-color: var(--primary);
--vis-secondary-color: 160 81% 40%;
--vis-text-color: var(--muted-foreground);
}
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
@@ -50,11 +130,11 @@
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.75rem;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--background: 240 5.9% 10%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
@@ -84,72 +164,8 @@
}
}
@layer base {
:root {
--vis-tooltip-background-color: none !important;
--vis-tooltip-border-color: none !important;
--vis-tooltip-text-color: none !important;
--vis-tooltip-shadow-color: none !important;
--vis-tooltip-backdrop-filter: none !important;
--vis-tooltip-padding: none !important;
--vis-primary-color: var(--primary);
--vis-secondary-color: 160 81% 40%;
--vis-text-color: var(--muted-foreground);
}
}
// Shake animation
@keyframes shake {
0% {
transform: translateX(0);
}
15% {
transform: translateX(-5px);
}
25% {
transform: translateX(5px);
}
35% {
transform: translateX(-5px);
}
45% {
transform: translateX(5px);
}
55% {
transform: translateX(-5px);
}
65% {
transform: translateX(5px);
}
75% {
transform: translateX(-5px);
}
85% {
transform: translateX(5px);
}
95% {
transform: translateX(-5px);
}
100% {
transform: translateX(0);
}
}
.animate-shake {
animation: shake 0.5s infinite;
}
.message-bubble {
@apply flex
flex-col
px-4
pt-2
pb-3
min-w-[30%] max-w-[70%]
border
overflow-x-auto
rounded-xl;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
@apply flex flex-col px-4 pt-2 pb-3 w-fit min-w-[30%] max-w-full border overflow-x-auto rounded shadow-sm;
table {
width: 100% !important;
table-layout: fixed !important;
@@ -165,7 +181,7 @@
}
.box {
@apply border shadow rounded-lg;
@apply border shadow rounded;
}
// Scrollbar start
@@ -191,85 +207,6 @@
}
// End Scrollbar
.code-editor {
@apply rounded-md border shadow h-[65vh] min-h-[250px] w-full relative;
}
.ql-container {
margin: 0 !important;
}
.ql-container .ql-editor {
height: 300px !important;
border-radius: var(--radius) !important;
@apply rounded-lg rounded-t-none;
}
.ql-toolbar {
@apply rounded-t-lg;
}
.blinking-dot {
display: inline-block;
width: 8px;
height: 8px;
background-color: red;
border-radius: 50%;
animation: blink 2s infinite;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
// Sidebar start
@layer base {
:root {
--sidebar-background: 0 0% 96%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
a[data-active='true'] {
background-color: hsl(var(--sidebar-background)) !important;
color: hsl(var(--sidebar-accent-foreground)) !important;
font-weight: 500;
transition:
background-color 0.2s,
color 0.2s;
}
a[data-active='false']:hover {
background-color: hsl(var(--sidebar-accent)) !important;
color: hsl(var(--sidebar-accent-foreground)) !important;
font-weight: 500;
transition:
background-color 0.2s,
color 0.2s;
}
// Sidebar end
.show-quoted-text {
blockquote {
@apply block;
@@ -282,37 +219,13 @@ a[data-active='false']:hover {
}
}
.dot-loader {
display: inline-flex;
align-items: center;
}
.dot {
width: 4px;
height: 4px;
border-radius: 50%;
background-color: currentColor;
margin: 0 2px;
animation: dot-flashing 1s infinite linear alternate;
}
.dot:nth-child(2) {
animation-delay: 0.2s;
}
.dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes dot-flashing {
0% {
opacity: 0.2;
}
100% {
opacity: 1;
}
}
[data-radix-popper-content-wrapper] {
z-index: 9999 !important;
}
// Components
@layer components {
.link-style {
@apply text-blue-500 hover:underline;
}
}

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 '@/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 '@/components/ui/avatar'
import ComboBox from '@/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,
@@ -45,20 +53,30 @@ import {
TableRow
} from '@/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

@@ -1,10 +1,13 @@
<template>
<div ref="codeEditor" id="code-editor" class="code-editor" />
<div ref="codeEditor" @click="editorView?.focus()" class="w-full h-[28rem] border rounded-md" />
</template>
<script setup>
import { ref, onMounted, watch, nextTick } from 'vue'
import CodeFlask from 'codeflask'
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: '' },
@@ -13,45 +16,38 @@ const props = defineProps({
})
const emit = defineEmits(['update:modelValue'])
const codeEditor = ref(null)
const data = ref('')
const flask = ref(null)
let editorView = null
const codeEditor = useTemplateRef('codeEditor')
const initCodeEditor = (body) => {
const el = document.createElement('code-flask')
el.attachShadow({ mode: 'open' })
el.shadowRoot.innerHTML = `
<style>
.codeflask .codeflask__flatten {
font-size: 15px;
white-space: pre-wrap;
word-break: break-word;
}
.codeflask .codeflask__lines { background: #fafafa; z-index: 10; }
.codeflask .token.tag { font-weight: bold; }
.codeflask .token.attr-name { color: #111; }
.codeflask .token.attr-value { color: #000 !important; }
</style>
<div id="area"></div>
`
codeEditor.value.appendChild(el)
const isDark = useColorMode().value === 'dark'
flask.value = new CodeFlask(el.shadowRoot.getElementById('area'), {
language: props.language,
lineNumbers: false,
styleParent: el.shadowRoot,
readonly: props.disabled
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
})
flask.value.onUpdate((v) => {
emit('update:modelValue', v)
data.value = v
})
flask.value.updateCode(body)
nextTick(() => {
document.querySelector('code-flask').shadowRoot.querySelector('textarea').focus()
editorView?.focus()
})
}
@@ -61,7 +57,9 @@ onMounted(() => {
watch(() => props.modelValue, (newVal) => {
if (newVal !== data.value) {
flask.value.updateCode(newVal)
editorView?.dispatch({
changes: { from: 0, to: editorView.state.doc.length, insert: newVal }
})
}
})
</script>

View File

@@ -0,0 +1,309 @@
<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 '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/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'
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)
// 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')
return true
}
}
},
// To update state when user types.
onUpdate: ({ editor }) => {
isInternalUpdate.value = true
htmlContent.value = editor.getHTML()
textContent.value = editor.getText()
isInternalUpdate.value = false
}
})
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 '@/components/ui/select'
import { Plus } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useI18n } from 'vue-i18n'
import CloseButton from '@/components/button/CloseButton.vue'
import SelectComboBox from '@/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>

View File

@@ -1,6 +1,11 @@
<script setup>
import { adminNavItems, reportsNavItems, accountNavItems } from '@/constants/navigation'
import { RouterLink, useRoute } from 'vue-router'
import {
adminNavItems,
reportsNavItems,
accountNavItems,
contactNavItems
} from '@/constants/navigation'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import {
Sidebar,
@@ -9,7 +14,6 @@ import {
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarSeparator,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
@@ -18,14 +22,15 @@ import {
SidebarProvider,
SidebarRail
} from '@/components/ui/sidebar'
import { useAppSettingsStore } from '@/stores/appSettings'
import {
ChevronRight,
EllipsisVertical,
User,
Search,
Plus,
CircleUserRound,
UserSearch,
UsersRound,
Search
CircleDashed,
List
} from 'lucide-vue-next'
import {
DropdownMenu,
@@ -35,16 +40,30 @@ import {
} from '@/components/ui/dropdown-menu'
import { filterNavItems } from '@/utils/nav-permissions'
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
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 emit = defineEmits(['createView', 'editView', 'deleteView'])
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')
@@ -58,18 +77,86 @@ 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))
const isActiveParent = (parentHref) => {
return route.path.startsWith(parentHref)
}
const isInboxRoute = (path) => {
return path.startsWith('/inboxes')
// 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>
@@ -78,6 +165,43 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
: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="
@@ -89,22 +213,21 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton :isActive="isActiveParent('/reports/overview')" asChild>
<div>
<span class="font-semibold text-xl">Reports</span>
</div>
</SidebarMenuButton>
<div class="px-1">
<span class="font-semibold text-xl">
{{ t('globals.terms.report', 2) }}
</span>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem v-for="item in filteredReportsNavItems" :key="item.title">
<SidebarMenuItem v-for="item in filteredReportsNavItems" :key="item.titleKey">
<SidebarMenuButton :isActive="isActiveParent(item.href)" asChild>
<router-link :to="item.href">
<span>{{ item.title }}</span>
<span>{{ t(item.titleKey) }}</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -121,37 +244,41 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton :isActive="isActiveParent('/admin')" asChild>
<div>
<span class="font-semibold text-xl">Admin</span>
<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>
</SidebarMenuButton>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem v-for="item in filteredAdminNavItems" :key="item.title">
<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>{{ item.title }}</span>
<span>{{ t(item.titleKey) }}</span>
</router-link>
</SidebarMenuButton>
<Collapsible
v-else
class="group/collapsible"
:default-open="isActiveParent(item.href)"
:open="openAdminCollapsible === item.titleKey"
@update:open="toggleAdminCollapsible(item.titleKey)"
>
<CollapsibleTrigger as-child>
<SidebarMenuButton :isActive="isActiveParent(item.href)">
<span>{{ item.title }}</span>
<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"
/>
@@ -159,10 +286,10 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem v-for="child in item.children" :key="child.title">
<SidebarMenuSubItem v-for="child in item.children" :key="child.titleKey">
<SidebarMenuButton size="sm" :isActive="isActiveParent(child.href)" asChild>
<router-link :to="child.href">
<span>{{ child.title }}</span>
<span>{{ t(child.titleKey) }}</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuSubItem>
@@ -183,22 +310,21 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton :isActive="isActiveParent('/account/profile')" asChild>
<div>
<span class="font-semibold text-xl">Account</span>
</div>
</SidebarMenuButton>
<div class="px-1">
<span class="font-semibold text-xl">
{{ t('globals.terms.account') }}
</span>
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem v-for="item in accountNavItems" :key="item.title">
<SidebarMenuItem v-for="item in accountNavItems" :key="item.titleKey">
<SidebarMenuButton :isActive="isActiveParent(item.href)" asChild>
<router-link :to="item.href">
<span>{{ item.title }}</span>
<span>{{ t(item.titleKey) }}</span>
</router-link>
</SidebarMenuButton>
<SidebarMenuAction>
@@ -218,64 +344,83 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<div class="flex items-center justify-between w-full">
<div class="font-semibold text-xl">Inbox</div>
<div class="ml-auto">
<router-link :to="{ name: 'search' }">
<div class="flex items-center bg-accent p-2 rounded-full">
<Search
class="transition-transform duration-200 hover:scale-110 cursor-pointer"
size="15"
stroke-width="2.5"
/>
</div>
</router-link>
</div>
<div class="flex items-center justify-between w-full px-1">
<div class="font-semibold text-xl">
<span>{{ t('globals.terms.inbox') }}</span>
</div>
</SidebarMenuButton>
<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>
<SidebarSeparator />
<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')">
<router-link :to="{ name: 'inbox', params: { type: 'assigned' } }">
<CircleUserRound />
<span>My inbox</span>
</router-link>
<a href="#" @click.prevent="navigateToInbox('assigned')">
<User />
<span>{{ t('globals.terms.myInbox') }}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/unassigned')">
<router-link :to="{ name: 'inbox', params: { type: 'unassigned' } }">
<UserSearch />
<span>Unassigned</span>
</router-link>
<a href="#" @click.prevent="navigateToInbox('unassigned')">
<CircleDashed />
<span>
{{ t('globals.terms.unassigned') }}
</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/all')">
<router-link :to="{ name: 'inbox', params: { type: 'all' } }">
<UsersRound />
<span>All</span>
</router-link>
<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">
<Collapsible
defaultOpen
class="group/collapsible"
v-if="userTeams.length"
v-model:open="teamInboxOpen"
>
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<SidebarMenuButton asChild>
<router-link to="#">
<!-- <Users /> -->
<span>Team inboxes</span>
<span>
{{ t('globals.terms.teamInbox', 2) }}
</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
@@ -290,9 +435,9 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
:is-active="route.params.teamID == team.id"
asChild
>
<router-link :to="{ name: 'team-inbox', params: { teamID: team.id } }">
<a href="#" @click.prevent="navigateToTeamInbox(team.id)">
{{ team.emoji }}<span>{{ team.name }}</span>
</router-link>
</a>
</SidebarMenuButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
@@ -301,31 +446,30 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
</Collapsible>
<!-- Views -->
<Collapsible class="group/collapsible" defaultOpen>
<Collapsible class="group/collapsible" defaultOpen v-model:open="viewInboxOpen">
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<CollapsibleTrigger asChild>
<SidebarMenuButton asChild>
<router-link to="#">
<router-link to="#" class="group/item !p-2">
<!-- <SlidersHorizontal /> -->
<span>Views</span>
<span>
{{ t('globals.terms.view', 2) }}
</span>
<div>
<Plus
size="18"
@click.stop="openCreateViewDialog"
class="rounded-lg cursor-pointer opacity-0 transition-all duration-200 group-hover: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"
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>
<SidebarMenuAction>
<ChevronRight
class="transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
v-if="userViews.length"
/>
</SidebarMenuAction>
<CollapsibleContent>
<SidebarMenuSub v-for="view in userViews" :key="view.id">
<SidebarMenuSubItem>
@@ -334,26 +478,25 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
:isActive="route.params.viewID == view.id"
asChild
>
<router-link :to="{ name: 'view-inbox', params: { viewID: view.id } }">
<span class="break-all w-24">{{ view.name }}</span>
</router-link>
<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>
<SidebarMenuAction>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<EllipsisVertical />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="() => editView(view)">
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem @click="() => deleteView(view)">
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuAction>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>

View File

@@ -2,19 +2,22 @@
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0"
size="md"
class="p-0"
>
<Avatar class="h-8 w-8 rounded-lg relative overflow-visible">
<AvatarImage :src="userStore.avatar" alt="" class="rounded-lg"/>
<AvatarFallback class="rounded-lg">
<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',
'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>
@@ -27,50 +30,86 @@
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
class="w-[--radix-dropdown-menu-trigger-width] min-w-56"
side="bottom"
:side-offset="4"
>
<DropdownMenuLabel class="p-0 font-normal space-y-1">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="userStore.avatar" alt="Abhinav" />
<AvatarFallback class="rounded-lg">
<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="grid flex-1 text-left text-sm leading-tight">
<div class="flex-1 flex flex-col leading-tight">
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<span class="truncate text-xs">{{ userStore.email }}</span>
<span class="truncate text-xs text-muted-foreground">{{ userStore.email }}</span>
</div>
</div>
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm justify-between">
<span class="text-muted-foreground">Away</span>
<Switch
:checked="userStore.user.availability_status === 'away' || userStore.user.availability_status === 'away_manual'"
@update:checked="(val) => userStore.updateUserAvailability(val ? 'away' : 'online')"
/>
<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>
<router-link to="/account" class="flex items-center">
<CircleUserRound size="18" class="mr-2" />
Account
</router-link>
<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" />
Log out
{{ t('navigation.logout') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import {
DropdownMenu,
DropdownMenuContent,
@@ -83,9 +122,16 @@ import {
import { SidebarMenuButton } from '@/components/ui/sidebar'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Switch } from '@/components/ui/switch'
import { ChevronsUpDown, CircleUserRound, LogOut } from 'lucide-vue-next'
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'

View File

@@ -1,53 +1,112 @@
<template>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th v-for="(header, index) in headers" :key="index" scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{{ header }}
</th>
<th scope="col" class="relative px-6 py-3"></th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="(item, index) in data" :key="index">
<td v-for="key in keys" :key="key" class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{{ item[key] }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<Button size="xs" variant="ghost" @click.prevent="deleteItem(item)">
<Trash2 class="h-4 w-4" />
</Button>
</td>
</tr>
</tbody>
</table>
<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 { defineProps, defineEmits } from 'vue';
import { Trash2 } from 'lucide-vue-next'
import { defineEmits } from 'vue'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
defineProps({
headers: {
type: Array,
required: true,
default: () => []
},
keys: {
type: Array,
required: true,
default: () => []
},
data: {
type: Array,
required: true,
default: () => []
}
});
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']);
const emit = defineEmits(['deleteItem'])
function deleteItem(item) {
emit('deleteItem', item);
emit('deleteItem', item)
}
</script>
</script>

View File

@@ -2,7 +2,7 @@
import { AvatarImage } from 'radix-vue'
const props = defineProps({
src: { type: String, required: true },
src: { type: String, required: false, default: '' },
asChild: { type: Boolean, required: false },
as: { type: null, required: false }
})

View File

@@ -0,0 +1,59 @@
<template>
<div class="relative group w-28 h-28 cursor-pointer" @click="triggerFileInput">
<Avatar class="size-28">
<AvatarImage :src="src || ''" />
<AvatarFallback>{{ initials }}</AvatarFallback>
</Avatar>
<!-- Hover Overlay -->
<div
class="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer rounded-full"
>
<span class="text-white font-semibold">{{ label }}</span>
</div>
<!-- Delete Icon -->
<X
class="absolute top-1 right-1 rounded-full p-1 shadow-md z-10 opacity-0 group-hover:opacity-100 transition-opacity"
size="20"
@click.stop="emit('remove')"
v-if="src"
/>
<!-- File Input -->
<input
ref="fileInput"
type="file"
class="hidden"
accept="image/png,image/jpeg,image/jpg"
@change="handleChange"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { X } from 'lucide-vue-next'
defineProps({
src: String,
initials: String,
label: {
type: String,
default: 'Upload'
}
})
const emit = defineEmits(['upload', 'remove'])
const fileInput = ref(null)
function triggerFileInput() {
fileInput.value?.click()
}
function handleChange(e) {
const file = e.target.files[0]
if (file) emit('upload', file)
}
</script>

View File

@@ -3,6 +3,7 @@ import { cva } from 'class-variance-authority'
export { default as Avatar } from './Avatar.vue'
export { default as AvatarImage } from './AvatarImage.vue'
export { default as AvatarFallback } from './AvatarFallback.vue'
export { default as AvatarUpload } from './AvatarUpload.vue'
export const avatarVariant = cva(
'inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden',

View File

@@ -1,25 +1,16 @@
<script setup>
import { Primitive } from 'radix-vue'
import { buttonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { ref, computed } from 'vue'
import { DotLoader } from '@/components/ui/loader'
import { buttonVariants } from '.'
import { Loader2 } from 'lucide-vue-next'
const props = defineProps({
variant: { type: null, required: false },
size: { type: null, required: false },
class: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false, default: 'button' },
isLoading: { type: Boolean, required: false, default: false }
})
const isDisabled = ref(false)
const computedClass = computed(() => {
return cn(buttonVariants({ variant: props.variant, size: props.size }), props.class, {
'cursor-not-allowed opacity-50': props.isLoading || isDisabled.value
})
isLoading: { type: Boolean, required: false, default: false },
disabled: { type: Boolean, required: false, default: false }
})
</script>
@@ -27,10 +18,22 @@ const computedClass = computed(() => {
<Primitive
:as="as"
:as-child="asChild"
:class="computedClass"
:disabled="isLoading || isDisabled"
:class="
cn(
buttonVariants({ variant, size }),
'relative',
{ 'text-transparent': isLoading },
props.class
)
"
:disabled="isLoading || disabled"
>
<DotLoader v-if="isLoading" />
<slot v-else />
<slot />
<span
v-if="isLoading"
class="absolute inset-0 flex items-center justify-center pointer-events-none text-background"
>
<Loader2 class="h-5 w-5 animate-spin" />
</span>
</Primitive>
</template>

View File

@@ -1,31 +1,34 @@
import { cva } from 'class-variance-authority'
import { cva } from 'class-variance-authority';
export { default as Button } from './Button.vue'
export { default as Button } from './Button.vue';
export const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
default:
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
xs: 'h-7 rounded px-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9'
}
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
size: 'default',
},
},
);

View File

@@ -5,7 +5,7 @@
variant="outline"
role="combobox"
:aria-expanded="open"
class="w-full justify-between"
:class="['w-full justify-between', buttonClass]"
>
<slot name="selected" :selected="selectedItem">{{ selectedLabel }}</slot>
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
@@ -58,7 +58,11 @@ const props = defineProps({
required: true
},
placeholder: String,
defaultLabel: String
defaultLabel: String,
buttonClass: {
type: String,
default: ''
}
})
const emit = defineEmits(['select'])

View File

@@ -0,0 +1,116 @@
<template>
<div class="flex items-center gap-2">
<Select v-model="selectedDays" @update:model-value="handleFilterChange">
<SelectTrigger class="w-[140px] h-8 text-xs">
<SelectValue
:placeholder="
t('globals.messages.select', {
name: t('globals.terms.day', 2)
})
"
/>
</SelectTrigger>
<SelectContent class="text-xs">
<SelectItem value="0">{{ $t('globals.terms.today') }}</SelectItem>
<SelectItem value="1">
{{
$t('globals.messages.lastNItems', {
n: 1,
name: t('globals.terms.day', 1).toLowerCase()
})
}}
</SelectItem>
<SelectItem value="2">
{{
$t('globals.messages.lastNItems', {
n: 2,
name: t('globals.terms.day', 2).toLowerCase()
})
}}
</SelectItem>
<SelectItem value="7">
{{
$t('globals.messages.lastNItems', {
n: 7,
name: t('globals.terms.day', 2).toLowerCase()
})
}}
</SelectItem>
<SelectItem value="30">
{{
$t('globals.messages.lastNItems', {
n: 30,
name: t('globals.terms.day', 2).toLowerCase()
})
}}
</SelectItem>
<SelectItem value="90">
{{
$t('globals.messages.lastNItems', {
n: 90,
name: t('globals.terms.day', 2).toLowerCase()
})
}}
</SelectItem>
<SelectItem value="custom">
{{
$t('globals.messages.custom', {
name: t('globals.terms.day', 2).toLowerCase()
})
}}
</SelectItem>
</SelectContent>
</Select>
<div v-if="selectedDays === 'custom'" class="flex items-center gap-2">
<Input
v-model="customDaysInput"
type="number"
min="1"
max="365"
class="w-20 h-8"
@blur="handleCustomDaysChange"
@keyup.enter="handleCustomDaysChange"
/>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
const { t } = useI18n()
const emit = defineEmits(['filterChange'])
const selectedDays = ref('30')
const customDaysInput = ref('')
const handleFilterChange = (value) => {
if (value === 'custom') {
customDaysInput.value = '30'
emit('filterChange', 30)
} else {
emit('filterChange', parseInt(value))
}
}
const handleCustomDaysChange = () => {
const days = parseInt(customDaysInput.value)
if (days && days > 0 && days <= 365) {
emit('filterChange', days)
} else {
customDaysInput.value = '30'
emit('filterChange', 30)
}
}
handleFilterChange(selectedDays.value)
</script>

View File

@@ -0,0 +1 @@
export { default as DateFilter } from './DateFilter.vue'

View File

@@ -1,19 +1,19 @@
<script setup>
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
import { useVModel } from '@vueuse/core';
import { cn } from '@/lib/utils';
const props = defineProps({
defaultValue: { type: [String, Number], required: false },
modelValue: { type: [String, Number], required: false },
class: { type: null, required: false }
})
class: { type: null, required: false },
});
const emits = defineEmits(['update:modelValue'])
const emits = defineEmits(['update:modelValue']);
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue
})
defaultValue: props.defaultValue,
});
</script>
<template>
@@ -22,7 +22,7 @@ const modelValue = useVModel(props, 'modelValue', emits, {
:class="
cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
props.class
props.class,
)
"
/>

View File

@@ -1 +1 @@
export { default as Input } from './Input.vue'
export { default as Input } from './Input.vue';

View File

@@ -1,7 +1,11 @@
<template>
<span class="dot-loader">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
<span class="inline-flex items-center">
<span class="w-1 h-1 rounded-full bg-current mx-0.5 animate-dot-flashing"></span>
<span
class="w-1 h-1 rounded-full bg-current mx-0.5 animate-dot-flashing [animation-delay:0.2s]"
></span>
<span
class="w-1 h-1 rounded-full bg-current mx-0.5 animate-dot-flashing [animation-delay:0.4s]"
></span>
</span>
</template>

View File

@@ -0,0 +1,29 @@
<script setup>
import { cn } from '@/lib/utils';
import { DotsHorizontalIcon } from '@radix-icons/vue';
import { PaginationEllipsis } from 'reka-ui';
import { computed } from 'vue';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<PaginationEllipsis
v-bind="delegatedProps"
:class="cn('w-9 h-9 flex items-center justify-center', props.class)"
>
<slot>
<DotsHorizontalIcon />
</slot>
</PaginationEllipsis>
</template>

View File

@@ -0,0 +1,29 @@
<script setup>
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { ChevronsLeft } from 'lucide-vue-next';
import { PaginationFirst } from 'reka-ui';
import { computed } from 'vue';
const props = defineProps({
asChild: { type: Boolean, required: false, default: true },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<PaginationFirst v-bind="delegatedProps">
<Button :class="cn('w-9 h-9 p-0', props.class)" variant="outline">
<slot>
<ChevronsLeft />
</slot>
</Button>
</PaginationFirst>
</template>

View File

@@ -0,0 +1,29 @@
<script setup>
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { ChevronsRight } from 'lucide-vue-next';
import { PaginationLast } from 'reka-ui';
import { computed } from 'vue';
const props = defineProps({
asChild: { type: Boolean, required: false, default: true },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<PaginationLast v-bind="delegatedProps">
<Button :class="cn('w-9 h-9 p-0', props.class)" variant="outline">
<slot>
<ChevronsRight />
</slot>
</Button>
</PaginationLast>
</template>

View File

@@ -0,0 +1,29 @@
<script setup>
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { ChevronRightIcon } from '@radix-icons/vue';
import { PaginationNext } from 'reka-ui';
import { computed } from 'vue';
const props = defineProps({
asChild: { type: Boolean, required: false, default: true },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<PaginationNext v-bind="delegatedProps">
<Button :class="cn('w-9 h-9 p-0', props.class)" variant="outline">
<slot>
<ChevronRightIcon />
</slot>
</Button>
</PaginationNext>
</template>

View File

@@ -0,0 +1,29 @@
<script setup>
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { ChevronLeftIcon } from '@radix-icons/vue';
import { PaginationPrev } from 'reka-ui';
import { computed } from 'vue';
const props = defineProps({
asChild: { type: Boolean, required: false, default: true },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<PaginationPrev v-bind="delegatedProps">
<Button :class="cn('w-9 h-9 p-0', props.class)" variant="outline">
<slot>
<ChevronLeftIcon />
</slot>
</Button>
</PaginationPrev>
</template>

View File

@@ -0,0 +1,10 @@
export { default as PaginationEllipsis } from './PaginationEllipsis.vue';
export { default as PaginationFirst } from './PaginationFirst.vue';
export { default as PaginationLast } from './PaginationLast.vue';
export { default as PaginationNext } from './PaginationNext.vue';
export { default as PaginationPrev } from './PaginationPrev.vue';
export {
PaginationRoot as Pagination,
PaginationList,
PaginationListItem,
} from 'reka-ui';

View File

@@ -4,8 +4,8 @@ import { RadioGroupRoot, useForwardPropsEmits } from 'radix-vue'
import { cn } from '@/lib/utils'
const props = defineProps({
modelValue: { type: String, required: false },
defaultValue: { type: String, required: false },
modelValue: { type: [String, Boolean], required: false },
defaultValue: { type: [String, Boolean], required: false },
disabled: { type: Boolean, required: false },
name: { type: String, required: false },
required: { type: Boolean, required: false },

View File

@@ -6,7 +6,7 @@ import { cn } from '@/lib/utils'
const props = defineProps({
id: { type: String, required: false },
value: { type: String, required: false },
value: { type: [String, Boolean], required: false },
disabled: { type: Boolean, required: false },
required: { type: Boolean, required: false },
name: { type: String, required: false },

View File

@@ -4,8 +4,8 @@ import { SelectRoot, useForwardPropsEmits } from 'radix-vue'
const props = defineProps({
open: { type: Boolean, required: false },
defaultOpen: { type: Boolean, required: false },
defaultValue: { type: String, required: false },
modelValue: { type: String, required: false },
defaultValue: { type: [String, Number], required: false },
modelValue: { type: [String, Number], required: false },
dir: { type: String, required: false },
name: { type: String, required: false },
autocomplete: { type: String, required: false },

View File

@@ -1,26 +1,47 @@
<template>
<TagsInput v-model="tags" class="px-0 gap-0">
<TagsInput v-model="tags" class="px-0 gap-0" :displayValue="getLabel">
<!-- Tags visible to the user -->
<div class="flex gap-2 flex-wrap items-center px-3">
<TagsInputItem v-for="tag in tags" :key="tag" :value="tag">
<TagsInputItemText>{{ tag }}</TagsInputItemText>
<TagsInputItem v-for="tagValue in tags" :key="tagValue" :value="tagValue">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
</div>
<ComboboxRoot :model-value="tags" v-model:open="open" v-model:search-term="searchTerm" class="w-full">
<!-- Combobox for selecting new tags -->
<ComboboxRoot
:model-value="tags"
v-model:open="open"
v-model:search-term="searchTerm"
:filterFunction="filterFunc"
class="w-full"
>
<ComboboxAnchor as-child>
<ComboboxInput :placeholder="placeholder" as-child>
<TagsInputInput class="w-full px-3" :class="tags.length > 0 ? 'mt-2' : ''" @keydown.enter.prevent
@blur="handleBlur" />
<TagsInputInput
class="w-full px-3"
:class="tags.length > 0 ? 'mt-2' : ''"
@keydown.enter.prevent
@blur="handleBlur"
@click="open = true"
/>
</ComboboxInput>
</ComboboxAnchor>
<ComboboxPortal>
<ComboboxContent>
<CommandList position="popper"
class="w-[--radix-popper-anchor-width] rounded-md mt-2 border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2">
<CommandEmpty />
<CommandList
position="popper"
class="w-[--radix-popper-anchor-width] rounded-md mt-2 border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
>
<CommandEmpty> No results found </CommandEmpty>
<CommandGroup>
<CommandItem v-for="item in filteredOptions" :key="item" :value="item" @select="handleSelect">
{{ item }}
<CommandItem
v-for="item in filteredOptions"
:key="item.value"
:value="item.value"
@select="handleSelect"
>
{{ item.label }}
</CommandItem>
</CommandGroup>
</CommandList>
@@ -32,8 +53,20 @@
<script setup>
import { CommandEmpty, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
import { TagsInput, TagsInputInput, TagsInputItem, TagsInputItemDelete, TagsInputItemText } from '@/components/ui/tags-input'
import { ComboboxAnchor, ComboboxContent, ComboboxInput, ComboboxPortal, ComboboxRoot } from 'radix-vue'
import {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
} from '@/components/ui/tags-input'
import {
ComboboxAnchor,
ComboboxContent,
ComboboxInput,
ComboboxPortal,
ComboboxRoot
} from 'radix-vue'
import { computed, ref } from 'vue'
import { useField } from 'vee-validate'
@@ -54,7 +87,8 @@ const props = defineProps({
},
items: {
type: Array,
required: true
required: true,
validator: (value) => value.every((item) => 'label' in item && 'value' in item)
}
})
@@ -65,20 +99,40 @@ const { handleBlur } = useField(() => props.name, undefined, {
const open = ref(false)
const searchTerm = ref('')
const filteredOptions = computed(() =>
props.items.filter(item => !tags.value.includes(item))
)
// Get all options that are not already selected and match the search term
// If not search term is provided, return all available options
const filteredOptions = computed(() => {
const available = props.items.filter((item) => !tags.value.includes(item.value))
if (!searchTerm.value) return available
return available.filter((item) =>
item.label.toLowerCase().includes(searchTerm.value.toLowerCase())
)
})
const getLabel = (value) => {
const item = props.items.find((item) => item.value === value)
return item?.label || value
}
const handleSelect = (event) => {
if (event.detail.value) {
const selectedValue = event.detail.value
if (selectedValue) {
tags.value = [...tags.value, selectedValue]
searchTerm.value = ''
const newTags = [...tags.value]
newTags.push(event.detail.value)
tags.value = newTags
}
if (filteredOptions.value.length === 0) {
open.value = false
}
}
</script>
// Custom filter function to filter items based on the search term
const filterFunc = (remainingItemValues, term) => {
const remainingItems = props.items.filter((item) => remainingItemValues.includes(item.value))
return remainingItems
.filter((item) => item.label.toLowerCase().includes(term.toLowerCase()))
.map((item) => item.value)
}
</script>

View File

@@ -2,7 +2,7 @@
import { SelectValue } from 'radix-vue'
const props = defineProps({
placeholder: { type: String, required: false },
placeholder: { type: [String, Number], required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false }
})

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