Compare commits

...

375 Commits

Author SHA1 Message Date
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
Abhinav Raut
59951f0829 fix: private message sent as reply 2025-02-28 21:44:02 +05:30
Abhinav Raut
461ae3cf22 fix: sla badge not visible in conversation info sidebar. 2025-02-28 21:32:08 +05:30
Abhinav Raut
da5dfdbcde fix: prevent email enumeration in reset password flow. 2025-02-28 20:57:47 +05:30
Abhinav Raut
9c67c02b08 fix: ensure navigation to SSO list only after creating SSO provider and not while updating SSO provider. 2025-02-27 23:46:31 +05:30
Abhinav Raut
15b200b0db fix: add descriptions for notification settings SMTP config for better clarity 2025-02-27 23:02:14 +05:30
Abhinav Raut
f4617c599c fix: correct Zod schema for email address validation 2025-02-27 23:01:49 +05:30
Abhinav Raut
341d0b7e47 Update README.md 2025-02-27 21:37:07 +05:30
Abhinav Raut
78b8c508d8 fix: message bubble styling for better text wrapping 2025-02-27 03:01:05 +05:30
Abhinav Raut
f17d96f96f rafactor: move full screen editor and non-fullscreen editor to a common component.
feat: add typography plugin and improve DOM purifying in conversation messages
fix: sooner not working in outer app.
fix: macro actions getting deleted when macro is remove from the text editor preview.
fix: square user avatar image in sidebar,made it rounded-lg
refactor: visual fixes and improvements to macro previews for consistency with attachment preview.
2025-02-27 02:47:23 +05:30
Abhinav Raut
c75c117a4d fix: improve password handling and error reporting during password reset 2025-02-27 01:58:08 +05:30
Abhinav Raut
873d26ccb2 fix: ensure deep copy of macros, as removing macro from editor was deleting the macro action from the macro store.
- fix: conversation macro cmds visible when conversation is not open.
2025-02-26 23:19:37 +05:30
Abhinav Raut
71601364ae fix: mark conversation as read when messages are already cached 2025-02-26 17:41:25 +05:30
Abhinav Raut
44723fb70d fix: update command to start backend dev server in documentation 2025-02-26 12:11:15 +05:30
Abhinav Raut
67e1230485 feat: agent availability status
New columns in users table to store user availability status.

Websocket pings sets the last active at timestamp, once user stops sending pings (on disconnect) after 5 minutes the user availalbility status changes to offline.

Detects auto away by checking for mouse, keyboard events and sets user status to away.

User can also set their status to away manually from the sidebar.

Migrations for v0.3.0

Minor visual fixes.

Bump version in package.json
2025-02-26 04:34:30 +05:30
Abhinav Raut
d58898c60f fix: update DockerHub image path and branch reference in installation documentation 2025-02-26 00:52:42 +05:30
Abhinav Raut
a8dc0a6242 fix: correct DockerHub image path in installation documentation 2025-02-26 00:50:30 +05:30
Abhinav Raut
3aa144f703 feat: display app update component only for admin routes. 2025-02-25 18:27:21 +05:30
Abhinav Raut
fcbd16f042 feat: implement app update checker and UI notification 2025-02-25 02:49:09 +05:30
Abhinav Raut
e8f3f24422 feat: update build configuration and versioning system 2025-02-25 01:35:52 +05:30
Abhinav Raut
425bb4ed04 feat: add database upgrade functionality (adapted from listmonk) 2025-02-25 01:35:27 +05:30
Abhinav Raut
0c3da82250 fix: remove redundant margin class from Actions AccordionItem 2025-02-25 00:34:28 +05:30
Abhinav Raut
8649826a89 Update .gitattributes 2025-02-25 00:19:06 +05:30
Abhinav Raut
d427dfd20c Update .gitattributes 2025-02-25 00:18:42 +05:30
Abhinav Raut
afb54c371b Update .gitattributes 2025-02-25 00:16:28 +05:30
Abhinav Raut
46459599c7 Update .gitattributes 2025-02-25 00:10:14 +05:30
Abhinav Raut
63a6aedfd0 chore: update .gitattributes to specify Go language for all files 2025-02-25 00:08:04 +05:30
Abhinav Raut
ffbf613e68 chore: add .gitattributes to mark frontend files as vendored 2025-02-25 00:06:28 +05:30
Abhinav Raut
88f82fe80b fix: typos in docker image and curl request. 2025-02-24 22:28:01 +05:30
Abhinav Raut
914b6371b6 fix: goreleaser 2025-02-24 22:10:01 +05:30
Abhinav Raut
89eb05f337 feat: disallow edits for admin role
- chore: minor layout fixes
- chore: adds doc strings to handlers
2025-02-24 22:07:24 +05:30
Abhinav Raut
71a3588855 refactor: update help text for clarity across various admin views 2025-02-24 21:46:57 +05:30
Abhinav Raut
c6baf3f9bf fix: increase z-index for popper content wrapper to ensure visibility 2025-02-24 21:12:14 +05:30
Abhinav Raut
368ec3c82b fix: extend timeout for closing WebSocket connection on no pong response 2025-02-24 21:12:14 +05:30
Abhinav Raut
4cc40ec5d5 Update README.md 2025-02-24 03:41:58 +05:30
Abhinav Raut
171e404e6f chore: update screenshot URL in documentation 2025-02-24 03:40:53 +05:30
Abhinav Raut
28f4fda274 Update README.md 2025-02-24 03:31:30 +05:30
Abhinav Raut
00ded9c19b Update README.md 2025-02-24 03:30:28 +05:30
Abhinav Raut
17efaf0f2c fix: update password strength requirements and hint for system users 2025-02-24 02:42:27 +05:30
Abhinav Raut
b44290a6f0 feat: add GitHub Actions workflow for MkDocs deployment
- fix: inject correct vars in go releaser builds
2025-02-24 02:27:06 +05:30
Abhinav Raut
1a7ee4d8c6 chore: clean and remove unused UI components and update DotLoader usage 2025-02-23 22:40:09 +05:30
Abhinav Raut
ab56d01e22 chore: remove unused ConversationActions and DashboardGreet components 2025-02-23 21:50:43 +05:30
408 changed files with 21823 additions and 9986 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
VERSION export-subst

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

@@ -0,0 +1,45 @@
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
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 }}

66
.github/workflows/cypress.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
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
- 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

31
.github/workflows/github-pages.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: Deploy MkDocs
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: 3.x
- run: pip install mkdocs-material
- run: |
if [ -f requirements.txt ]; then
pip install -r requirements.txt;
fi
- run: cd docs && mkdocs build
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/site

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

3
.gitignore vendored
View File

@@ -6,4 +6,5 @@ libredesk
libredesk.exe
uploads
.env
dist/
dist/
.vscode/

View File

@@ -10,7 +10,7 @@ before:
- make frontend-build
builds:
- id: "standard"
- id: "universal"
main: ./cmd
env:
- CGO_ENABLED=0
@@ -24,29 +24,13 @@ builds:
goarch:
- amd64
- arm64
binary: 'libredesk{{ if eq .Os "windows" }}.exe{{ end }}'
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
hooks:
post: make stuff BIN={{ .Path }}
- id: "arm"
main: ./cmd
env:
- CGO_ENABLED=0
goos:
- freebsd
- linux
- netbsd
- openbsd
goarch:
- arm
goarm:
- 6
- 7
binary: 'libredesk{{ if eq .Os "windows" }}.exe{{ end }}'
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
- -s -w -X "main.buildString={{ .Tag }} ({{ .ShortCommit }} {{ .Date }}, {{ .Os }}/{{ .Arch }})" -X "main.versionString={{ .Tag }}"
hooks:
post: make stuff BIN={{ .Path }}
@@ -70,7 +54,7 @@ dockers:
goos: linux
goarch: amd64
ids:
- standard
- universal
image_templates:
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-amd64"
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64"
@@ -94,7 +78,7 @@ dockers:
goos: linux
goarch: arm64
ids:
- standard
- universal
image_templates:
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-arm64"
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64"
@@ -119,7 +103,7 @@ dockers:
goarch: arm
goarm: 6
ids:
- arm
- universal
image_templates:
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv6"
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6"
@@ -144,7 +128,7 @@ dockers:
goarch: arm
goarm: 7
ids:
- arm
- universal
image_templates:
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv7"
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7"
@@ -195,4 +179,4 @@ release:
owner: abhinavxd
name: libredesk
prerelease: auto
draft: true
draft: true

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

@@ -1,8 +1,10 @@
# Build variables
LAST_COMMIT := $(shell git rev-parse --short HEAD)
LAST_COMMIT_DATE := $(shell git show -s --format=%ci ${LAST_COMMIT})
VERSION := $(shell git describe --tags)
BUILDSTR := ${VERSION} (Commit: ${LAST_COMMIT_DATE} (${LAST_COMMIT}), Build: $(shell date +"%Y-%m-%d %H:%M:%S %z"))
# Try to get the commit hash from 1) git 2) the VERSION file 3) fallback.
LAST_COMMIT := $(or $(shell git rev-parse --short HEAD 2> /dev/null),$(shell head -n 1 VERSION | grep -oP -m 1 "^[a-z0-9]+$$"), "")
# Try to get the semver from 1) git 2) the VERSION file 3) fallback.
VERSION := $(or $(LIBREDESK_VERSION),$(shell git describe --tags --abbrev=0 2> /dev/null),$(shell grep -oP 'tag: \Kv\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?' VERSION),"v0.0.0")
BUILDSTR := ${VERSION} (\#${LAST_COMMIT} $(shell date -u +"%Y-%m-%dT%H:%M:%S%z"))
# Binary names and paths
BIN := libredesk
@@ -30,13 +32,13 @@ install-deps: $(STUFFBIN)
.PHONY: frontend-build
frontend-build: install-deps
@echo "→ Building frontend for production..."
@cd ${FRONTEND_DIR} && pnpm build
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build
# Run the Go backend server in development mode.
.PHONY: run-backend
run-backend:
@echo "→ Running backend..."
@go run cmd/*.go
CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go
# Run the JS frontend server in development mode.
.PHONY: run-frontend
@@ -44,19 +46,19 @@ run-frontend:
@echo "→ Installing frontend dependencies (if not already installed)..."
@cd ${FRONTEND_DIR} && pnpm install
@echo "→ Running frontend..."
@export VUE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev
@export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev
# Build the backend binary.
.PHONY: backend-build
backend-build: $(STUFFBIN)
.PHONY: build-backend
build-backend: $(STUFFBIN)
@echo "→ Building backend..."
@CGO_ENABLED=0 go build -a\
-ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.buildDate=${LAST_COMMIT_DATE}' -s -w" \
-ldflags="-X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -s -w" \
-o ${BIN} cmd/*.go
# Main build target: builds both frontend and backend, then stuffs static assets into the binary.
.PHONY: build
build: frontend-build backend-build stuff
build: frontend-build build-backend stuff
@echo "→ Build successful. Current version: $(VERSION)"
# Stuff static assets into the binary using stuffbin.
@@ -69,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

@@ -1,4 +1,4 @@
<a href="https://zerodha.tech"><img src="https://zerodha.tech/static/images/github-badge.svg" align="right" /></a>
<a href="https://zerodha.tech"><img src="https://zerodha.tech/static/images/github-badge.svg" align="right" alt="Zerodha Tech Badge" /></a>
# Libredesk
@@ -7,40 +7,85 @@ Open source, self-hosted customer support desk. Single binary app.
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)
![image](https://github.com/user-attachments/assets/8e434a02-8b33-41c8-8433-3c98d1d5b834)
> [!CAUTION]
> This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
> **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.
- **Granular Permissions**
Create custom roles with granular permissions for teams and individual agents.
- **Smart Automation**
Eliminate repetitive tasks with powerful automation rules. Auto-tag, assign, and route conversations based on custom conditions.
- **CSAT Surveys**
Measure customer satisfaction with automated surveys.
- **Macros**
Save frequently sent messages as templates. With one click, send saved responses, set tags, and more.
- **Smart Organization**
Keep conversations organized with tags, custom statuses for conversations, and snoozing. Find any conversation instantly from the search bar.
- **Auto Assignment**
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**
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.
- **Command Bar**
Opens with a simple shortcut (CTRL+k) and lets you quickly perform actions on conversations.
And more checkout - [libredesk.io](https://libredesk.io)
## Developer Setup
## Installation
#### Prerequisites
### Docker
- **go**
- **pnpm**
- **postgreSQL >= 13**
- **redis**
The latest image is available on DockerHub at [`libredesk/libredesk:latest`](https://hub.docker.com/r/libredesk/libredesk/tags?page=1&ordering=last_updated&name=latest)
1. **Clone the repository**:
```shell
# Download the compose file and sample config file in the current directory.
curl -LO https://github.com/abhinavxd/libredesk/raw/main/docker-compose.yml
curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml
```bash
git clone https://github.com/abhinavxd/libredesk.git
cd libredesk
```
# Copy the config.sample.toml to config.toml and edit it as needed.
cp config.sample.toml config.toml
2. **Create config file**:
# Edit config.toml and find commented lines containing "docker compose". Replace the values in the lines below those comments with service names instead of IP addresses.
- Copy the sample configuration file `config.toml.sample` to `config.toml`:
```bash
cp config.toml.sample config.toml
```
- Edit the `config.toml` file to configure your postgres and redis connection settings.
# Run the services in the background.
docker compose up -d
3. **Run in development mode**:
# Setting System user password.
docker exec -it libredesk_app ./libredesk --set-system-user-password
```
- Backend: `make run-backend`
- Frontend: `make run-frontend`
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/)
__________________
### Binary
- Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary.
- Copy config.sample.toml to config.toml and edit as needed.
- `./libredesk --install` to setup the Postgres DB (or `--upgrade` to upgrade an existing DB. Upgrades are idempotent and running them multiple times have no side effects).
- Run `./libredesk --set-system-user-password` to set the password for the System user.
- Run `./libredesk` and visit `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)
__________________
## 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.
## Translators
You can help translate Libredesk into your language on [Crowdin](https://crowdin.com/project/libredesk).

2
VERSION Normal file
View File

@@ -0,0 +1,2 @@
$Format:%h$
$Format:%D$

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,6 +1,14 @@
package main
import "github.com/zerodha/fastglue"
import (
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/zerodha/fastglue"
)
type providerUpdateReq struct {
Provider string `json:"provider"`
APIKey string `json:"api_key"`
}
// handleAICompletion handles AI completion requests
func handleAICompletion(r *fastglue.Request) error {
@@ -27,3 +35,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

@@ -44,7 +44,7 @@ func handleToggleAutomationRule(r *fastglue.Request) error {
if err := app.automation.ToggleRule(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Rule toggled successfully")
return r.SendEnvelope(true)
}
// handleUpdateAutomationRule updates an automation rule
@@ -55,18 +55,17 @@ 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 {
if err = app.automation.UpdateRule(id, rule); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Rule updated successfully")
return r.SendEnvelope(true)
}
// handleCreateAutomationRule creates a new automation rule
@@ -76,12 +75,12 @@ 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 {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Rule created successfully")
return r.SendEnvelope(true)
}
// handleDeleteAutomationRule deletes an automation rule
@@ -92,15 +91,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,13 +106,13 @@ 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
@@ -126,11 +122,11 @@ func handleUpdateAutomationRuleExecutionMode(r *fastglue.Request) error {
mode = string(r.RequestCtx.PostArgs().Peek("mode"))
)
if mode != amodels.ExecutionModeAll && mode != amodels.ExecutionModeFirstMatch {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid execution mode", nil, envelope.InputError)
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 {
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,11 +48,11 @@ 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 {
@@ -69,14 +69,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 +85,16 @@ 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 {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}

250
cmd/contacts.go Normal file
View File

@@ -0,0 +1,250 @@
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"
)
// 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)
note = string(r.RequestCtx.PostArgs().Peek("note"))
)
if len(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, 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))
enabled = r.RequestCtx.PostArgs().GetBool("enabled")
)
if contactID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := app.user.ToggleEnabled(contactID, models.UserTypeContact, enabled); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}

View File

@@ -10,12 +10,26 @@ 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"
"github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue"
)
type createConversationRequest struct {
InboxID int `json:"inbox_id" form:"inbox_id"`
AssignedAgentID int `json:"agent_id" form:"agent_id"`
AssignedTeamID int `json:"team_id" form:"team_id"`
Email string `json:"contact_email" form:"contact_email"`
FirstName string `json:"first_name" form:"first_name"`
LastName string `json:"last_name" form:"last_name"`
Subject string `json:"subject" form:"subject"`
Content string `json:"content" form:"content"`
Attachments []int `json:"attachments" form:"attachments"`
}
// handleGetAllConversations retrieves all conversations.
func handleGetAllConversations(r *fastglue.Request) error {
var (
@@ -37,14 +51,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 +74,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 +103,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 +131,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 +140,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 +169,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 +180,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 +204,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 +214,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 +225,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 +242,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 +252,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 +264,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 +275,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 +285,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)
}
@@ -340,10 +309,10 @@ func handleUpdateUserAssignee(r *fastglue.Request) error {
assigneeID = r.RequestCtx.PostArgs().GetUintOrZero("assignee_id")
)
if assigneeID == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `assignee_id`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`assignee_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)
}
@@ -360,7 +329,7 @@ func handleUpdateUserAssignee(r *fastglue.Request) error {
// 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.
@@ -372,10 +341,10 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
)
assigneeID, err := r.RequestCtx.PostArgs().GetUint("assignee_id")
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid assignee `id`.", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`assignee_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)
}
@@ -385,7 +354,7 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
conversation, err := enforceConversationAccess(app, uuid, user)
_, err = enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -396,19 +365,7 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
// 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.
@@ -420,30 +377,25 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
priority = string(r.RequestCtx.PostArgs().Peek("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.
@@ -458,20 +410,20 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
// 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 +434,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.
@@ -506,7 +458,7 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
}
}
}
return r.SendEnvelope("Status updated successfully")
return r.SendEnvelope(true)
}
// handleUpdateConversationtags updates conversation tags.
@@ -521,28 +473,80 @@ func handleUpdateConversationtags(r *fastglue.Request) error {
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)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
conversation, err := app.conversation.GetConversation(0, uuid)
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 := app.conversation.SetConversationTags(uuid, models.ActionSetTags, tagNames, user); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleUpdateConversationCustomAttributes updates custom attributes of a conversation.
func handleUpdateConversationCustomAttributes(r *fastglue.Request) error {
var (
app = r.Context.(*App)
attributes = map[string]any{}
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
)
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)
}
_, err = enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
if allowed, err := app.authz.EnforceConversationAccess(user, conversation); err != nil {
// Update custom attributes.
if err := app.conversation.UpdateConversationCustomAttributes(uuid, attributes); err != nil {
return sendErrorEnvelope(r, err)
} else if !allowed {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
}
return r.SendEnvelope(true)
}
// handleUpdateContactCustomAttributes updates custom attributes of a contact.
func handleUpdateContactCustomAttributes(r *fastglue.Request) error {
var (
app = r.Context.(*App)
attributes = map[string]any{}
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
)
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)
}
if err := app.conversation.UpsertConversationTags(uuid, tagNames, user); err != nil {
// Enforce conversation access.
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Tags added successfully")
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)
}
return r.SendEnvelope(true)
}
// handleDashboardCounts retrieves general dashboard counts for all users.
@@ -577,7 +581,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 +589,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 +596,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)
}
@@ -628,7 +617,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)
}
@@ -651,3 +640,109 @@ 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)
}
// Send the created conversation back to the client.
conversation, _ := app.conversation.GetConversation(conversationID, "")
return r.SendEnvelope(conversation)
}

137
cmd/custom_attributes.go Normal file
View File

@@ -0,0 +1,137 @@
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)
}
if err := app.customAttribute.Create(attribute); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// 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)
}
if err = app.customAttribute.Update(id, attribute); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// 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.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))
@@ -63,10 +62,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 +84,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,21 +98,34 @@ 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.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/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"))
@@ -117,20 +133,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"))
@@ -138,18 +151,18 @@ 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.
// Reports.
g.GET("/api/v1/reports/overview/counts", perm(handleDashboardCounts, "reports:manage"))
g.GET("/api/v1/reports/overview/charts", perm(handleDashboardCharts, "reports:manage"))
// Template.
// 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"))
@@ -157,22 +170,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 {
@@ -185,6 +209,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))
@@ -214,7 +239,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())
@@ -222,7 +247,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
}
@@ -236,7 +261,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.
@@ -261,7 +286,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,30 +28,35 @@ 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)
if err != nil {
if err := app.inbox.Create(inbox); err != nil {
return sendErrorEnvelope(r, err)
}
if err := validateInbox(app, inbox); err != nil {
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)
@@ -63,24 +71,30 @@ 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)
}
if err := validateInbox(app, inbox); err != nil {
return sendErrorEnvelope(r, err)
}
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)
}
// handleToggleInbox toggles an inbox
func handleToggleInbox(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -88,7 +102,7 @@ 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 {
@@ -96,12 +110,13 @@ func handleToggleInbox(r *fastglue.Request) error {
}
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)
}
// handleDeleteInbox deletes an inbox
func handleDeleteInbox(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -109,12 +124,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"
@@ -231,11 +233,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 +260,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 +274,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 +288,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 +302,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)
@@ -308,8 +315,13 @@ func initCSAT(db *sqlx.DB) *csat.Manager {
return m
}
// initWS inits websocket hub.
func initWS(user *user.Manager) *ws.Hub {
return ws.NewHub(user)
}
// 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)
@@ -322,7 +334,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)
}
@@ -393,11 +405,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)
@@ -406,7 +419,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
@@ -447,6 +460,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)
@@ -455,9 +469,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)
}
@@ -465,11 +479,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)
@@ -491,13 +506,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"),
})
@@ -513,7 +528,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.
@@ -539,7 +554,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"),
@@ -549,16 +564,16 @@ func initEmailInbox(inboxRecord imodels.Inbox, store inbox.MessageStore) (inbox.
return nil, fmt.Errorf("initializing `%s` inbox: `%s` error : %w", inboxRecord.Channel, inboxRecord.Name, err)
}
log.Printf("`%s` inbox successfully initialized. %d SMTP servers. %d IMAP clients.", inboxRecord.Name, len(config.SMTP), len(config.IMAP))
log.Printf("`%s` inbox successfully initialized", inboxRecord.Name)
return inbox, nil
}
// initializeInboxes handles inbox initialization.
func initializeInboxes(inboxR imodels.Inbox, store inbox.MessageStore) (inbox.Inbox, error) {
func initializeInboxes(inboxR imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) {
switch inboxR.Channel {
case "email":
return initEmailInbox(inboxR, store)
return initEmailInbox(inboxR, msgStore, usrStore)
default:
return nil, fmt.Errorf("unknown inbox channel: %s", inboxR.Channel)
}
@@ -571,8 +586,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)
@@ -584,8 +600,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)
}
@@ -593,7 +609,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)
@@ -601,7 +617,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)
}
@@ -648,11 +665,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)
@@ -662,9 +680,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 {
@@ -708,11 +728,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)
@@ -721,10 +742,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)
@@ -733,10 +755,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)
@@ -745,11 +768,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)
@@ -758,11 +782,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)
@@ -770,6 +795,34 @@ 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
}
// initLogger initializes a logf logger.
func initLogger(src string) *logf.Logger {
lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env")

View File

@@ -9,10 +9,10 @@ import (
"time"
"github.com/abhinavxd/libredesk/internal/colorlog"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/user"
"github.com/jmoiron/sqlx"
"github.com/knadh/stuffbin"
"github.com/lib/pq"
)
// Install checks if the schema is already installed, prompts for confirmation, and installs the schema if needed.
@@ -24,9 +24,9 @@ func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem, idempoten
}
// Make sure the system user password is strong enough.
password := strings.TrimSpace(os.Getenv("LIBREDESK_SYSTEM_USER_PASSWORD"))
if password != "" && !user.IsStrongSystemUserPassword(password) && !schemaInstalled {
log.Fatalf("system user password is not strong, %s", user.SystemUserPasswordHint)
password := os.Getenv("LIBREDESK_SYSTEM_USER_PASSWORD")
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)
@@ -76,7 +75,7 @@ func setSystemUserPass(ctx context.Context, db *sqlx.DB) {
// checkSchema verifies if the DB schema is already installed by querying a table.
func checkSchema(db *sqlx.DB) (bool, error) {
if _, err := db.Exec(`SELECT * FROM settings LIMIT 1`); err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "42P01" {
if dbutil.IsTableNotExistError(err) {
return false, nil
}
return false, err

View File

@@ -3,22 +3,38 @@ package main
import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
realip "github.com/ferluci/fast-realip"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
// handleLogin logs a user in.
// handleLogin logs in the user and returns the user.
func handleLogin(r *fastglue.Request) error {
var (
app = r.Context.(*App)
p = r.RequestCtx.PostArgs()
email = string(p.Peek("email"))
password = p.Peek("password")
email = string(r.RequestCtx.PostArgs().Peek("email"))
password = r.RequestCtx.PostArgs().Peek("password")
ip = realip.FromRequest(r.RequestCtx)
)
// Verify email and password.
user, err := app.user.VerifyPassword(email, 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)
}
user.AvailabilityStatus = umodels.Online
if err := app.auth.SaveSession(amodels.User{
ID: user.ID,
Email: user.Email.String,
@@ -26,25 +42,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,15 +74,14 @@ 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)
if err != nil {
if err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -108,11 +105,11 @@ 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 {
if err = app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -122,18 +119,14 @@ func handleUpdateMacro(r *fastglue.Request) error {
// 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 +138,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 +149,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 +160,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 +168,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 +177,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 +194,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 +202,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 +232,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 +269,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 +295,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

@@ -6,15 +6,21 @@ import (
"log"
"os"
"os/signal"
"sync"
"sync/atomic"
"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/search"
@@ -34,7 +40,6 @@ import (
"github.com/abhinavxd/libredesk/internal/team"
"github.com/abhinavxd/libredesk/internal/template"
"github.com/abhinavxd/libredesk/internal/user"
"github.com/abhinavxd/libredesk/internal/ws"
"github.com/knadh/go-i18n"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
@@ -50,38 +55,45 @@ var (
frontendDir = "frontend/dist"
// Injected at build time.
buildString = ""
buildString string
versionString string
)
// 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
// Global state that stores data on an available app update.
update *AppUpdate
sync.Mutex
}
func main() {
@@ -99,9 +111,7 @@ func main() {
}
// Build string injected at build time.
if buildString != "" {
colorlog.Green("Build: %s", buildString)
}
colorlog.Green("Build: %s", buildString)
// Load the config files into Koanf.
initConfig(ko)
@@ -136,10 +146,13 @@ func main() {
// Upgrade.
if ko.Bool("upgrade") {
log.Println("no upgrades available")
upgrade(db, fs, !ko.Bool("yes"))
os.Exit(0)
}
// Check for pending upgrade.
checkPendingUpgrade(db)
// Load app settings from DB into the Koanf instance.
settings := initSettings(db)
loadSettings(settings)
@@ -153,66 +166,70 @@ func main() {
messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval")
slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval")
lo = initLogger(appName)
wsHub = ws.NewHub()
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)
user = initUser(i18n, db)
notifier = initNotifier(user)
automation = initAutomationEngine(db)
sla = initSLA(db, team, settings, businessHours)
wsHub = initWS(user)
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)
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 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),
csat: initCSAT(db, i18n),
search: initSearch(db, i18n),
role: initRole(db, i18n),
tag: initTag(db, i18n),
macro: initMacro(db, i18n),
ai: initAI(db, i18n),
}
app.consts.Store(constants)
@@ -226,7 +243,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() {
@@ -239,6 +256,11 @@ func main() {
}
}()
// Start the app update checker.
if ko.Bool("app.check_updates") {
go checkUpdates(versionString, time.Hour*1, app)
}
// Wait for shutdown signal.
<-ctx.Done()
colorlog.Red("Shutting down HTTP server...")

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

@@ -15,6 +15,7 @@ type messageReq struct {
Attachments []int `json:"attachments"`
Message string `json:"message"`
Private bool `json:"private"`
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc"`
}
@@ -30,7 +31,7 @@ func handleGetMessages(r *fastglue.Request) error {
total = 0
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -48,11 +49,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 +74,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 +109,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 +120,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,31 +132,32 @@ 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)
}
@@ -163,17 +167,11 @@ func handleSendMessage(r *fastglue.Request) error {
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 {
if err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/); err != nil {
return sendErrorEnvelope(r, err)
}
// Evaluate automation rules.
app.automation.EvaluateConversationUpdateRules(cuuid, models.EventConversationMessageOutgoing)
}
// Reopen if snoozed/closed/resolved regardless of automation rules - this is the default behavior
if err := app.conversation.ReOpenConversation(cuuid, user); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Message sent successfully")
return r.SendEnvelope(true)
}

View File

@@ -8,10 +8,11 @@ import (
"github.com/abhinavxd/libredesk/internal/envelope"
"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.
// 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.
func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
app := r.Context.(*App)
@@ -23,7 +24,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
// Try to get user.
user, err := app.user.Get(userSession.ID)
user, err := app.user.GetAgent(userSession.ID, "")
if err != nil {
return handler(r)
}
@@ -40,22 +41,20 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
}
// auth makes sure the user is logged in.
// auth validates the session and adds the user to the request context.
func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
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)
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.T("auth.invalidOrExpiredSession"), nil, envelope.GeneralError)
}
// Set user in the request context.
user, err := app.user.Get(userSession.ID)
user, err := app.user.GetAgent(userSession.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -70,7 +69,8 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
}
// perm does session validation, CSRF, and permission enforcement.
// perm matches the CSRF token and checks if the user has the required permission to access the endpoint.
// and sets the user in the request context.
func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var (
@@ -79,36 +79,45 @@ func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequest
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", "cookie_token", cookieToken, "header_token", hdrToken)
return r.SendErrorEnvelope(http.StatusForbidden, "Invalid CSRF token", nil, envelope.PermissionError)
return r.SendErrorEnvelope(http.StatusForbidden, app.i18n.T("auth.csrfTokenMismatch"), 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)
return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.T("auth.invalidOrExpiredSession"), nil, envelope.GeneralError)
}
// Get user from DB.
user, err := app.user.Get(sessUser.ID)
user, err := app.user.GetAgent(sessUser.ID, "")
if err != nil {
return sendErrorEnvelope(r, 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 r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.T("user.accountDisabled"), nil, envelope.PermissionError)
}
// 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.
@@ -131,9 +140,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)
}
@@ -142,7 +159,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),
}, "")
}
@@ -157,7 +174,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 {
@@ -53,7 +59,7 @@ func handleTestOIDC(r *fastglue.Request) error {
if err := app.auth.TestProvider(providerURL); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("OIDC provider discovered successfully")
return r.SendEnvelope(true)
}
// handleCreateOIDC creates a new OIDC record.
@@ -63,7 +69,7 @@ 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 {
@@ -72,7 +78,7 @@ func handleCreateOIDC(r *fastglue.Request) error {
// 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")
}
@@ -85,12 +91,11 @@ 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 {
@@ -99,9 +104,9 @@ func handleUpdateOIDC(r *fastglue.Request) error {
// 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")
return r.SendEnvelope(true)
}
// handleDeleteOIDC deletes an OIDC record.
@@ -109,11 +114,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)
}

View File

@@ -9,17 +9,19 @@ import (
"github.com/zerodha/fastglue"
)
// handleGetRoles returns all roles
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
func handleGetRole(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -32,33 +34,34 @@ func handleGetRole(r *fastglue.Request) error {
return r.SendEnvelope(role)
}
// handleDeleteRole deletes a role
func handleDeleteRole(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
err := app.role.Delete(id)
if err != nil {
if err := app.role.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleCreateRole creates a new role
func handleCreateRole(r *fastglue.Request) error {
var (
app = r.Context.(*App)
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)
}
err := app.role.Create(req)
if err != nil {
if err := app.role.Create(req); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleUpdateRole updates a role
func handleUpdateRole(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -66,10 +69,9 @@ 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)
}
err := app.role.Update(id, req)
if err != nil {
if err := app.role.Update(id, req); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)

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,7 +21,17 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(out)
// 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, app.i18n.Ts("globals.messages.errorFetching", "name", app.i18n.T("globals.terms.setting")), nil))
}
// 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)
}
// handleUpdateGeneralSettings updates general settings.
@@ -31,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.
@@ -61,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)
@@ -78,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")
@@ -87,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 == "" {
@@ -97,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,80 @@ 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)
}
if err := app.sla.Create(sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("SLA created successfully.")
}
// 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)
}
if err := app.sla.Update(id, sla.Name, sla.Description, sla.FirstResponseTime, sla.ResolutionTime, sla.NextResponseTime, sla.Notifications); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// 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 +105,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,11 +26,11 @@ 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)
@@ -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,16 +63,15 @@ 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)

View File

@@ -9,81 +9,76 @@ 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)
if err != nil {
if err := app.tag.Create(tag.Name); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// 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)
if err != nil {
if err = app.tag.Update(id, tag.Name); err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -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 {
@@ -64,7 +64,7 @@ func handleCreateTeam(r *fastglue.Request) error {
if err := app.team.Create(name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID != 0), null.NewInt(slaPolicyID, slaPolicyID != 0), emoji, maxAutoAssignedConversations); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Team created successfully.")
return r.SendEnvelope(true)
}
// handleUpdateTeam updates an existing team.
@@ -86,7 +86,7 @@ func handleUpdateTeam(r *fastglue.Request) error {
if err := app.team.Update(id, name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID != 0), null.NewInt(slaPolicyID, slaPolicyID != 0), emoji, maxAutoAssignedConversations); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Team updated successfully.")
return r.SendEnvelope(true)
}
// handleDeleteTeam deletes a team
@@ -96,12 +96,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,7 +48,10 @@ 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 req.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if err := app.tmpl.Create(req); err != nil {
return sendErrorEnvelope(r, err)
@@ -69,7 +71,10 @@ 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 req.Name == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
}
if err = app.tmpl.Update(id, req); err != nil {
return sendErrorEnvelope(r, err)
@@ -89,7 +94,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)

98
cmd/updates.go Normal file
View File

@@ -0,0 +1,98 @@
// Copyright Kailash Nadh (https://github.com/knadh/listmonk)
// SPDX-License-Identifier: AGPL-3.0
// Adapted from listmonk for Libredesk.
package main
import (
"encoding/json"
"io"
"net/http"
"regexp"
"time"
"golang.org/x/mod/semver"
)
const updateCheckURL = "https://updates.libredesk.io/updates.json"
type AppUpdate struct {
Update struct {
ReleaseVersion string `json:"release_version"`
ReleaseDate string `json:"release_date"`
URL string `json:"url"`
Description string `json:"description"`
// This is computed and set locally based on the local version.
IsNew bool `json:"is_new"`
} `json:"update"`
Messages []struct {
Date string `json:"date"`
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
Priority string `json:"priority"`
} `json:"messages"`
}
var reSemver = regexp.MustCompile(`-(.*)`)
// checkUpdates is a blocking function that checks for updates to the app
// at the given intervals. On detecting a new update (new semver), it
// sets the global update status that renders a prompt on the UI.
func checkUpdates(curVersion string, interval time.Duration, app *App) {
// Strip -* suffix.
curVersion = reSemver.ReplaceAllString(curVersion, "")
fnCheck := func() {
resp, err := http.Get(updateCheckURL)
if err != nil {
app.lo.Error("error checking for app updates", "err", err)
return
}
if resp.StatusCode != 200 {
app.lo.Error("non-ok status code checking for app updates", "status", resp.StatusCode)
return
}
b, err := io.ReadAll(resp.Body)
if err != nil {
app.lo.Error("error reading response body", "err", err)
return
}
resp.Body.Close()
var out AppUpdate
if err := json.Unmarshal(b, &out); err != nil {
app.lo.Error("error unmarshalling response body", "err", err)
return
}
// There is an update. Set it on the global app state.
if semver.IsValid(out.Update.ReleaseVersion) {
v := reSemver.ReplaceAllString(out.Update.ReleaseVersion, "")
if semver.Compare(v, curVersion) > 0 {
out.Update.IsNew = true
app.lo.Info("new update available", "version", out.Update.ReleaseVersion)
}
}
app.Lock()
app.update = &out
app.Unlock()
}
// 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 * 5)
fnCheck()
// Thereafter, check every $interval.
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
fnCheck()
}
}

151
cmd/upgrade.go Normal file
View File

@@ -0,0 +1,151 @@
// Copyright Kailash Nadh (https://github.com/knadh/listmonk)
// SPDX-License-Identifier: AGPL-3.0
// Adapted from listmonk for Libredesk.
package main
import (
"fmt"
"log"
"strings"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/migrations"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
"golang.org/x/mod/semver"
)
// migFunc represents a migration function for a particular version.
// fn (generally) executes database migrations and additionally
// takes the filesystem and config objects in case there are additional bits
// of logic to be performed before executing upgrades. fn is idempotent.
type migFunc struct {
version string
fn func(*sqlx.DB, stuffbin.FileSystem, *koanf.Koanf) error
}
// migList is the list of available migList ordered by the semver.
// Each migration is a Go file in internal/migrations named after the semver.
// 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},
}
// upgrade upgrades the database to the current version by running SQL migration files
// for all version from the last known version to the current one.
func upgrade(db *sqlx.DB, fs stuffbin.FileSystem, prompt bool) {
if prompt {
var ok string
fmt.Printf("** IMPORTANT: Take a backup of the database before upgrading.\n")
fmt.Print("continue (y/n)? ")
if _, err := fmt.Scanf("%s", &ok); err != nil {
log.Fatalf("error reading value from terminal: %v", err)
}
if !strings.EqualFold(ok, "y") {
fmt.Println("upgrade cancelled")
return
}
}
_, toRun, err := getPendingMigrations(db)
if err != nil {
log.Fatalf("error checking migrations: %v", err)
}
// No migrations to run.
if len(toRun) == 0 {
log.Printf("no upgrades to run. Database is up to date.")
return
}
// Execute migrations in succession.
for _, m := range toRun {
log.Printf("running migration %s", m.version)
if err := m.fn(db, fs, ko); err != nil {
log.Fatalf("error running migration %s: %v", m.version, err)
}
// Record the migration version in the settings table. There was no
// settings table until v0.7.0, so ignore the no-table errors.
if err := recordMigrationVersion(m.version, db); err != nil {
if dbutil.IsTableNotExistError(err) {
continue
}
log.Fatalf("error recording migration version %s: %v", m.version, err)
}
}
log.Printf("upgrade complete")
}
// getPendingMigrations gets the pending migrations by comparing the last
// recorded migration in the DB against all migrations listed in `migrations`.
func getPendingMigrations(db *sqlx.DB) (string, []migFunc, error) {
lastVer, err := getLastMigrationVersion(db)
if err != nil {
return "", nil, err
}
// Iterate through the migration versions and get everything above the last
// upgraded semver.
var toRun []migFunc
for i, m := range migList {
if semver.Compare(m.version, lastVer) > 0 {
toRun = migList[i:]
break
}
}
return lastVer, toRun, nil
}
// getLastMigrationVersion returns the last migration semver recorded in the DB.
// If there isn't any, `v0.0.0` is returned.
func getLastMigrationVersion(db *sqlx.DB) (string, error) {
var v string
if err := db.Get(&v, `
SELECT COALESCE(
(SELECT value->>-1 FROM settings WHERE key='migrations'),
'v0.0.0')`); err != nil {
if dbutil.IsTableNotExistError(err) {
return "v0.0.0", nil
}
return v, err
}
return v, nil
}
// recordMigrationVersion inserts the given version (of DB migration) into the
// `migrations` array in the settings table.
func recordMigrationVersion(ver string, db *sqlx.DB) error {
_, err := db.Exec(fmt.Sprintf(`INSERT INTO settings (key, value)
VALUES('migrations', '["%s"]'::JSONB)
ON CONFLICT (key) DO UPDATE SET value = settings.value || EXCLUDED.value`, ver))
return err
}
// checkPendingUpgrade checks if the current database schema matches the expected binary version.
func checkPendingUpgrade(db *sqlx.DB) {
lastVer, toRun, err := getPendingMigrations(db)
if err != nil {
log.Fatalf("error checking migrations: %v", err)
}
// No migrations to run.
if len(toRun) == 0 {
return
}
var vers []string
for _, m := range toRun {
vers = append(vers, m.version)
}
log.Fatalf(`there are %d pending database upgrade(s): %v. The last upgrade was %s. Backup the database and run libredesk --upgrade`,
len(toRun), vers, lastVer)
}

View File

@@ -2,7 +2,7 @@ package main
import (
"fmt"
"net/http"
"mime/multipart"
"path/filepath"
"slices"
"strconv"
@@ -16,87 +16,112 @@ 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 = 5
maxAvatarSizeMB = 2
)
// handleGetUsers returns all users.
func handleGetUsers(r *fastglue.Request) error {
// 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 {
var (
app = r.Context.(*App)
)
agents, err := app.user.GetAllCompact()
// handleGetAgentsCompact returns all agents in a compact format.
func handleGetAgentsCompact(r *fastglue.Request) error {
var app = r.Context.(*App)
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)
}
// handleGetCurrentUserTeams returns the teams of a user.
func handleGetCurrentUserTeams(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"))
ip = realip.FromRequest(r.RequestCtx)
)
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Same status?
if agent.AvailabilityStatus == status {
return r.SendEnvelope(true)
}
// Update availability status.
if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
return sendErrorEnvelope(r, err)
}
// Skip activity log if agent returns online from away (to avoid spam).
if !(agent.AvailabilityStatus == models.Away && status == models.Online) {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, status, ip, "", 0); err != nil {
app.lo.Error("error creating activity log", "error", err)
}
}
return r.SendEnvelope(true)
}
// 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)
}
@@ -104,94 +129,40 @@ 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)
}
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.
@@ -200,8 +171,10 @@ func handleCreateUser(r *fastglue.Request) error {
}
// 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 {
@@ -212,82 +185,101 @@ 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 r.SendEnvelope("User created successfully, but error sending welcome email.")
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, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
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", "`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)
}
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.
// 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)
}
@@ -296,54 +288,54 @@ 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 == "" {
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)
}
err = app.user.UpdateAvatar(user.ID, "")
if 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)
@@ -352,67 +344,131 @@ func handleResetPassword(r *fastglue.Request) error {
email = string(p.Peek("email"))
)
if ok && auser.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "User is already logged in", 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)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
}
user, err := app.user.GetByEmail(email)
agent, err := app.user.GetAgent(0, email)
if err != nil {
return sendErrorEnvelope(r, err)
// 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 notification message", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error sending notification message", nil, envelope.GeneralError)
app.lo.Error("error sending password reset email", "error", err)
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)
p = r.RequestCtx.PostArgs()
password = string(p.Peek("password"))
token = string(p.Peek("token"))
)
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)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "{globals.terms.password}"), nil, envelope.InputError)
}
if err := app.user.ResetPassword(token, 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
}

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,49 @@ 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 {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("View created successfully")
return r.SendEnvelope(true)
}
// 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 +89,30 @@ 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 {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}

View File

@@ -2,17 +2,22 @@
[app]
log_level = "debug"
env = "dev"
check_updates = true
# HTTP server.
[app.server]
address = "0.0.0.0:9000"
socket = ""
# Do NOT disable secure cookies in production environment if you don't know
# exactly what you're doing!
disable_secure_cookies = false
read_timeout = "5s"
write_timeout = "5s"
max_body_size = 500000000
read_buffer_size = 4096
keepalive_timeout = "10s"
# File upload provider to use.
# File upload provider to use, either `fs` or `s3`.
[upload]
provider = "fs"
@@ -32,11 +37,12 @@ expiry = "6h"
# Postgres.
[db]
# If using docker compose, use the service name as the host.
# If using docker compose, use the service name as the host. e.g. db
host = "127.0.0.1"
port = 5432
user = "postgres"
password = "postgres"
# Update the following values with your database credentials.
user = "libredesk"
password = "libredesk"
database = "libredesk"
ssl_mode = "disable"
max_open = 30
@@ -45,7 +51,7 @@ max_lifetime = "300s"
# Redis.
[redis]
# If using docker compose, use the service name as the host.
# If using docker compose, use the service name as the host. e.g. redis:6379
address = "127.0.0.1:6379"
password = ""
db = 0
@@ -71,4 +77,4 @@ autoassign_interval = "5m"
unsnooze_interval = "5m"
[sla]
evaluation_interval = "5m"
evaluation_interval = "5m"

View File

@@ -1,7 +1,7 @@
services:
# Libredesk app
app:
image: libredesk:latest
image: libredesk/libredesk:latest
container_name: libredesk_app
restart: unless-stopped
ports:
@@ -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,31 @@
# Developer Setup
Libredesk is a monorepo with a Go backend and a Vue.js frontend. The frontend uses Shadcn for UI components.
### Pre-requisites
- `go`
- `nodejs` (if you are working on the frontend) and `pnpm`
- Postgres database (>= 13)
### First time setup
Clone the repository:
```sh
git clone https://github.com/abhinavxd/libredesk.git
```
1. Copy `config.toml.sample` as `config.toml` and add your config.
2. Run `make` to build the libredesk binary. Once the binary is built, run `./libredesk --install` to run the DB setup and set the System user password.
### Running the Dev Environment
1. Run `make run-backend` to start the libredesk backend dev server on `:9000`.
2. Run `make run-frontend` to start the Vue frontend in dev mode using pnpm on `:8000`. Requests are proxied to the backend running on `:9000` check `vite.config.js` for the proxy config.
---
# Production Build
Run `make` to build the Go binary, build the Javascript frontend, and embed the static assets producing a single self-contained binary, `libredesk`.

13
docs/docs/index.md Normal file
View File

@@ -0,0 +1,13 @@
# Introduction
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/image-HvmxvOkalQSLp4qVezdTXaCd3dB4Rm.png" alt="libredesk screenshot" style="display: block; margin: 0 auto;">
</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.

67
docs/docs/installation.md Normal file
View File

@@ -0,0 +1,67 @@
# Installation
Libredesk is a single binary application that requires postgres and redis to run. You can install it using the binary or docker.
## Binary
1. Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary.
2. `./libredesk --install` to install the tables in the Postgres DB (⩾ 13) and set the System user password.
3. Run `./libredesk` and visit `http://localhost:9000` and login with the email `System` and the password you set during installation.
!!! Tip
To set the System user password during installation, set the environment variables:
`LIBREDESK_SYSTEM_USER_PASSWORD=xxxxxxxxxxx ./libredesk --install`
## Docker
The latest image is available on DockerHub at `libredesk/libredesk:latest`
The recommended method is to download the [docker-compose.yml](https://github.com/abhinavxd/libredesk/blob/main/docker-compose.yml) file, customize it for your environment and then to simply run `docker compose up -d`.
```shell
# Download the compose file and the sample config file in the current directory.
curl -LO https://github.com/abhinavxd/libredesk/raw/main/docker-compose.yml
curl -LO https://github.com/abhinavxd/libredesk/raw/main/config.sample.toml
# Copy the config.sample.toml to config.toml and edit it as needed.
cp config.sample.toml config.toml
# Edit config.toml and find commented lines containing "docker compose". Replace the values in the lines below those comments with service names instead of IP addresses.
# Run the services in the background.
docker compose up -d
# Setting System user password.
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
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`

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

@@ -0,0 +1,43 @@
# 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, and recipient 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.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 |
### Example outgoing email template
```html
Dear {{ .Recipient.FirstName }}
{{ template "content" . }}
Best regards,
```
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)

18
docs/docs/upgrade.md Normal file
View File

@@ -0,0 +1,18 @@
# Upgrade
!!! warning "Warning"
Always take a backup of the Postgres database before upgrading Libredesk.
## Binary
- Stop running libredesk binary.
- Download the [latest release](https://github.com/abhinavxd/libredesk/releases) and extract the libredesk binary and overwrite the previous version.
- `./libredesk --upgrade` to upgrade an existing database schema. Upgrades are idempotent and running them multiple times have no side effects.
- Run `./libredesk` again.
## Docker
```shell
docker compose down app
docker compose pull
docker compose up app -d
```

38
docs/mkdocs.yml Normal file
View File

@@ -0,0 +1,38 @@
site_name: Libredesk Documentation
theme:
name: material
language: en
font:
text: Source Sans Pro
code: Roboto Mono
weights:
- 400
- 700
direction: ltr
palette:
primary: white
accent: red
features:
- navigation.indexes
- navigation.sections
- content.code.copy
extra:
search:
language: en
markdown_extensions:
- admonition
- codehilite
- toc:
permalink: true
nav:
- Introduction: index.md
- Getting Started:
- Installation: installation.md
- Upgrade: upgrade.md
- Templating: templating.md
- SSO: sso.md
- Contributors:
- Developer setup: developer-setup.md
- Translations: translations.md

View File

@@ -1,8 +0,0 @@
{
"recommendations": [
"Vue.volar",
"Vue.vscode-typescript-vue-plugin",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}

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/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/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/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('svg.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&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&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,6 +1,6 @@
{
"name": "libredesk",
"version": "0.0.0",
"version": "0.6.0-alpha",
"private": true,
"type": "module",
"scripts": {
@@ -8,6 +8,7 @@
"build": "vite build",
"preview": "vite preview",
"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",
@@ -18,43 +19,37 @@
"@formkit/auto-animate": "^0.8.2",
"@internationalized/date": "^3.5.5",
"@radix-icons/vue": "^1.0.0",
"@tailwindcss/typography": "^0.5.10",
"@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-ordered-list": "^2.4.0",
"@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/suggestion": "^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",
"@vue/reactivity": "^3.4.15",
"@vue/runtime-core": "^3.4.15",
"@vueup/vue-quill": "^1.2.0",
"@vee-validate/zod": "^4.15.0",
"@vueuse/core": "^12.4.0",
"add": "^2.0.6",
"axios": "^1.7.9",
"axios": "^1.8.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"codeflask": "^1.4.1",
"date-fns": "^3.6.0",
"install": "^0.13.0",
"lucide-vue-next": "^0.378.0",
"mitt": "^3.0.1",
"npm": "^10.4.0",
"npx": "^10.2.2",
"pinia": "^2.1.7",
"qs": "^6.12.1",
"radix-vue": "latest",
"shadcn-vue": "latest",
"radix-vue": "^1.9.17",
"reka-ui": "^2.2.0",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"textarea": "^0.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",
"vue-letter": "^0.2.0",
"vue-picture-cropper": "^0.7.0",
@@ -62,13 +57,13 @@
"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",
"@vitejs/plugin-vue": "^5.0.3",
"@vue/eslint-config-prettier": "^8.0.0",
"autoprefixer": "latest",
"autoprefixer": "^10.4.20",
"cypress": "^13.6.3",
"eslint": "^8.49.0",
"eslint-plugin-cypress": "^2.15.1",
@@ -77,8 +72,9 @@
"prettier": "^3.0.3",
"sass": "^1.70.0",
"start-server-and-test": "^2.0.3",
"tailwindcss": "latest",
"vite": "^5.4.9"
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"vite": "^5.4.18"
},
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
}

2308
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,9 +85,16 @@
@create-view="openCreateViewForm = true"
@edit-view="editView"
@delete-view="deleteView"
@create-conversation="() => (openCreateConversationDialog = true)"
>
<div class="flex flex-col h-screen">
<!-- Show app update only in admin routes -->
<AppUpdate v-if="route.path.startsWith('/admin')" />
<!-- Common header for all pages -->
<PageHeader />
<!-- Main content -->
<RouterView class="flex-grow" />
</div>
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
@@ -58,6 +104,9 @@
<!-- Command box -->
<Command />
<!-- Create conversation dialog -->
<CreateConversation v-model="openCreateConversationDialog" v-if="openCreateConversationDialog" />
</template>
<script setup>
@@ -75,13 +124,18 @@ import { useTeamStore } from '@/stores/team'
import { useSlaStore } from '@/stores/sla'
import { useMacroStore } from '@/stores/macro'
import { useTagStore } from '@/stores/tag'
import { useCustomAttributeStore } from '@/stores/customAttributes'
import { useIdleDetection } from '@/composables/useIdleDetection'
import PageHeader from './components/layout/PageHeader.vue'
import ViewForm from '@/features/view/ViewForm.vue'
import AppUpdate from '@/components/update/AppUpdate.vue'
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,
@@ -94,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()
@@ -106,21 +161,28 @@ const inboxStore = useInboxStore()
const slaStore = useSlaStore()
const macroStore = useMacroStore()
const tagStore = useTagStore()
const customAttributeStore = useCustomAttributeStore()
const userViews = ref([])
const view = ref({})
const openCreateViewForm = ref(false)
const openCreateConversationDialog = ref(false)
const { t } = useI18n()
initWS()
useIdleDetection()
onMounted(() => {
initToaster()
listenViewRefresh()
initStores()
})
// initialize data stores
// Initialize data stores
const initStores = async () => {
if (!userStore.userID) {
await userStore.getCurrentUser()
}
await Promise.allSettled([
userStore.getCurrentUser(),
getUserViews(),
conversationStore.fetchStatuses(),
conversationStore.fetchPriorities(),
@@ -129,7 +191,8 @@ const initStores = async () => {
inboxStore.fetchInboxes(),
slaStore.fetchSlas(),
macroStore.loadMacros(),
tagStore.fetchTags()
tagStore.fetchTags(),
customAttributeStore.fetchCustomAttributes()
])
}
@@ -143,8 +206,9 @@ 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, {
@@ -161,7 +225,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,7 +1,27 @@
<template>
<RouterView />
<RouterView />
</template>
<script setup>
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
</script>
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { toast as sooner } from 'vue-sonner'
const emitter = useEmitter()
onMounted(() => {
initToaster()
})
const initToaster = () => {
emitter.on(EMITTER_EVENTS.SHOW_TOAST, (message) => {
if (message.variant === 'destructive') {
sooner.error(message.description)
} else {
sooner.success(message.description)
}
})
}
</script>

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

@@ -33,11 +33,26 @@ http.interceptors.request.use((request) => {
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 getPriorities = () => http.get('/api/v1/priorities')
@@ -81,8 +96,16 @@ 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, {
@@ -110,31 +133,31 @@ const updateSettings = (key, data) =>
const getSettings = (key) => http.get(`/api/v1/settings/${key}`)
const login = (data) => http.post(`/api/v1/login`, data)
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/automation/rules/execution-mode`, data)
const updateAutomationRulesExecutionMode = (data) => http.put(`/api/v1/automations/rules/execution-mode`, data)
const getRoles = () => http.get('/api/v1/roles')
const getRole = (id) => http.get(`/api/v1/roles/${id}`)
const createRole = (data) =>
@@ -150,29 +173,69 @@ const updateRole = (id, data) =>
}
})
const deleteRole = (id) => http.delete(`/api/v1/roles/${id}`)
const getUser = (id) => http.get(`/api/v1/users/${id}`)
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 blockContact = (id, data) => http.put(`/api/v1/contacts/${id}/block`, data)
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 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/users/me', data, {
http.put('/api/v1/agents/me', 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 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)
const resetPassword = (data) => http.post('/api/v1/agents/reset-password', data)
const setPassword = (data) => http.post('/api/v1/agents/set-password', data)
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 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)
const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data)
const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)
@@ -220,18 +283,6 @@ const uploadMedia = (data) =>
const getOverviewCounts = () => http.get('/api/v1/reports/overview/counts')
const getOverviewCharts = () => http.get('/api/v1/reports/overview/charts')
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,6 +315,11 @@ 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 updateAIProvider = (data) => http.put('/api/v1/ai/provider', data)
const getContactNotes = (id) => http.get(`/api/v1/contacts/${id}/notes`)
const createContactNote = (id, data) => http.post(`/api/v1/contacts/${id}/notes`, data)
const deleteContactNote = (id, noteId) => http.delete(`/api/v1/contacts/${id}/notes/${noteId}`)
const getActivityLogs = (params) => http.get('/api/v1/activity-logs', { params })
export default {
login,
@@ -320,15 +376,20 @@ export default {
updateConversationStatus,
updateConversationPriority,
upsertTags,
updateConversationCustomAttribute,
updateContactCustomAttribute,
uploadMedia,
updateAssigneeLastSeen,
updateUser,
updateCurrentUserAvailability,
updateAutomationRule,
updateAutomationRuleWeights,
updateAutomationRulesExecutionMode,
updateAIProvider,
createAutomationRule,
toggleAutomationRule,
deleteAutomationRule,
createConversation,
sendMessage,
retryMessage,
createUser,
@@ -373,5 +434,19 @@ export default {
aiCompletion,
searchConversations,
searchMessages,
searchContacts,
removeAssignee,
getContacts,
getContact,
updateContact,
blockContact,
getCustomAttributes,
createCustomAttribute,
updateCustomAttribute,
deleteCustomAttribute,
getCustomAttribute,
getContactNotes,
createContactNote,
deleteContactNote,
getActivityLogs
}

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
@@ -192,84 +208,9 @@
// End Scrollbar
.code-editor {
@apply rounded-md border shadow h-[65vh] min-h-[250px] w-full relative;
@apply rounded 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,33 +223,13 @@ a[data-active='false']:hover {
}
}
.dot-loader {
display: inline-flex;
align-items: center;
[data-radix-popper-content-wrapper] {
z-index: 9999 !important;
}
.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;
// 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

@@ -0,0 +1,411 @@
<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="isBold = !isBold"
:active="isBold"
:class="{ 'bg-gray-200 dark:bg-secondary': isBold }"
>
<Bold size="14" />
</Button>
<Button
size="sm"
variant="ghost"
@click.prevent="isItalic = !isItalic"
:active="isItalic"
:class="{ 'bg-gray-200 dark:bg-secondary': isItalic }"
>
<Italic size="14" />
</Button>
<Button
size="sm"
variant="ghost"
@click.prevent="toggleBulletList"
:class="{ 'bg-gray-200 dark:bg-secondary': editor?.isActive('bulletList') }"
>
<List size="14" />
</Button>
<Button
size="sm"
variant="ghost"
@click.prevent="toggleOrderedList"
: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, watchEffect, onUnmounted, computed } 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 selectedText = defineModel('selectedText', { default: '' })
const textContent = defineModel('textContent')
const htmlContent = defineModel('htmlContent')
const isBold = defineModel('isBold')
const isItalic = defineModel('isItalic')
const cursorPosition = defineModel('cursorPosition', { default: 0 })
const showLinkInput = ref(false)
const linkUrl = ref('')
const props = defineProps({
placeholder: String,
contentToSet: String,
setInlineImage: Object,
insertContent: String,
clearContent: Boolean,
autoFocus: {
type: Boolean,
default: true
},
aiPrompts: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['send', 'aiPromptSelected'])
const emitPrompt = (key) => emit('aiPromptSelected', key)
const getSelectionText = (from, to, doc) => doc.textBetween(from, to)
// 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 editorConfig = computed(() => ({
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,
editorProps: {
attributes: { class: 'outline-none' },
handleKeyDown: (view, event) => {
if (event.ctrlKey && event.key === 'Enter') {
emit('send')
return true
}
if (event.ctrlKey && event.key.toLowerCase() === 'b') {
// Prevent outer listeners
event.stopPropagation()
return false
}
}
}
}))
const editor = ref(
useEditor({
...editorConfig.value,
content: htmlContent.value,
onSelectionUpdate: ({ editor }) => {
const { from, to } = editor.state.selection
selectedText.value = getSelectionText(from, to, editor.state.doc)
},
onUpdate: ({ editor }) => {
htmlContent.value = editor.getHTML()
textContent.value = editor.getText()
cursorPosition.value = editor.state.selection.from
},
onCreate: ({ editor }) => {
if (cursorPosition.value) {
editor.commands.setTextSelection(cursorPosition.value)
}
}
})
)
watchEffect(() => {
const editorInstance = editor.value
if (!editorInstance) return
isBold.value = editorInstance.isActive('bold')
isItalic.value = editorInstance.isActive('italic')
})
watchEffect(() => {
const editorInstance = editor.value
if (!editorInstance) return
if (isBold.value !== editorInstance.isActive('bold')) {
isBold.value
? editorInstance.chain().focus().setBold().run()
: editorInstance.chain().focus().unsetBold().run()
}
if (isItalic.value !== editorInstance.isActive('italic')) {
isItalic.value
? editorInstance.chain().focus().setItalic().run()
: editorInstance.chain().focus().unsetItalic().run()
}
})
watch(
() => props.contentToSet,
(newContentData) => {
if (!newContentData) return
try {
const parsedData = JSON.parse(newContentData)
const content = parsedData.content
if (content === '') {
editor.value?.commands.clearContent()
} else {
editor.value?.commands.setContent(content, true)
}
editor.value?.commands.focus()
} catch (e) {
console.error('Error parsing content data', e)
}
}
)
watch(cursorPosition, (newPos, oldPos) => {
if (editor.value && newPos !== oldPos && newPos !== editor.value.state.selection.from) {
editor.value.commands.setTextSelection(newPos)
}
})
watch(
() => props.clearContent,
() => {
if (!props.clearContent) return
editor.value?.commands.clearContent()
editor.value?.commands.focus()
// `onUpdate` is not called when clearing content, so need to reset the content here.
htmlContent.value = ''
textContent.value = ''
cursorPosition.value = 0
}
)
watch(
() => props.setInlineImage,
(val) => {
if (val) {
editor.value?.commands.setImage({
src: val.src,
alt: val.alt,
title: val.title
})
}
}
)
watch(
() => props.insertContent,
(val) => {
if (val) editor.value?.commands.insertContent(val)
}
)
onUnmounted(() => {
editor.value?.destroy()
})
const toggleBulletList = () => {
if (editor.value) {
editor.value.chain().focus().toggleBulletList().run()
}
}
const toggleOrderedList = () => {
if (editor.value) {
editor.value.chain().focus().toggleOrderedList().run()
}
}
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,205 @@
<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 class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue :placeholder="t('form.field.selectField')" />
</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 class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue :placeholder="t('form.field.selectOperator')" />
</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('form.field.select')"
type="user"
/>
<SelectComboBox
v-else-if="
getFieldOptions(modelFilter).length > 0 &&
modelFilter.field === 'assigned_team_id'
"
v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)"
:placeholder="t('form.field.select')"
type="team"
/>
<SelectComboBox
v-else-if="getFieldOptions(modelFilter).length > 0"
v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)"
:placeholder="t('form.field.select')"
/>
<Input
v-else
v-model="modelFilter.value"
class="bg-transparent hover:bg-slate-100"
: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.buttons.reset') }}</Button>
<Button @click="applyFilters">{{ $t('globals.buttons.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" />

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,5 +1,10 @@
<script setup>
import { adminNavItems, reportsNavItems, accountNavItems } from '@/constants/navigation'
import {
adminNavItems,
reportsNavItems,
accountNavItems,
contactNavItems
} from '@/constants/navigation'
import { RouterLink, useRoute } from 'vue-router'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import {
@@ -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,
@@ -36,6 +41,7 @@ import {
import { filterNavItems } from '@/utils/nav-permissions'
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user'
defineProps({
@@ -43,8 +49,10 @@ defineProps({
userViews: { type: Array, default: () => [] }
})
const userStore = useUserStore()
const settingsStore = useAppSettingsStore()
const route = useRoute()
const emit = defineEmits(['createView', 'editView', 'deleteView'])
const { t } = useI18n()
const emit = defineEmits(['createView', 'editView', 'deleteView', 'createConversation'])
const openCreateViewDialog = () => {
emit('createView')
@@ -60,6 +68,7 @@ const deleteView = (view) => {
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)
@@ -70,6 +79,8 @@ const isInboxRoute = (path) => {
}
const sidebarOpen = useStorage('mainSidebarOpen', true)
const teamInboxOpen = useStorage('teamInboxOpen', true)
const viewInboxOpen = useStorage('viewInboxOpen', true)
</script>
<template>
@@ -78,6 +89,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 +137,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,26 +168,29 @@ 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>
@@ -151,7 +201,7 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
>
<CollapsibleTrigger as-child>
<SidebarMenuButton :isActive="isActiveParent(item.href)">
<span>{{ item.title }}</span>
<span>{{ t(item.titleKey) }}</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
@@ -159,10 +209,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 +233,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,34 +267,42 @@ 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>
<User />
<span>{{ t('globals.terms.myInbox') }}</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -253,8 +310,10 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/unassigned')">
<router-link :to="{ name: 'inbox', params: { type: 'unassigned' } }">
<UserSearch />
<span>Unassigned</span>
<CircleDashed />
<span>
{{ t('globals.terms.unassigned') }}
</span>
</router-link>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -262,20 +321,29 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/all')">
<router-link :to="{ name: 'inbox', params: { type: 'all' } }">
<UsersRound />
<span>All</span>
<List />
<span>
{{ t('globals.messages.all') }}
</span>
</router-link>
</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"
/>
@@ -301,31 +369,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>
@@ -335,25 +402,24 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
asChild
>
<router-link :to="{ name: 'view-inbox', params: { viewID: view.id } }">
<span class="break-all w-24">{{ view.name }}</span>
<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.buttons.edit') }}</span>
</DropdownMenuItem>
<DropdownMenuItem @click="() => deleteView(view)">
<span>{{ t('globals.buttons.delete') }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuAction>
</router-link>
</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

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

View File

@@ -1,53 +1,84 @@
<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 scope="col" class="relative px-6 py-3"></th>
</tr>
</thead>
<tbody class="bg-background divide-y divide-border">
<template v-if="data.length === 0">
<tr>
<td :colspan="headers.length + 1" 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>
<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 { defineProps, defineEmits } from 'vue'
import { Button } from '@/components/ui/button'
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
}
})
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,24 +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 { 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>
@@ -26,14 +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"
>
<span v-if="isLoading" class="dot-loader">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
<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>
<slot v-else />
</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

@@ -1,94 +0,0 @@
<script setup>
import { VisDonut, VisSingleContainer } from '@unovis/vue'
import { Donut } from '@unovis/ts'
import { computed, ref } from 'vue'
import { useMounted } from '@vueuse/core'
import { ChartSingleTooltip, defaultColors } from '@/components/ui/chart'
import { cn } from '@/lib/utils'
const props = defineProps({
data: { type: Array, required: true },
colors: { type: Array, required: false },
index: { type: null, required: true },
margin: {
type: null,
required: false,
default: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
},
showLegend: { type: Boolean, required: false, default: true },
showTooltip: { type: Boolean, required: false, default: true },
filterOpacity: { type: Number, required: false, default: 0.2 },
category: { type: String, required: true },
type: { type: String, required: false, default: 'donut' },
sortFunction: { type: Function, required: false, default: () => undefined },
valueFormatter: { type: Function, required: false, default: (tick) => `${tick}` },
customTooltip: { type: null, required: false }
})
const category = computed(() => props.category)
const index = computed(() => props.index)
const isMounted = useMounted()
const activeSegmentKey = ref()
const colors = computed(() =>
props.colors?.length
? props.colors
: defaultColors(props.data.filter((d) => d[props.category]).filter(Boolean).length)
)
const legendItems = computed(() =>
props.data.map((item, i) => ({
name: item[props.index],
color: colors.value[i],
inactive: false
}))
)
const totalValue = computed(() =>
props.data.reduce((prev, curr) => {
return prev + curr[props.category]
}, 0)
)
</script>
<template>
<div :class="cn('w-full h-48 flex flex-col items-end', $attrs.class ?? '')">
<VisSingleContainer
:style="{ height: isMounted ? '100%' : 'auto' }"
:margin="{ left: 20, right: 20 }"
:data="data"
>
<ChartSingleTooltip
:selector="Donut.selectors.segment"
:index="category"
:items="legendItems"
:value-formatter="valueFormatter"
:custom-tooltip="customTooltip"
/>
<VisDonut
:value="(d) => d[category]"
:sort-function="sortFunction"
:color="colors"
:arc-width="type === 'donut' ? 20 : 0"
:show-background="false"
:central-label="type === 'donut' ? valueFormatter(totalValue) : ''"
:events="{
[Donut.selectors.segment]: {
click: (d, ev, i, elements) => {
if (d?.data?.[index] === activeSegmentKey) {
activeSegmentKey = undefined
elements.forEach((el) => (el.style.opacity = '1'))
} else {
activeSegmentKey = d?.data?.[index]
elements.forEach((el) => (el.style.opacity = `${filterOpacity}`))
elements[i].style.opacity = '1'
}
}
}
}"
/>
<slot />
</VisSingleContainer>
</div>
</template>

View File

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

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

@@ -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,9 +1,11 @@
<template>
<div class="flex flex-col items-center justify-center text-gray-600 dark:text-gray-300">
<span class="dot-loader">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</span>
</div>
<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

@@ -1,43 +0,0 @@
<script setup>
import { computed } from 'vue'
import { SplitterResizeHandle, useForwardPropsEmits } from 'radix-vue'
import { DragHandleDots2Icon } from '@radix-icons/vue'
import { cn } from '@/lib/utils'
const props = defineProps({
id: { type: String, required: false },
hitAreaMargins: { type: Object, required: false },
tabindex: { type: Number, required: false },
disabled: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
withHandle: { type: Boolean, required: false }
})
const emits = defineEmits(['dragging'])
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SplitterResizeHandle
v-bind="forwarded"
:class="
cn(
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 [&[data-orientation=vertical]]:h-px [&[data-orientation=vertical]]:w-full [&[data-orientation=vertical]]:after:left-0 [&[data-orientation=vertical]]:after:h-1 [&[data-orientation=vertical]]:after:w-full [&[data-orientation=vertical]]:after:-translate-y-1/2 [&[data-orientation=vertical]]:after:translate-x-0 [&[data-orientation=vertical]>div]:rotate-90',
props.class
)
"
>
<template v-if="props.withHandle">
<div class="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<DragHandleDots2Icon class="h-2.5 w-2.5" />
</div>
</template>
</SplitterResizeHandle>
</template>

View File

@@ -1,33 +0,0 @@
<script setup>
import { computed } from 'vue'
import { SplitterGroup, useForwardPropsEmits } from 'radix-vue'
import { cn } from '@/lib/utils'
const props = defineProps({
id: { type: [String, null], required: false },
autoSaveId: { type: [String, null], required: false },
direction: { type: String, required: true },
keyboardResizeBy: { type: [Number, null], required: false },
storage: { type: Object, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
})
const emits = defineEmits(['layout'])
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SplitterGroup
v-bind="forwarded"
:class="cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', props.class)"
>
<slot />
</SplitterGroup>
</template>

View File

@@ -1,3 +0,0 @@
export { default as ResizablePanelGroup } from './ResizablePanelGroup.vue'
export { default as ResizableHandle } from './ResizableHandle.vue'
export { SplitterPanel as ResizablePanel } from 'radix-vue'

View File

@@ -1,31 +0,0 @@
<script setup>
import { computed } from 'vue'
import { ScrollAreaCorner, ScrollAreaRoot, ScrollAreaViewport } from 'radix-vue'
import ScrollBar from './ScrollBar.vue'
import { cn } from '@/lib/utils'
const props = defineProps({
type: { type: String, required: false },
dir: { type: String, required: false },
scrollHideDelay: { type: Number, required: false },
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>
<ScrollAreaRoot v-bind="delegatedProps" :class="cn('relative overflow-hidden', props.class)">
<ScrollAreaViewport class="h-full w-full rounded-[inherit]">
<slot />
</ScrollAreaViewport>
<ScrollBar />
<ScrollAreaCorner />
</ScrollAreaRoot>
</template>

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