From 5b6a58fba0bdcef46d350db8da3b6f76d726a28e Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sun, 29 Jun 2025 04:59:55 +0530 Subject: [PATCH 01/55] wip: intercom like live chat with chat widget - new vue app for serving live chat widget, created subdirectories inside frontend dir `main` and `widget` - vite changes for both main app and widget app. - new backend live chat channel - apis for live chat widget --- Makefile | 43 +- cmd/chat.go | 564 +++++++++++++ cmd/conversation.go | 3 - cmd/handlers.go | 78 +- cmd/inboxes.go | 8 +- cmd/init.go | 34 +- cmd/main.go | 3 +- cmd/messages.go | 3 +- cmd/upgrade.go | 1 + cmd/widget_ws.go | 208 +++++ frontend/README-SETUP.md | 59 ++ frontend/{ => apps/main}/index.html | 0 frontend/{ => apps/main}/src/App.vue | 38 +- frontend/{ => apps/main}/src/OuterApp.vue | 4 +- frontend/{ => apps/main}/src/Root.vue | 4 +- frontend/{ => apps/main}/src/api/index.js | 0 .../main}/src/assets/styles/main.scss | 0 .../src/components/button/CloseButton.vue | 2 +- .../components/combobox/SelectCombobox.vue | 4 +- .../src/components/datatable/DataTable.vue | 2 +- .../src/components/editor/CodeEditor.vue | 0 .../src/components/editor/TextEditor.vue | 6 +- .../src/components/filter/FilterBuilder.vue | 10 +- .../main}/src/components/layout/MenuCard.vue | 0 .../src/components/layout/PageHeader.vue | 4 +- .../main}/src/components/sidebar/Sidebar.vue | 16 +- .../src/components/sidebar/SidebarNavUser.vue | 10 +- .../src/components/table/SimpleTable.vue | 4 +- .../main}/src/components/update/AppUpdate.vue | 2 +- .../src/composables/useActivityLogFilters.js | 4 +- .../src/composables/useConversationFilters.js | 14 +- .../main}/src/composables/useEmitter.js | 0 .../main}/src/composables/useFileUpload.js | 8 +- .../main}/src/composables/useIdleDetection.js | 4 +- .../{ => apps/main}/src/composables/useSla.js | 2 +- .../src/composables/useTemporaryClass.js | 0 .../main}/src/constants/conversation.js | 0 .../main}/src/constants/countries.js | 0 .../{ => apps/main}/src/constants/date.js | 0 .../main}/src/constants/emitterEvents.js | 0 .../main}/src/constants/filterConfig.js | 0 .../main}/src/constants/navigation.js | 0 .../main}/src/constants/permissions.js | 0 .../main}/src/constants/timezones.js | 0 .../{ => apps/main}/src/constants/user.js | 0 .../main}/src/constants/websocket.js | 0 .../admin/activity-log/ActivityLog.vue | 18 +- .../src/features/admin/agents/AgentForm.vue | 28 +- .../features/admin/agents/dataTableColumns.js | 0 .../admin/agents/dataTableDropdown.vue | 14 +- .../src/features/admin/agents/formSchema.js | 0 .../src/features/admin/agents/schema.test.js | 0 .../features/admin/automation/ActionBox.vue | 18 +- .../admin/automation/AutomationTabs.vue | 2 +- .../src/features/admin/automation/RuleBox.vue | 20 +- .../features/admin/automation/RuleList.vue | 6 +- .../src/features/admin/automation/RuleTab.vue | 6 +- .../features/admin/automation/formSchema.js | 0 .../business-hours/BusinessHoursForm.vue | 24 +- .../admin/business-hours/dataTableColumns.js | 0 .../business-hours/dataTableDropdown.vue | 12 +- .../admin/business-hours/formSchema.js | 0 .../CustomAttributesForm.vue | 8 +- .../custom-attributes/dataTableColumns.js | 0 .../custom-attributes/dataTableDropdown.vue | 14 +- .../admin/custom-attributes/formSchema.js | 0 .../admin/general/GeneralSettingForm.vue | 20 +- .../src/features/admin/general/formSchema.js | 0 .../features/admin/inbox/EmailInboxForm.vue | 10 +- .../admin/inbox/InboxDataTableDropDown.vue | 6 +- .../admin/inbox/LivechatInboxForm.vue | 752 ++++++++++++++++++ .../src/features/admin/inbox/formSchema.js | 2 +- .../admin/inbox/livechatFormSchema.js | 57 ++ .../features/admin/macros/ActionBuilder.vue | 12 +- .../src/features/admin/macros/MacroForm.vue | 22 +- .../features/admin/macros/dataTableColumns.js | 0 .../admin/macros/dataTableDropdown.vue | 12 +- .../src/features/admin/macros/formSchema.js | 2 +- .../notification/NotificationSetting.vue | 10 +- .../notification/NotificationSettingForm.vue | 14 +- .../features/admin/notification/formSchema.js | 2 +- .../src/features/admin/oidc/OIDCForm.vue | 12 +- .../features/admin/oidc/dataTableColumns.js | 0 .../features/admin/oidc/dataTableDropdown.vue | 12 +- .../src/features/admin/oidc/formSchema.js | 0 .../src/features/admin/roles/RoleForm.vue | 10 +- .../features/admin/roles/dataTableColumns.js | 0 .../admin/roles/dataTableDropdown.vue | 16 +- .../src/features/admin/roles/formSchema.js | 0 .../main}/src/features/admin/sla/SLAForm.vue | 12 +- .../features/admin/sla/dataTableColumns.js | 0 .../features/admin/sla/dataTableDropdown.vue | 14 +- .../src/features/admin/sla/formSchema.js | 2 +- .../src/features/admin/status/StatusForm.vue | 4 +- .../features/admin/status/dataTableColumns.js | 0 .../admin/status/dataTableDropdown.vue | 18 +- .../src/features/admin/status/formSchema.js | 0 .../src/features/admin/tags/TagsForm.vue | 4 +- .../features/admin/tags/dataTableColumns.js | 0 .../features/admin/tags/dataTableDropdown.vue | 14 +- .../src/features/admin/tags/formSchema.js | 0 .../admin/teams/TeamDataTableDropdown.vue | 14 +- .../src/features/admin/teams/TeamForm.vue | 20 +- .../admin/teams/TeamsDataTableColumns.js | 0 .../features/admin/teams/teamFormSchema.js | 0 .../features/admin/templates/TemplateForm.vue | 12 +- .../admin/templates/dataTableColumns.js | 0 .../admin/templates/dataTableDropdown.vue | 14 +- .../features/admin/templates/formSchema.js | 0 .../features/admin/webhooks/WebhookForm.vue | 8 +- .../admin/webhooks/dataTableColumns.js | 2 +- .../admin/webhooks/dataTableDropdown.vue | 14 +- .../src/features/admin/webhooks/formSchema.js | 0 .../main}/src/features/command/CommandBox.vue | 24 +- .../src/features/contact/ContactForm.vue | 12 +- .../src/features/contact/ContactNotes.vue | 24 +- .../src/features/contact/ContactsList.vue | 28 +- .../main}/src/features/contact/formSchema.js | 0 .../features/conversation/Conversation.vue | 12 +- .../conversation/ConversationPlaceholder.vue | 0 .../conversation/CreateConversation.vue | 34 +- .../conversation/MacroActionsPreview.vue | 2 +- .../src/features/conversation/ReplyBox.vue | 22 +- .../features/conversation/ReplyBoxContent.vue | 114 +-- .../features/conversation/ReplyBoxMenuBar.vue | 4 +- .../list/ConversationEmptyList.vue | 0 .../conversation/list/ConversationList.vue | 8 +- .../list/ConversationListItem.vue | 4 +- .../list/ConversationListItemSkeleton.vue | 2 +- .../message/ActivityMessageBubble.vue | 2 +- .../message/AgentMessageBubble.vue | 12 +- .../message/ContactMessageBubble.vue | 8 +- .../conversation/message/MessageEnvelope.vue | 0 .../conversation/message/MessageList.vue | 10 +- .../conversation/message/MessagesSkeleton.vue | 2 +- .../message/attachment/AttachmentsPreview.vue | 6 +- .../attachment/FileAttachmentPreview.vue | 2 +- .../attachment/ImageAttachmentPreview.vue | 2 +- .../attachment/MessageAttachmentPreview.vue | 0 .../conversation/sidebar/ConversationInfo.vue | 14 +- .../sidebar/ConversationSideBar.vue | 22 +- .../sidebar/ConversationSideBarContact.vue | 19 +- .../sidebar/ConversationSideBarWrapper.vue | 6 +- .../conversation/sidebar/CustomAttributes.vue | 10 +- .../sidebar/PreviousConversations.vue | 2 +- .../src/features/reports/OverviewBarChart.vue | 2 +- .../src/features/reports/OverviewCard.vue | 0 .../features/reports/OverviewLineChart.vue | 2 +- .../src/features/search/SearchHeader.vue | 6 +- .../src/features/search/SearchResults.vue | 0 .../main}/src/features/sla/SlaBadge.vue | 2 +- .../main}/src/features/view/ViewForm.vue | 22 +- .../src/layouts/account/AccountLayout.vue | 0 .../main}/src/layouts/admin/ActivityLog.vue | 0 .../main}/src/layouts/admin/AdminLayout.vue | 0 .../src/layouts/admin/AdminPageWithHelp.vue | 0 .../main}/src/layouts/auth/AuthLayout.vue | 0 .../src/layouts/contact/ContactDetail.vue | 0 .../main}/src/layouts/contact/ContactList.vue | 0 .../main}/src/layouts/inbox/InboxLayout.vue | 0 frontend/{ => apps/main}/src/main.js | 0 frontend/{ => apps/main}/src/router/index.js | 136 ++-- .../{ => apps/main}/src/stores/appSettings.js | 0 .../main}/src/stores/conversation.js | 14 +- .../main}/src/stores/customAttributes.js | 8 +- frontend/{ => apps/main}/src/stores/inbox.js | 8 +- frontend/{ => apps/main}/src/stores/macro.js | 10 +- frontend/{ => apps/main}/src/stores/sla.js | 2 +- frontend/{ => apps/main}/src/stores/tag.js | 8 +- frontend/{ => apps/main}/src/stores/team.js | 8 +- frontend/{ => apps/main}/src/stores/user.js | 12 +- frontend/{ => apps/main}/src/stores/users.js | 8 +- .../src/utils/conversation-message-cache.js | 0 .../{ => apps/main}/src/utils/datetime.js | 0 .../{ => apps/main}/src/utils/debounce.js | 0 .../main}/src/utils/email-recipients.js | 0 frontend/{ => apps/main}/src/utils/file.js | 0 frontend/{ => apps/main}/src/utils/http.js | 0 .../main}/src/utils/nav-permissions.js | 0 .../{ => apps/main}/src/utils/pagination.js | 0 frontend/{ => apps/main}/src/utils/sla.js | 0 frontend/{ => apps/main}/src/utils/strings.js | 0 .../views/account/profile/ProfileEditView.vue | 16 +- .../views/admin/activity-log/ActivityLog.vue | 0 .../src/views/admin/agents/AgentList.vue | 16 +- .../main}/src/views/admin/agents/Agents.vue | 0 .../src/views/admin/agents/CreateAgent.vue | 10 +- .../src/views/admin/agents/EditAgent.vue | 12 +- .../views/admin/automations/Automation.vue | 2 +- .../admin/automations/CreateOrEditRule.vue | 28 +- .../admin/business-hours/BusinessHours.vue | 0 .../business-hours/BusinessHoursList.vue | 14 +- .../CreateOrEditBusinessHours.vue | 12 +- .../custom-attributes/CustomAttributes.vue | 22 +- .../main}/src/views/admin/general/General.vue | 4 +- .../main}/src/views/admin/inbox/EditInbox.vue | 70 +- .../main}/src/views/admin/inbox/InboxList.vue | 14 +- .../main}/src/views/admin/inbox/InboxView.vue | 0 .../main}/src/views/admin/inbox/NewInbox.vue | 46 +- .../src/views/admin/macros/CreateMacro.vue | 10 +- .../src/views/admin/macros/EditMacro.vue | 12 +- .../src/views/admin/macros/MacroList.vue | 16 +- .../main}/src/views/admin/macros/Macros.vue | 0 .../src/views/admin/oidc/CreateEditOIDC.vue | 12 +- .../main}/src/views/admin/oidc/OIDC.vue | 0 .../main}/src/views/admin/oidc/OIDCList.vue | 14 +- .../main}/src/views/admin/roles/EditRole.vue | 12 +- .../main}/src/views/admin/roles/NewRole.vue | 10 +- .../main}/src/views/admin/roles/RoleList.vue | 16 +- .../main}/src/views/admin/roles/Roles.vue | 0 .../src/views/admin/sla/CreateEditSLA.vue | 12 +- .../main}/src/views/admin/sla/SLA.vue | 0 .../main}/src/views/admin/sla/SLAList.vue | 14 +- .../src/views/admin/status/StatusView.vue | 18 +- .../main}/src/views/admin/tags/TagsView.vue | 18 +- .../src/views/admin/teams/CreateTeamForm.vue | 10 +- .../src/views/admin/teams/EditTeamForm.vue | 12 +- .../main}/src/views/admin/teams/TeamList.vue | 16 +- .../main}/src/views/admin/teams/Teams.vue | 0 .../admin/templates/CreateEditTemplate.vue | 12 +- .../src/views/admin/templates/Templates.vue | 18 +- .../admin/webhooks/CreateEditWebhook.vue | 16 +- .../src/views/admin/webhooks/WebhookList.vue | 14 +- .../src/views/admin/webhooks/Webhooks.vue | 0 .../src/views/auth/ResetPasswordView.vue | 22 +- .../main}/src/views/auth/SetPasswordView.vue | 20 +- .../main}/src/views/auth/UserLoginView.vue | 26 +- .../src/views/contact/ContactDetailView.vue | 22 +- .../main}/src/views/contact/ContactsView.vue | 0 .../conversation/ConversationDetailView.vue | 2 +- .../main}/src/views/inbox/InboxView.vue | 4 +- .../main}/src/views/reports/OverviewView.vue | 14 +- .../main}/src/views/search/SearchView.vue | 8 +- frontend/{ => apps/main}/src/websocket.js | 0 frontend/apps/widget/index.html | 19 + frontend/apps/widget/src/App.vue | 70 ++ frontend/apps/widget/src/api/index.js | 43 + frontend/apps/widget/src/assets/widget.css | 0 frontend/apps/widget/src/main.js | 12 + frontend/apps/widget/src/router/index.js | 23 + frontend/apps/widget/src/store/chat.js | 81 ++ frontend/apps/widget/src/store/widget.js | 63 ++ frontend/apps/widget/src/views/ChatView.vue | 214 +++++ .../apps/widget/src/views/WelcomeView.vue | 87 ++ frontend/components.json | 6 +- frontend/jsconfig.json | 5 +- frontend/package.json | 5 +- frontend/shared-ui/assets/styles/main.scss | 235 ++++++ frontend/shared-ui/components/index.js | 14 + .../components/ui/accordion/Accordion.vue | 0 .../ui/accordion/AccordionContent.vue | 2 +- .../components/ui/accordion/AccordionItem.vue | 2 +- .../ui/accordion/AccordionTrigger.vue | 2 +- .../components/ui/accordion/index.js | 0 .../ui/alert-dialog/AlertDialog.vue | 0 .../ui/alert-dialog/AlertDialogAction.vue | 4 +- .../ui/alert-dialog/AlertDialogCancel.vue | 4 +- .../ui/alert-dialog/AlertDialogContent.vue | 2 +- .../alert-dialog/AlertDialogDescription.vue | 2 +- .../ui/alert-dialog/AlertDialogFooter.vue | 2 +- .../ui/alert-dialog/AlertDialogHeader.vue | 2 +- .../ui/alert-dialog/AlertDialogTitle.vue | 2 +- .../ui/alert-dialog/AlertDialogTrigger.vue | 0 .../components/ui/alert-dialog/index.js | 0 .../components/ui/alert/Alert.vue | 2 +- .../components/ui/alert/AlertDescription.vue | 2 +- .../components/ui/alert/AlertTitle.vue | 2 +- .../components/ui/alert/index.js | 0 .../components/ui/auto-form/AutoForm.vue | 2 +- .../components/ui/auto-form/AutoFormField.vue | 0 .../ui/auto-form/AutoFormFieldArray.vue | 8 +- .../ui/auto-form/AutoFormFieldBoolean.vue | 6 +- .../ui/auto-form/AutoFormFieldDate.vue | 10 +- .../ui/auto-form/AutoFormFieldEnum.vue | 8 +- .../ui/auto-form/AutoFormFieldFile.vue | 6 +- .../ui/auto-form/AutoFormFieldInput.vue | 6 +- .../ui/auto-form/AutoFormFieldNumber.vue | 4 +- .../ui/auto-form/AutoFormFieldObject.vue | 4 +- .../components/ui/auto-form/AutoFormLabel.vue | 2 +- .../components/ui/auto-form/constant.js | 0 .../components/ui/auto-form/dependencies.js | 0 .../components/ui/auto-form/index.js | 0 .../components/ui/auto-form/interface.js | 0 .../components/ui/auto-form/utils.js | 0 .../components/ui/avatar/Avatar.vue | 2 +- .../components/ui/avatar/AvatarFallback.vue | 0 .../components/ui/avatar/AvatarImage.vue | 0 .../components/ui/avatar/AvatarUpload.vue | 2 +- .../components/ui/avatar/index.js | 0 .../components/ui/badge/Badge.vue | 2 +- .../components/ui/badge/index.js | 0 .../components/ui/breadcrumb/Breadcrumb.vue | 0 .../ui/breadcrumb/BreadcrumbEllipsis.vue | 2 +- .../ui/breadcrumb/BreadcrumbItem.vue | 2 +- .../ui/breadcrumb/BreadcrumbLink.vue | 2 +- .../ui/breadcrumb/BreadcrumbList.vue | 2 +- .../ui/breadcrumb/BreadcrumbPage.vue | 2 +- .../ui/breadcrumb/BreadcrumbSeparator.vue | 2 +- .../components/ui/breadcrumb/Custom.vue | 2 +- .../components/ui/breadcrumb/index.js | 0 .../components/ui/button/Button.vue | 2 +- .../components/ui/button/index.js | 0 .../components/ui/calendar/Calendar.vue | 2 +- .../components/ui/calendar/CalendarCell.vue | 2 +- .../ui/calendar/CalendarCellTrigger.vue | 4 +- .../components/ui/calendar/CalendarGrid.vue | 2 +- .../ui/calendar/CalendarGridBody.vue | 0 .../ui/calendar/CalendarGridHead.vue | 0 .../ui/calendar/CalendarGridRow.vue | 2 +- .../ui/calendar/CalendarHeadCell.vue | 2 +- .../components/ui/calendar/CalendarHeader.vue | 2 +- .../ui/calendar/CalendarHeading.vue | 2 +- .../ui/calendar/CalendarNextButton.vue | 4 +- .../ui/calendar/CalendarPrevButton.vue | 4 +- .../components/ui/calendar/index.js | 0 .../components/ui/card/Card.vue | 2 +- .../components/ui/card/CardContent.vue | 2 +- .../components/ui/card/CardDescription.vue | 2 +- .../components/ui/card/CardFooter.vue | 2 +- .../components/ui/card/CardHeader.vue | 2 +- .../components/ui/card/CardTitle.vue | 2 +- .../components/ui/card/index.js | 0 .../components/ui/chart-bar/BarChart.vue | 4 +- .../components/ui/chart-bar/index.js | 0 .../components/ui/chart-line/LineChart.vue | 4 +- .../components/ui/chart-line/index.js | 0 .../components/ui/chart/ChartCrosshair.vue | 0 .../components/ui/chart/ChartLegend.vue | 2 +- .../ui/chart/ChartSingleTooltip.vue | 0 .../components/ui/chart/ChartTooltip.vue | 2 +- .../components/ui/chart/index.js | 0 .../components/ui/chart/interface.js | 0 .../components/ui/checkbox/Checkbox.vue | 2 +- .../components/ui/checkbox/index.js | 0 .../components/ui/collapsible/Collapsible.vue | 0 .../ui/collapsible/CollapsibleContent.vue | 0 .../ui/collapsible/CollapsibleTrigger.vue | 0 .../components/ui/collapsible/index.js | 0 .../components/ui/combobox/ComboBox.vue | 8 +- .../components/ui/command/Command.vue | 2 +- .../components/ui/command/CommandDialog.vue | 2 +- .../components/ui/command/CommandEmpty.vue | 2 +- .../components/ui/command/CommandGroup.vue | 2 +- .../components/ui/command/CommandInput.vue | 2 +- .../components/ui/command/CommandItem.vue | 2 +- .../components/ui/command/CommandList.vue | 2 +- .../ui/command/CommandSeparator.vue | 2 +- .../components/ui/command/CommandShortcut.vue | 2 +- .../components/ui/command/index.js | 0 .../components/ui/date-filter/DateFilter.vue | 4 +- .../components/ui/date-filter/index.js | 0 .../components/ui/dialog/Dialog.vue | 0 .../components/ui/dialog/DialogClose.vue | 0 .../components/ui/dialog/DialogContent.vue | 2 +- .../ui/dialog/DialogDescription.vue | 2 +- .../components/ui/dialog/DialogFooter.vue | 2 +- .../components/ui/dialog/DialogHeader.vue | 2 +- .../ui/dialog/DialogScrollContent.vue | 2 +- .../components/ui/dialog/DialogTitle.vue | 2 +- .../components/ui/dialog/DialogTrigger.vue | 0 .../components/ui/dialog/index.js | 0 .../ui/dropdown-menu/DropdownMenu.vue | 0 .../DropdownMenuCheckboxItem.vue | 2 +- .../ui/dropdown-menu/DropdownMenuContent.vue | 2 +- .../ui/dropdown-menu/DropdownMenuGroup.vue | 0 .../ui/dropdown-menu/DropdownMenuItem.vue | 2 +- .../ui/dropdown-menu/DropdownMenuLabel.vue | 2 +- .../dropdown-menu/DropdownMenuRadioGroup.vue | 0 .../dropdown-menu/DropdownMenuRadioItem.vue | 2 +- .../dropdown-menu/DropdownMenuSeparator.vue | 2 +- .../ui/dropdown-menu/DropdownMenuShortcut.vue | 2 +- .../ui/dropdown-menu/DropdownMenuSub.vue | 0 .../dropdown-menu/DropdownMenuSubContent.vue | 2 +- .../dropdown-menu/DropdownMenuSubTrigger.vue | 2 +- .../ui/dropdown-menu/DropdownMenuTrigger.vue | 0 .../components/ui/dropdown-menu/index.js | 0 .../components/ui/error/Error.vue | 2 +- .../components/ui/error/index.js | 0 .../components/ui/form/FormControl.vue | 0 .../components/ui/form/FormDescription.vue | 2 +- .../components/ui/form/FormItem.vue | 2 +- .../components/ui/form/FormLabel.vue | 4 +- .../components/ui/form/FormMessage.vue | 0 .../components/ui/form/index.js | 0 .../components/ui/form/injectionKeys.js | 0 .../components/ui/form/useFormField.js | 0 .../components/ui/input/Input.vue | 2 +- .../components/ui/input/index.js | 0 .../components/ui/label/Label.vue | 2 +- .../components/ui/label/index.js | 0 .../components/ui/loader/DotLoader.vue | 0 .../components/ui/loader/index.js | 0 .../ui/pagination/PaginationEllipsis.vue | 2 +- .../ui/pagination/PaginationFirst.vue | 4 +- .../ui/pagination/PaginationLast.vue | 4 +- .../ui/pagination/PaginationNext.vue | 4 +- .../ui/pagination/PaginationPrev.vue | 4 +- .../components/ui/pagination/index.js | 0 .../components/ui/popover/Popover.vue | 0 .../components/ui/popover/PopoverContent.vue | 2 +- .../components/ui/popover/PopoverTrigger.vue | 0 .../components/ui/popover/index.js | 0 .../components/ui/radio-group/RadioGroup.vue | 2 +- .../ui/radio-group/RadioGroupItem.vue | 2 +- .../components/ui/radio-group/index.js | 0 .../components/ui/select/Select.vue | 0 .../components/ui/select/SelectContent.vue | 2 +- .../components/ui/select/SelectGroup.vue | 2 +- .../components/ui/select/SelectItem.vue | 2 +- .../components/ui/select/SelectItemText.vue | 0 .../components/ui/select/SelectLabel.vue | 2 +- .../ui/select/SelectScrollDownButton.vue | 2 +- .../ui/select/SelectScrollUpButton.vue | 2 +- .../components/ui/select/SelectSeparator.vue | 2 +- .../components/ui/select/SelectTag.vue | 4 +- .../components/ui/select/SelectTrigger.vue | 2 +- .../components/ui/select/SelectValue.vue | 0 .../components/ui/select/index.js | 0 .../components/ui/separator/Separator.vue | 2 +- .../components/ui/separator/index.js | 0 .../components/ui/sheet/Sheet.vue | 0 .../components/ui/sheet/SheetClose.vue | 0 .../components/ui/sheet/SheetContent.vue | 2 +- .../components/ui/sheet/SheetDescription.vue | 2 +- .../components/ui/sheet/SheetFooter.vue | 2 +- .../components/ui/sheet/SheetHeader.vue | 2 +- .../components/ui/sheet/SheetTitle.vue | 2 +- .../components/ui/sheet/SheetTrigger.vue | 0 .../components/ui/sheet/index.js | 0 .../components/ui/sidebar/Sidebar.vue | 4 +- .../components/ui/sidebar/SidebarContent.vue | 2 +- .../components/ui/sidebar/SidebarFooter.vue | 2 +- .../components/ui/sidebar/SidebarGroup.vue | 2 +- .../ui/sidebar/SidebarGroupAction.vue | 2 +- .../ui/sidebar/SidebarGroupContent.vue | 2 +- .../ui/sidebar/SidebarGroupLabel.vue | 2 +- .../components/ui/sidebar/SidebarHeader.vue | 2 +- .../components/ui/sidebar/SidebarInput.vue | 4 +- .../components/ui/sidebar/SidebarInset.vue | 2 +- .../components/ui/sidebar/SidebarMenu.vue | 2 +- .../ui/sidebar/SidebarMenuAction.vue | 2 +- .../ui/sidebar/SidebarMenuBadge.vue | 2 +- .../ui/sidebar/SidebarMenuButton.vue | 2 +- .../ui/sidebar/SidebarMenuButtonChild.vue | 2 +- .../components/ui/sidebar/SidebarMenuItem.vue | 2 +- .../ui/sidebar/SidebarMenuSkeleton.vue | 4 +- .../components/ui/sidebar/SidebarMenuSub.vue | 2 +- .../ui/sidebar/SidebarMenuSubButton.vue | 2 +- .../ui/sidebar/SidebarMenuSubItem.vue | 0 .../components/ui/sidebar/SidebarProvider.vue | 2 +- .../components/ui/sidebar/SidebarRail.vue | 2 +- .../ui/sidebar/SidebarSeparator.vue | 4 +- .../components/ui/sidebar/SidebarTrigger.vue | 4 +- .../components/ui/sidebar/index.js | 0 .../components/ui/sidebar/index.ts | 0 .../components/ui/sidebar/utils.js | 0 .../components/ui/sidebar/utils.ts | 0 .../components/ui/skeleton/Skeleton.vue | 2 +- .../components/ui/skeleton/index.js | 0 .../components/ui/sonner/Sonner.vue | 0 .../components/ui/sonner/index.js | 0 .../components/ui/spinner/Spinner.vue | 0 .../components/ui/spinner/index.js | 0 .../components/ui/stepper/Stepper.vue | 2 +- .../ui/stepper/StepperDescription.vue | 2 +- .../ui/stepper/StepperIndicator.vue | 2 +- .../components/ui/stepper/StepperItem.vue | 2 +- .../ui/stepper/StepperSeparator.vue | 2 +- .../components/ui/stepper/StepperTitle.vue | 2 +- .../components/ui/stepper/StepperTrigger.vue | 2 +- .../components/ui/stepper/index.js | 0 .../components/ui/switch/Switch.vue | 2 +- .../components/ui/switch/index.js | 0 .../components/ui/table/Table.vue | 2 +- .../components/ui/table/TableBody.vue | 2 +- .../components/ui/table/TableCaption.vue | 2 +- .../components/ui/table/TableCell.vue | 2 +- .../components/ui/table/TableEmpty.vue | 2 +- .../components/ui/table/TableFooter.vue | 2 +- .../components/ui/table/TableHead.vue | 2 +- .../components/ui/table/TableHeader.vue | 2 +- .../components/ui/table/TableRow.vue | 2 +- .../components/ui/table/index.js | 0 .../components/ui/tabs/Tabs.vue | 0 .../components/ui/tabs/TabsContent.vue | 2 +- .../components/ui/tabs/TabsList.vue | 2 +- .../components/ui/tabs/TabsTrigger.vue | 2 +- .../components/ui/tabs/index.js | 0 .../components/ui/tags-input/TagsInput.vue | 2 +- .../ui/tags-input/TagsInputInput.vue | 2 +- .../ui/tags-input/TagsInputItem.vue | 2 +- .../ui/tags-input/TagsInputItemDelete.vue | 2 +- .../ui/tags-input/TagsInputItemText.vue | 2 +- .../components/ui/tags-input/index.js | 0 .../components/ui/textarea/Textarea.vue | 2 +- .../components/ui/textarea/index.js | 0 .../components/ui/toggle/Toggle.vue | 2 +- .../components/ui/toggle/index.js | 0 .../components/ui/tooltip/Tooltip.vue | 0 .../components/ui/tooltip/TooltipContent.vue | 2 +- .../components/ui/tooltip/TooltipProvider.vue | 0 .../components/ui/tooltip/TooltipTrigger.vue | 0 .../components/ui/tooltip/index.js | 0 frontend/shared-ui/index.js | 1 + frontend/{src => shared-ui}/lib/utils.js | 0 frontend/shared-ui/utils/datetime.js | 28 + frontend/shared-ui/utils/http.js | 32 + frontend/tailwind.config.js | 7 +- frontend/vite.config.js | 121 +-- go.mod | 2 + go.sum | 6 +- i18n/en.json | 57 ++ internal/conversation/conversation.go | 4 +- internal/conversation/message.go | 138 +++- internal/conversation/queries.sql | 18 +- internal/inbox/channel/email/imap.go | 5 +- internal/inbox/channel/livechat/livechat.go | 302 +++++++ internal/inbox/inbox.go | 3 +- internal/migrations/v0.8.0.go | 44 + internal/user/contact.go | 2 +- internal/user/models/models.go | 5 +- internal/user/queries.sql | 23 +- internal/user/user.go | 1 + internal/user/visitor.go | 38 + internal/ws/client.go | 6 +- internal/ws/ws.go | 8 +- schema.sql | 20 +- static/widget.js | 190 +++++ 528 files changed, 4792 insertions(+), 1286 deletions(-) create mode 100644 cmd/chat.go create mode 100644 cmd/widget_ws.go create mode 100644 frontend/README-SETUP.md rename frontend/{ => apps/main}/index.html (100%) rename frontend/{ => apps/main}/src/App.vue (88%) rename frontend/{ => apps/main}/src/OuterApp.vue (80%) rename frontend/{ => apps/main}/src/Root.vue (66%) rename frontend/{ => apps/main}/src/api/index.js (100%) rename frontend/{ => apps/main}/src/assets/styles/main.scss (100%) rename frontend/{ => apps/main}/src/components/button/CloseButton.vue (87%) rename frontend/{ => apps/main}/src/components/combobox/SelectCombobox.vue (91%) rename frontend/{ => apps/main}/src/components/datatable/DataTable.vue (98%) rename frontend/{ => apps/main}/src/components/editor/CodeEditor.vue (100%) rename frontend/{ => apps/main}/src/components/editor/TextEditor.vue (98%) rename frontend/{ => apps/main}/src/components/filter/FilterBuilder.vue (95%) rename frontend/{ => apps/main}/src/components/layout/MenuCard.vue (100%) rename frontend/{ => apps/main}/src/components/layout/PageHeader.vue (79%) rename frontend/{ => apps/main}/src/components/sidebar/Sidebar.vue (97%) rename frontend/{ => apps/main}/src/components/sidebar/SidebarNavUser.vue (94%) rename frontend/{ => apps/main}/src/components/table/SimpleTable.vue (96%) rename frontend/{ => apps/main}/src/components/update/AppUpdate.vue (91%) rename frontend/{ => apps/main}/src/composables/useActivityLogFilters.js (91%) rename frontend/{ => apps/main}/src/composables/useConversationFilters.js (96%) rename frontend/{ => apps/main}/src/composables/useEmitter.js (100%) rename frontend/{ => apps/main}/src/composables/useFileUpload.js (96%) rename frontend/{ => apps/main}/src/composables/useIdleDetection.js (94%) rename frontend/{ => apps/main}/src/composables/useSla.js (92%) rename frontend/{ => apps/main}/src/composables/useTemporaryClass.js (100%) rename frontend/{ => apps/main}/src/constants/conversation.js (100%) rename frontend/{ => apps/main}/src/constants/countries.js (100%) rename frontend/{ => apps/main}/src/constants/date.js (100%) rename frontend/{ => apps/main}/src/constants/emitterEvents.js (100%) rename frontend/{ => apps/main}/src/constants/filterConfig.js (100%) rename frontend/{ => apps/main}/src/constants/navigation.js (100%) rename frontend/{ => apps/main}/src/constants/permissions.js (100%) rename frontend/{ => apps/main}/src/constants/timezones.js (100%) rename frontend/{ => apps/main}/src/constants/user.js (100%) rename frontend/{ => apps/main}/src/constants/websocket.js (100%) rename frontend/{ => apps/main}/src/features/admin/activity-log/ActivityLog.vue (93%) rename frontend/{ => apps/main}/src/features/admin/agents/AgentForm.vue (94%) rename frontend/{ => apps/main}/src/features/admin/agents/dataTableColumns.js (100%) rename frontend/{ => apps/main}/src/features/admin/agents/dataTableDropdown.vue (86%) rename frontend/{ => apps/main}/src/features/admin/agents/formSchema.js (100%) rename frontend/{ => apps/main}/src/features/admin/agents/schema.test.js (100%) rename frontend/{ => apps/main}/src/features/admin/automation/ActionBox.vue (88%) rename frontend/{ => apps/main}/src/features/admin/automation/AutomationTabs.vue (93%) rename frontend/{ => apps/main}/src/features/admin/automation/RuleBox.vue (95%) rename frontend/{ => apps/main}/src/features/admin/automation/RuleList.vue (95%) rename frontend/{ => apps/main}/src/features/admin/automation/RuleTab.vue (96%) rename frontend/{ => apps/main}/src/features/admin/automation/formSchema.js (100%) rename frontend/{ => apps/main}/src/features/admin/business-hours/BusinessHoursForm.vue (91%) rename frontend/{ => apps/main}/src/features/admin/business-hours/dataTableColumns.js (100%) rename frontend/{ => apps/main}/src/features/admin/business-hours/dataTableDropdown.vue (88%) rename frontend/{ => apps/main}/src/features/admin/business-hours/formSchema.js (100%) rename frontend/{ => apps/main}/src/features/admin/custom-attributes/CustomAttributesForm.vue (97%) rename frontend/{ => apps/main}/src/features/admin/custom-attributes/dataTableColumns.js (100%) rename frontend/{ => apps/main}/src/features/admin/custom-attributes/dataTableDropdown.vue (87%) rename frontend/{ => apps/main}/src/features/admin/custom-attributes/formSchema.js (100%) rename frontend/{ => apps/main}/src/features/admin/general/GeneralSettingForm.vue (93%) rename frontend/{ => apps/main}/src/features/admin/general/formSchema.js (100%) rename frontend/{ => apps/main}/src/features/admin/inbox/EmailInboxForm.vue (98%) rename frontend/{ => apps/main}/src/features/admin/inbox/InboxDataTableDropDown.vue (93%) create mode 100644 frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue rename frontend/{ => apps/main}/src/features/admin/inbox/formSchema.js (97%) create mode 100644 frontend/apps/main/src/features/admin/inbox/livechatFormSchema.js rename frontend/{ => apps/main}/src/features/admin/macros/ActionBuilder.vue (94%) rename frontend/{ => apps/main}/src/features/admin/macros/MacroForm.vue (91%) rename frontend/{ => apps/main}/src/features/admin/macros/dataTableColumns.js (100%) rename frontend/{ => apps/main}/src/features/admin/macros/dataTableDropdown.vue (86%) rename frontend/{ => apps/main}/src/features/admin/macros/formSchema.js (97%) rename frontend/{ => apps/main}/src/features/admin/notification/NotificationSetting.vue (89%) rename frontend/{ => apps/main}/src/features/admin/notification/NotificationSettingForm.vue (95%) rename frontend/{ => apps/main}/src/features/admin/notification/formSchema.js (97%) rename frontend/{ => apps/main}/src/features/admin/oidc/OIDCForm.vue (92%) rename frontend/{ => apps/main}/src/features/admin/oidc/dataTableColumns.js (100%) rename frontend/{ => apps/main}/src/features/admin/oidc/dataTableDropdown.vue (86%) rename frontend/{ => apps/main}/src/features/admin/oidc/formSchema.js (100%) rename frontend/{ => apps/main}/src/features/admin/roles/RoleForm.vue (96%) rename frontend/{ => apps/main}/src/features/admin/roles/dataTableColumns.js (100%) rename frontend/{ => apps/main}/src/features/admin/roles/dataTableDropdown.vue (85%) rename frontend/{ => apps/main}/src/features/admin/roles/formSchema.js (100%) rename frontend/{ => apps/main}/src/features/admin/sla/SLAForm.vue (97%) rename frontend/{ => apps/main}/src/features/admin/sla/dataTableColumns.js (100%) rename frontend/{ => apps/main}/src/features/admin/sla/dataTableDropdown.vue (86%) rename frontend/{ => apps/main}/src/features/admin/sla/formSchema.js (98%) rename frontend/{ => apps/main}/src/features/admin/status/StatusForm.vue (85%) rename frontend/{ => apps/main}/src/features/admin/status/dataTableColumns.js (100%) rename frontend/{ => apps/main}/src/features/admin/status/dataTableDropdown.vue (88%) rename frontend/{ => apps/main}/src/features/admin/status/formSchema.js (100%) rename frontend/{ => apps/main}/src/features/admin/tags/TagsForm.vue (87%) rename frontend/{ => apps/main}/src/features/admin/tags/dataTableColumns.js (100%) rename frontend/{ => apps/main}/src/features/admin/tags/dataTableDropdown.vue (90%) rename frontend/{ => apps/main}/src/features/admin/tags/formSchema.js (100%) rename frontend/{ => apps/main}/src/features/admin/teams/TeamDataTableDropdown.vue (85%) rename frontend/{ => apps/main}/src/features/admin/teams/TeamForm.vue (92%) rename frontend/{ => apps/main}/src/features/admin/teams/TeamsDataTableColumns.js (100%) rename frontend/{ => apps/main}/src/features/admin/teams/teamFormSchema.js (100%) rename frontend/{ => apps/main}/src/features/admin/templates/TemplateForm.vue (89%) rename frontend/{ => apps/main}/src/features/admin/templates/dataTableColumns.js (100%) rename frontend/{ => apps/main}/src/features/admin/templates/dataTableDropdown.vue (87%) rename frontend/{ => apps/main}/src/features/admin/templates/formSchema.js (100%) rename frontend/{ => apps/main}/src/features/admin/webhooks/WebhookForm.vue (95%) rename frontend/{ => apps/main}/src/features/admin/webhooks/dataTableColumns.js (97%) rename frontend/{ => apps/main}/src/features/admin/webhooks/dataTableDropdown.vue (91%) rename frontend/{ => apps/main}/src/features/admin/webhooks/formSchema.js (100%) rename frontend/{ => apps/main}/src/features/command/CommandBox.vue (94%) rename frontend/{ => apps/main}/src/features/contact/ContactForm.vue (92%) rename frontend/{ => apps/main}/src/features/contact/ContactNotes.vue (90%) rename frontend/{ => apps/main}/src/features/contact/ContactsList.vue (91%) rename frontend/{ => apps/main}/src/features/contact/formSchema.js (100%) rename frontend/{ => apps/main}/src/features/conversation/Conversation.vue (84%) rename frontend/{ => apps/main}/src/features/conversation/ConversationPlaceholder.vue (100%) rename frontend/{ => apps/main}/src/features/conversation/CreateConversation.vue (94%) rename frontend/{ => apps/main}/src/features/conversation/MacroActionsPreview.vue (97%) rename frontend/{ => apps/main}/src/features/conversation/ReplyBox.vue (94%) rename frontend/{ => apps/main}/src/features/conversation/ReplyBoxContent.vue (72%) rename frontend/{ => apps/main}/src/features/conversation/ReplyBoxMenuBar.vue (95%) rename frontend/{ => apps/main}/src/features/conversation/list/ConversationEmptyList.vue (100%) rename frontend/{ => apps/main}/src/features/conversation/list/ConversationList.vue (96%) rename frontend/{ => apps/main}/src/features/conversation/list/ConversationListItem.vue (97%) rename frontend/{ => apps/main}/src/features/conversation/list/ConversationListItemSkeleton.vue (84%) rename frontend/{ => apps/main}/src/features/conversation/message/ActivityMessageBubble.vue (86%) rename frontend/{ => apps/main}/src/features/conversation/message/AgentMessageBubble.vue (91%) rename frontend/{ => apps/main}/src/features/conversation/message/ContactMessageBubble.vue (93%) rename frontend/{ => apps/main}/src/features/conversation/message/MessageEnvelope.vue (100%) rename frontend/{ => apps/main}/src/features/conversation/message/MessageList.vue (94%) rename frontend/{ => apps/main}/src/features/conversation/message/MessagesSkeleton.vue (92%) rename frontend/{ => apps/main}/src/features/conversation/message/attachment/AttachmentsPreview.vue (92%) rename frontend/{ => apps/main}/src/features/conversation/message/attachment/FileAttachmentPreview.vue (95%) rename frontend/{ => apps/main}/src/features/conversation/message/attachment/ImageAttachmentPreview.vue (93%) rename frontend/{ => apps/main}/src/features/conversation/message/attachment/MessageAttachmentPreview.vue (100%) rename frontend/{ => apps/main}/src/features/conversation/sidebar/ConversationInfo.vue (92%) rename frontend/{ => apps/main}/src/features/conversation/sidebar/ConversationSideBar.vue (92%) rename frontend/{ => apps/main}/src/features/conversation/sidebar/ConversationSideBarContact.vue (79%) rename frontend/{ => apps/main}/src/features/conversation/sidebar/ConversationSideBarWrapper.vue (90%) rename frontend/{ => apps/main}/src/features/conversation/sidebar/CustomAttributes.vue (96%) rename frontend/{ => apps/main}/src/features/conversation/sidebar/PreviousConversations.vue (95%) rename frontend/{ => apps/main}/src/features/reports/OverviewBarChart.vue (90%) rename frontend/{ => apps/main}/src/features/reports/OverviewCard.vue (100%) rename frontend/{ => apps/main}/src/features/reports/OverviewLineChart.vue (89%) rename frontend/{ => apps/main}/src/features/search/SearchHeader.vue (77%) rename frontend/{ => apps/main}/src/features/search/SearchResults.vue (100%) rename frontend/{ => apps/main}/src/features/sla/SlaBadge.vue (97%) rename frontend/{ => apps/main}/src/features/view/ViewForm.vue (89%) rename frontend/{ => apps/main}/src/layouts/account/AccountLayout.vue (100%) rename frontend/{ => apps/main}/src/layouts/admin/ActivityLog.vue (100%) rename frontend/{ => apps/main}/src/layouts/admin/AdminLayout.vue (100%) rename frontend/{ => apps/main}/src/layouts/admin/AdminPageWithHelp.vue (100%) rename frontend/{ => apps/main}/src/layouts/auth/AuthLayout.vue (100%) rename frontend/{ => apps/main}/src/layouts/contact/ContactDetail.vue (100%) rename frontend/{ => apps/main}/src/layouts/contact/ContactList.vue (100%) rename frontend/{ => apps/main}/src/layouts/inbox/InboxLayout.vue (100%) rename frontend/{ => apps/main}/src/main.js (100%) rename frontend/{ => apps/main}/src/router/index.js (68%) rename frontend/{ => apps/main}/src/stores/appSettings.js (100%) rename frontend/{ => apps/main}/src/stores/conversation.js (98%) rename frontend/{ => apps/main}/src/stores/customAttributes.js (88%) rename frontend/{ => apps/main}/src/stores/inbox.js (79%) rename frontend/{ => apps/main}/src/stores/macro.js (91%) rename frontend/{ => apps/main}/src/stores/sla.js (96%) rename frontend/{ => apps/main}/src/stores/tag.js (82%) rename frontend/{ => apps/main}/src/stores/team.js (81%) rename frontend/{ => apps/main}/src/stores/user.js (91%) rename frontend/{ => apps/main}/src/stores/users.js (82%) rename frontend/{ => apps/main}/src/utils/conversation-message-cache.js (100%) rename frontend/{ => apps/main}/src/utils/datetime.js (100%) rename frontend/{ => apps/main}/src/utils/debounce.js (100%) rename frontend/{ => apps/main}/src/utils/email-recipients.js (100%) rename frontend/{ => apps/main}/src/utils/file.js (100%) rename frontend/{ => apps/main}/src/utils/http.js (100%) rename frontend/{ => apps/main}/src/utils/nav-permissions.js (100%) rename frontend/{ => apps/main}/src/utils/pagination.js (100%) rename frontend/{ => apps/main}/src/utils/sla.js (100%) rename frontend/{ => apps/main}/src/utils/strings.js (100%) rename frontend/{ => apps/main}/src/views/account/profile/ProfileEditView.vue (90%) rename frontend/{ => apps/main}/src/views/admin/activity-log/ActivityLog.vue (100%) rename frontend/{ => apps/main}/src/views/admin/agents/AgentList.vue (70%) rename frontend/{ => apps/main}/src/views/admin/agents/Agents.vue (100%) rename frontend/{ => apps/main}/src/views/admin/agents/CreateAgent.vue (81%) rename frontend/{ => apps/main}/src/views/admin/agents/EditAgent.vue (83%) rename frontend/{ => apps/main}/src/views/admin/automations/Automation.vue (95%) rename frontend/{ => apps/main}/src/views/admin/automations/CreateOrEditRule.vue (94%) rename frontend/{ => apps/main}/src/views/admin/business-hours/BusinessHours.vue (100%) rename frontend/{ => apps/main}/src/views/admin/business-hours/BusinessHoursList.vue (74%) rename frontend/{ => apps/main}/src/views/admin/business-hours/CreateOrEditBusinessHours.vue (87%) rename frontend/{ => apps/main}/src/views/admin/custom-attributes/CustomAttributes.vue (89%) rename frontend/{ => apps/main}/src/views/admin/general/General.vue (93%) rename frontend/{ => apps/main}/src/views/admin/inbox/EditInbox.vue (55%) rename frontend/{ => apps/main}/src/views/admin/inbox/InboxList.vue (89%) rename frontend/{ => apps/main}/src/views/admin/inbox/InboxView.vue (100%) rename frontend/{ => apps/main}/src/views/admin/inbox/NewInbox.vue (78%) rename frontend/{ => apps/main}/src/views/admin/macros/CreateMacro.vue (81%) rename frontend/{ => apps/main}/src/views/admin/macros/EditMacro.vue (82%) rename frontend/{ => apps/main}/src/views/admin/macros/MacroList.vue (73%) rename frontend/{ => apps/main}/src/views/admin/macros/Macros.vue (100%) rename frontend/{ => apps/main}/src/views/admin/oidc/CreateEditOIDC.vue (87%) rename frontend/{ => apps/main}/src/views/admin/oidc/OIDC.vue (100%) rename frontend/{ => apps/main}/src/views/admin/oidc/OIDCList.vue (73%) rename frontend/{ => apps/main}/src/views/admin/roles/EditRole.vue (81%) rename frontend/{ => apps/main}/src/views/admin/roles/NewRole.vue (80%) rename frontend/{ => apps/main}/src/views/admin/roles/RoleList.vue (71%) rename frontend/{ => apps/main}/src/views/admin/roles/Roles.vue (100%) rename frontend/{ => apps/main}/src/views/admin/sla/CreateEditSLA.vue (86%) rename frontend/{ => apps/main}/src/views/admin/sla/SLA.vue (100%) rename frontend/{ => apps/main}/src/views/admin/sla/SLAList.vue (74%) rename frontend/{ => apps/main}/src/views/admin/status/StatusView.vue (85%) rename frontend/{ => apps/main}/src/views/admin/tags/TagsView.vue (85%) rename frontend/{ => apps/main}/src/views/admin/teams/CreateTeamForm.vue (79%) rename frontend/{ => apps/main}/src/views/admin/teams/EditTeamForm.vue (81%) rename frontend/{ => apps/main}/src/views/admin/teams/TeamList.vue (71%) rename frontend/{ => apps/main}/src/views/admin/teams/Teams.vue (100%) rename frontend/{ => apps/main}/src/views/admin/templates/CreateEditTemplate.vue (87%) rename frontend/{ => apps/main}/src/views/admin/templates/Templates.vue (87%) rename frontend/{ => apps/main}/src/views/admin/webhooks/CreateEditWebhook.vue (89%) rename frontend/{ => apps/main}/src/views/admin/webhooks/WebhookList.vue (73%) rename frontend/{ => apps/main}/src/views/admin/webhooks/Webhooks.vue (100%) rename frontend/{ => apps/main}/src/views/auth/ResetPasswordView.vue (85%) rename frontend/{ => apps/main}/src/views/auth/SetPasswordView.vue (89%) rename frontend/{ => apps/main}/src/views/auth/UserLoginView.vue (90%) rename frontend/{ => apps/main}/src/views/contact/ContactDetailView.vue (91%) rename frontend/{ => apps/main}/src/views/contact/ContactsView.vue (100%) rename frontend/{ => apps/main}/src/views/conversation/ConversationDetailView.vue (94%) rename frontend/{ => apps/main}/src/views/inbox/InboxView.vue (96%) rename frontend/{ => apps/main}/src/views/reports/OverviewView.vue (95%) rename frontend/{ => apps/main}/src/views/search/SearchView.vue (94%) rename frontend/{ => apps/main}/src/websocket.js (100%) create mode 100644 frontend/apps/widget/index.html create mode 100644 frontend/apps/widget/src/App.vue create mode 100644 frontend/apps/widget/src/api/index.js create mode 100644 frontend/apps/widget/src/assets/widget.css create mode 100644 frontend/apps/widget/src/main.js create mode 100644 frontend/apps/widget/src/router/index.js create mode 100644 frontend/apps/widget/src/store/chat.js create mode 100644 frontend/apps/widget/src/store/widget.js create mode 100644 frontend/apps/widget/src/views/ChatView.vue create mode 100644 frontend/apps/widget/src/views/WelcomeView.vue create mode 100644 frontend/shared-ui/assets/styles/main.scss create mode 100644 frontend/shared-ui/components/index.js rename frontend/{src => shared-ui}/components/ui/accordion/Accordion.vue (100%) rename frontend/{src => shared-ui}/components/ui/accordion/AccordionContent.vue (94%) rename frontend/{src => shared-ui}/components/ui/accordion/AccordionItem.vue (94%) rename frontend/{src => shared-ui}/components/ui/accordion/AccordionTrigger.vue (96%) rename frontend/{src => shared-ui}/components/ui/accordion/index.js (100%) rename frontend/{src => shared-ui}/components/ui/alert-dialog/AlertDialog.vue (100%) rename frontend/{src => shared-ui}/components/ui/alert-dialog/AlertDialogAction.vue (85%) rename frontend/{src => shared-ui}/components/ui/alert-dialog/AlertDialogCancel.vue (86%) rename frontend/{src => shared-ui}/components/ui/alert-dialog/AlertDialogContent.vue (97%) rename frontend/{src => shared-ui}/components/ui/alert-dialog/AlertDialogDescription.vue (93%) rename frontend/{src => shared-ui}/components/ui/alert-dialog/AlertDialogFooter.vue (85%) rename frontend/{src => shared-ui}/components/ui/alert-dialog/AlertDialogHeader.vue (85%) rename frontend/{src => shared-ui}/components/ui/alert-dialog/AlertDialogTitle.vue (92%) rename frontend/{src => shared-ui}/components/ui/alert-dialog/AlertDialogTrigger.vue (100%) rename frontend/{src => shared-ui}/components/ui/alert-dialog/index.js (100%) rename frontend/{src => shared-ui}/components/ui/alert/Alert.vue (88%) rename frontend/{src => shared-ui}/components/ui/alert/AlertDescription.vue (84%) rename frontend/{src => shared-ui}/components/ui/alert/AlertTitle.vue (84%) rename frontend/{src => shared-ui}/components/ui/alert/index.js (100%) rename frontend/{src => shared-ui}/components/ui/auto-form/AutoForm.vue (98%) rename frontend/{src => shared-ui}/components/ui/auto-form/AutoFormField.vue (100%) rename frontend/{src => shared-ui}/components/ui/auto-form/AutoFormFieldArray.vue (93%) rename frontend/{src => shared-ui}/components/ui/auto-form/AutoFormFieldBoolean.vue (91%) rename frontend/{src => shared-ui}/components/ui/auto-form/AutoFormFieldDate.vue (89%) rename frontend/{src => shared-ui}/components/ui/auto-form/AutoFormFieldEnum.vue (92%) rename frontend/{src => shared-ui}/components/ui/auto-form/AutoFormFieldFile.vue (95%) rename frontend/{src => shared-ui}/components/ui/auto-form/AutoFormFieldInput.vue (91%) rename frontend/{src => shared-ui}/components/ui/auto-form/AutoFormFieldNumber.vue (93%) rename frontend/{src => shared-ui}/components/ui/auto-form/AutoFormFieldObject.vue (96%) rename frontend/{src => shared-ui}/components/ui/auto-form/AutoFormLabel.vue (81%) rename frontend/{src => shared-ui}/components/ui/auto-form/constant.js (100%) rename frontend/{src => shared-ui}/components/ui/auto-form/dependencies.js (100%) rename frontend/{src => shared-ui}/components/ui/auto-form/index.js (100%) rename frontend/{src => shared-ui}/components/ui/auto-form/interface.js (100%) rename frontend/{src => shared-ui}/components/ui/auto-form/utils.js (100%) rename frontend/{src => shared-ui}/components/ui/avatar/Avatar.vue (91%) rename frontend/{src => shared-ui}/components/ui/avatar/AvatarFallback.vue (100%) rename frontend/{src => shared-ui}/components/ui/avatar/AvatarImage.vue (100%) rename frontend/{src => shared-ui}/components/ui/avatar/AvatarUpload.vue (94%) rename frontend/{src => shared-ui}/components/ui/avatar/index.js (100%) rename frontend/{src => shared-ui}/components/ui/badge/Badge.vue (87%) rename frontend/{src => shared-ui}/components/ui/badge/index.js (100%) rename frontend/{src => shared-ui}/components/ui/breadcrumb/Breadcrumb.vue (100%) rename frontend/{src => shared-ui}/components/ui/breadcrumb/BreadcrumbEllipsis.vue (91%) rename frontend/{src => shared-ui}/components/ui/breadcrumb/BreadcrumbItem.vue (84%) rename frontend/{src => shared-ui}/components/ui/breadcrumb/BreadcrumbLink.vue (91%) rename frontend/{src => shared-ui}/components/ui/breadcrumb/BreadcrumbList.vue (88%) rename frontend/{src => shared-ui}/components/ui/breadcrumb/BreadcrumbPage.vue (87%) rename frontend/{src => shared-ui}/components/ui/breadcrumb/BreadcrumbSeparator.vue (88%) rename frontend/{src => shared-ui}/components/ui/breadcrumb/Custom.vue (95%) rename frontend/{src => shared-ui}/components/ui/breadcrumb/index.js (100%) rename frontend/{src => shared-ui}/components/ui/button/Button.vue (96%) rename frontend/{src => shared-ui}/components/ui/button/index.js (100%) rename frontend/{src => shared-ui}/components/ui/calendar/Calendar.vue (98%) rename frontend/{src => shared-ui}/components/ui/calendar/CalendarCell.vue (95%) rename frontend/{src => shared-ui}/components/ui/calendar/CalendarCellTrigger.vue (94%) rename frontend/{src => shared-ui}/components/ui/calendar/CalendarGrid.vue (93%) rename frontend/{src => shared-ui}/components/ui/calendar/CalendarGridBody.vue (100%) rename frontend/{src => shared-ui}/components/ui/calendar/CalendarGridHead.vue (100%) rename frontend/{src => shared-ui}/components/ui/calendar/CalendarGridRow.vue (93%) rename frontend/{src => shared-ui}/components/ui/calendar/CalendarHeadCell.vue (94%) rename frontend/{src => shared-ui}/components/ui/calendar/CalendarHeader.vue (94%) rename frontend/{src => shared-ui}/components/ui/calendar/CalendarHeading.vue (94%) rename frontend/{src => shared-ui}/components/ui/calendar/CalendarNextButton.vue (90%) rename frontend/{src => shared-ui}/components/ui/calendar/CalendarPrevButton.vue (90%) rename frontend/{src => shared-ui}/components/ui/calendar/index.js (100%) rename frontend/{src => shared-ui}/components/ui/card/Card.vue (85%) rename frontend/{src => shared-ui}/components/ui/card/CardContent.vue (82%) rename frontend/{src => shared-ui}/components/ui/card/CardDescription.vue (83%) rename frontend/{src => shared-ui}/components/ui/card/CardFooter.vue (83%) rename frontend/{src => shared-ui}/components/ui/card/CardHeader.vue (83%) rename frontend/{src => shared-ui}/components/ui/card/CardTitle.vue (84%) rename frontend/{src => shared-ui}/components/ui/card/index.js (100%) rename frontend/{src => shared-ui}/components/ui/chart-bar/BarChart.vue (98%) rename frontend/{src => shared-ui}/components/ui/chart-bar/index.js (100%) rename frontend/{src => shared-ui}/components/ui/chart-line/LineChart.vue (98%) rename frontend/{src => shared-ui}/components/ui/chart-line/index.js (100%) rename frontend/{src => shared-ui}/components/ui/chart/ChartCrosshair.vue (100%) rename frontend/{src => shared-ui}/components/ui/chart/ChartLegend.vue (96%) rename frontend/{src => shared-ui}/components/ui/chart/ChartSingleTooltip.vue (100%) rename frontend/{src => shared-ui}/components/ui/chart/ChartTooltip.vue (92%) rename frontend/{src => shared-ui}/components/ui/chart/index.js (100%) rename frontend/{src => shared-ui}/components/ui/chart/interface.js (100%) rename frontend/{src => shared-ui}/components/ui/checkbox/Checkbox.vue (97%) rename frontend/{src => shared-ui}/components/ui/checkbox/index.js (100%) rename frontend/{src => shared-ui}/components/ui/collapsible/Collapsible.vue (100%) rename frontend/{src => shared-ui}/components/ui/collapsible/CollapsibleContent.vue (100%) rename frontend/{src => shared-ui}/components/ui/collapsible/CollapsibleTrigger.vue (100%) rename frontend/{src => shared-ui}/components/ui/collapsible/index.js (100%) rename frontend/{src => shared-ui}/components/ui/combobox/ComboBox.vue (91%) rename frontend/{src => shared-ui}/components/ui/command/Command.vue (97%) rename frontend/{src => shared-ui}/components/ui/command/CommandDialog.vue (94%) rename frontend/{src => shared-ui}/components/ui/command/CommandEmpty.vue (92%) rename frontend/{src => shared-ui}/components/ui/command/CommandGroup.vue (95%) rename frontend/{src => shared-ui}/components/ui/command/CommandInput.vue (96%) rename frontend/{src => shared-ui}/components/ui/command/CommandItem.vue (95%) rename frontend/{src => shared-ui}/components/ui/command/CommandList.vue (97%) rename frontend/{src => shared-ui}/components/ui/command/CommandSeparator.vue (92%) rename frontend/{src => shared-ui}/components/ui/command/CommandShortcut.vue (85%) rename frontend/{src => shared-ui}/components/ui/command/index.js (100%) rename frontend/{src => shared-ui}/components/ui/date-filter/DateFilter.vue (97%) rename frontend/{src => shared-ui}/components/ui/date-filter/index.js (100%) rename frontend/{src => shared-ui}/components/ui/dialog/Dialog.vue (100%) rename frontend/{src => shared-ui}/components/ui/dialog/DialogClose.vue (100%) rename frontend/{src => shared-ui}/components/ui/dialog/DialogContent.vue (98%) rename frontend/{src => shared-ui}/components/ui/dialog/DialogDescription.vue (93%) rename frontend/{src => shared-ui}/components/ui/dialog/DialogFooter.vue (85%) rename frontend/{src => shared-ui}/components/ui/dialog/DialogHeader.vue (85%) rename frontend/{src => shared-ui}/components/ui/dialog/DialogScrollContent.vue (98%) rename frontend/{src => shared-ui}/components/ui/dialog/DialogTitle.vue (93%) rename frontend/{src => shared-ui}/components/ui/dialog/DialogTrigger.vue (100%) rename frontend/{src => shared-ui}/components/ui/dialog/index.js (100%) rename frontend/{src => shared-ui}/components/ui/dropdown-menu/DropdownMenu.vue (100%) rename frontend/{src => shared-ui}/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue (97%) rename frontend/{src => shared-ui}/components/ui/dropdown-menu/DropdownMenuContent.vue (98%) rename frontend/{src => shared-ui}/components/ui/dropdown-menu/DropdownMenuGroup.vue (100%) rename frontend/{src => shared-ui}/components/ui/dropdown-menu/DropdownMenuItem.vue (96%) rename frontend/{src => shared-ui}/components/ui/dropdown-menu/DropdownMenuLabel.vue (94%) rename frontend/{src => shared-ui}/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue (100%) rename frontend/{src => shared-ui}/components/ui/dropdown-menu/DropdownMenuRadioItem.vue (97%) rename frontend/{src => shared-ui}/components/ui/dropdown-menu/DropdownMenuSeparator.vue (92%) rename frontend/{src => shared-ui}/components/ui/dropdown-menu/DropdownMenuShortcut.vue (84%) rename frontend/{src => shared-ui}/components/ui/dropdown-menu/DropdownMenuSub.vue (100%) rename frontend/{src => shared-ui}/components/ui/dropdown-menu/DropdownMenuSubContent.vue (97%) rename frontend/{src => shared-ui}/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue (96%) rename frontend/{src => shared-ui}/components/ui/dropdown-menu/DropdownMenuTrigger.vue (100%) rename frontend/{src => shared-ui}/components/ui/dropdown-menu/index.js (100%) rename frontend/{src => shared-ui}/components/ui/error/Error.vue (84%) rename frontend/{src => shared-ui}/components/ui/error/index.js (100%) rename frontend/{src => shared-ui}/components/ui/form/FormControl.vue (100%) rename frontend/{src => shared-ui}/components/ui/form/FormDescription.vue (88%) rename frontend/{src => shared-ui}/components/ui/form/FormItem.vue (90%) rename frontend/{src => shared-ui}/components/ui/form/FormLabel.vue (85%) rename frontend/{src => shared-ui}/components/ui/form/FormMessage.vue (100%) rename frontend/{src => shared-ui}/components/ui/form/index.js (100%) rename frontend/{src => shared-ui}/components/ui/form/injectionKeys.js (100%) rename frontend/{src => shared-ui}/components/ui/form/useFormField.js (100%) rename frontend/{src => shared-ui}/components/ui/input/Input.vue (95%) rename frontend/{src => shared-ui}/components/ui/input/index.js (100%) rename frontend/{src => shared-ui}/components/ui/label/Label.vue (94%) rename frontend/{src => shared-ui}/components/ui/label/index.js (100%) rename frontend/{src => shared-ui}/components/ui/loader/DotLoader.vue (100%) rename frontend/{src => shared-ui}/components/ui/loader/index.js (100%) rename frontend/{src => shared-ui}/components/ui/pagination/PaginationEllipsis.vue (94%) rename frontend/{src => shared-ui}/components/ui/pagination/PaginationFirst.vue (88%) rename frontend/{src => shared-ui}/components/ui/pagination/PaginationLast.vue (88%) rename frontend/{src => shared-ui}/components/ui/pagination/PaginationNext.vue (88%) rename frontend/{src => shared-ui}/components/ui/pagination/PaginationPrev.vue (88%) rename frontend/{src => shared-ui}/components/ui/pagination/index.js (100%) rename frontend/{src => shared-ui}/components/ui/popover/Popover.vue (100%) rename frontend/{src => shared-ui}/components/ui/popover/PopoverContent.vue (98%) rename frontend/{src => shared-ui}/components/ui/popover/PopoverTrigger.vue (100%) rename frontend/{src => shared-ui}/components/ui/popover/index.js (100%) rename frontend/{src => shared-ui}/components/ui/radio-group/RadioGroup.vue (96%) rename frontend/{src => shared-ui}/components/ui/radio-group/RadioGroupItem.vue (96%) rename frontend/{src => shared-ui}/components/ui/radio-group/index.js (100%) rename frontend/{src => shared-ui}/components/ui/select/Select.vue (100%) rename frontend/{src => shared-ui}/components/ui/select/SelectContent.vue (98%) rename frontend/{src => shared-ui}/components/ui/select/SelectGroup.vue (92%) rename frontend/{src => shared-ui}/components/ui/select/SelectItem.vue (96%) rename frontend/{src => shared-ui}/components/ui/select/SelectItemText.vue (100%) rename frontend/{src => shared-ui}/components/ui/select/SelectLabel.vue (90%) rename frontend/{src => shared-ui}/components/ui/select/SelectScrollDownButton.vue (94%) rename frontend/{src => shared-ui}/components/ui/select/SelectScrollUpButton.vue (94%) rename frontend/{src => shared-ui}/components/ui/select/SelectSeparator.vue (92%) rename frontend/{src => shared-ui}/components/ui/select/SelectTag.vue (98%) rename frontend/{src => shared-ui}/components/ui/select/SelectTrigger.vue (96%) rename frontend/{src => shared-ui}/components/ui/select/SelectValue.vue (100%) rename frontend/{src => shared-ui}/components/ui/select/index.js (100%) rename frontend/{src => shared-ui}/components/ui/separator/Separator.vue (94%) rename frontend/{src => shared-ui}/components/ui/separator/index.js (100%) rename frontend/{src => shared-ui}/components/ui/sheet/Sheet.vue (100%) rename frontend/{src => shared-ui}/components/ui/sheet/SheetClose.vue (100%) rename frontend/{src => shared-ui}/components/ui/sheet/SheetContent.vue (97%) rename frontend/{src => shared-ui}/components/ui/sheet/SheetDescription.vue (92%) rename frontend/{src => shared-ui}/components/ui/sheet/SheetFooter.vue (87%) rename frontend/{src => shared-ui}/components/ui/sheet/SheetHeader.vue (85%) rename frontend/{src => shared-ui}/components/ui/sheet/SheetTitle.vue (92%) rename frontend/{src => shared-ui}/components/ui/sheet/SheetTrigger.vue (100%) rename frontend/{src => shared-ui}/components/ui/sheet/index.js (100%) rename frontend/{src => shared-ui}/components/ui/sidebar/Sidebar.vue (97%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarContent.vue (89%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarFooter.vue (85%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarGroup.vue (86%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarGroupAction.vue (95%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarGroupContent.vue (84%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarGroupLabel.vue (94%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarHeader.vue (85%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarInput.vue (80%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarInset.vue (93%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarMenu.vue (85%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarMenuAction.vue (96%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarMenuBadge.vue (94%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarMenuButton.vue (97%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarMenuButtonChild.vue (94%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarMenuItem.vue (85%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarMenuSkeleton.vue (88%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarMenuSub.vue (90%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarMenuSubButton.vue (96%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarMenuSubItem.vue (100%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarProvider.vue (98%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarRail.vue (96%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarSeparator.vue (74%) rename frontend/{src => shared-ui}/components/ui/sidebar/SidebarTrigger.vue (85%) rename frontend/{src => shared-ui}/components/ui/sidebar/index.js (100%) rename frontend/{src => shared-ui}/components/ui/sidebar/index.ts (100%) rename frontend/{src => shared-ui}/components/ui/sidebar/utils.js (100%) rename frontend/{src => shared-ui}/components/ui/sidebar/utils.ts (100%) rename frontend/{src => shared-ui}/components/ui/skeleton/Skeleton.vue (83%) rename frontend/{src => shared-ui}/components/ui/skeleton/index.js (100%) rename frontend/{src => shared-ui}/components/ui/sonner/Sonner.vue (100%) rename frontend/{src => shared-ui}/components/ui/sonner/index.js (100%) rename frontend/{src => shared-ui}/components/ui/spinner/Spinner.vue (100%) rename frontend/{src => shared-ui}/components/ui/spinner/index.js (100%) rename frontend/{src => shared-ui}/components/ui/stepper/Stepper.vue (95%) rename frontend/{src => shared-ui}/components/ui/stepper/StepperDescription.vue (94%) rename frontend/{src => shared-ui}/components/ui/stepper/StepperIndicator.vue (96%) rename frontend/{src => shared-ui}/components/ui/stepper/StepperItem.vue (95%) rename frontend/{src => shared-ui}/components/ui/stepper/StepperSeparator.vue (95%) rename frontend/{src => shared-ui}/components/ui/stepper/StepperTitle.vue (93%) rename frontend/{src => shared-ui}/components/ui/stepper/StepperTrigger.vue (94%) rename frontend/{src => shared-ui}/components/ui/stepper/index.js (100%) rename frontend/{src => shared-ui}/components/ui/switch/Switch.vue (97%) rename frontend/{src => shared-ui}/components/ui/switch/index.js (100%) rename frontend/{src => shared-ui}/components/ui/table/Table.vue (87%) rename frontend/{src => shared-ui}/components/ui/table/TableBody.vue (84%) rename frontend/{src => shared-ui}/components/ui/table/TableCaption.vue (84%) rename frontend/{src => shared-ui}/components/ui/table/TableCell.vue (88%) rename frontend/{src => shared-ui}/components/ui/table/TableEmpty.vue (94%) rename frontend/{src => shared-ui}/components/ui/table/TableFooter.vue (85%) rename frontend/{src => shared-ui}/components/ui/table/TableHead.vue (89%) rename frontend/{src => shared-ui}/components/ui/table/TableHeader.vue (83%) rename frontend/{src => shared-ui}/components/ui/table/TableRow.vue (87%) rename frontend/{src => shared-ui}/components/ui/table/index.js (100%) rename frontend/{src => shared-ui}/components/ui/tabs/Tabs.vue (100%) rename frontend/{src => shared-ui}/components/ui/tabs/TabsContent.vue (95%) rename frontend/{src => shared-ui}/components/ui/tabs/TabsList.vue (94%) rename frontend/{src => shared-ui}/components/ui/tabs/TabsTrigger.vue (96%) rename frontend/{src => shared-ui}/components/ui/tabs/index.js (100%) rename frontend/{src => shared-ui}/components/ui/tags-input/TagsInput.vue (97%) rename frontend/{src => shared-ui}/components/ui/tags-input/TagsInputInput.vue (94%) rename frontend/{src => shared-ui}/components/ui/tags-input/TagsInputItem.vue (95%) rename frontend/{src => shared-ui}/components/ui/tags-input/TagsInputItemDelete.vue (94%) rename frontend/{src => shared-ui}/components/ui/tags-input/TagsInputItemText.vue (93%) rename frontend/{src => shared-ui}/components/ui/tags-input/index.js (100%) rename frontend/{src => shared-ui}/components/ui/textarea/Textarea.vue (95%) rename frontend/{src => shared-ui}/components/ui/textarea/index.js (100%) rename frontend/{src => shared-ui}/components/ui/toggle/Toggle.vue (96%) rename frontend/{src => shared-ui}/components/ui/toggle/index.js (100%) rename frontend/{src => shared-ui}/components/ui/tooltip/Tooltip.vue (100%) rename frontend/{src => shared-ui}/components/ui/tooltip/TooltipContent.vue (97%) rename frontend/{src => shared-ui}/components/ui/tooltip/TooltipProvider.vue (100%) rename frontend/{src => shared-ui}/components/ui/tooltip/TooltipTrigger.vue (100%) rename frontend/{src => shared-ui}/components/ui/tooltip/index.js (100%) create mode 100644 frontend/shared-ui/index.js rename frontend/{src => shared-ui}/lib/utils.js (100%) create mode 100644 frontend/shared-ui/utils/datetime.js create mode 100644 frontend/shared-ui/utils/http.js create mode 100644 internal/inbox/channel/livechat/livechat.go create mode 100644 internal/migrations/v0.8.0.go create mode 100644 internal/user/visitor.go create mode 100644 static/widget.js diff --git a/Makefile b/Makefile index 3f2da410..871a4068 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ GOPATH ?= $(HOME)/go STUFFBIN ?= $(GOPATH)/bin/stuffbin # The default target to run when `make` is executed. -.DEFAULT_GOAL := build +.DEFAULT_GOAL := build # Install stuffbin if it doesn't exist. $(STUFFBIN): @@ -28,11 +28,24 @@ install-deps: $(STUFFBIN) @echo "→ Installing frontend dependencies..." @cd ${FRONTEND_DIR} && pnpm install -# Build the frontend for production. +# Build the frontend for production (both apps). .PHONY: frontend-build frontend-build: install-deps - @echo "→ Building frontend for production..." - @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build + @echo "→ Building frontend for production - main app & widget..." + @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:main + @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:widget + +# Build only the main frontend app. +.PHONY: frontend-build-main +frontend-build-main: install-deps + @echo "→ Building main frontend app for production..." + @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:main + +# Build only the widget frontend app. +.PHONY: frontend-build-widget +frontend-build-widget: install-deps + @echo "→ Building widget frontend app for production..." + @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm build:widget # Run the Go backend server in development mode. .PHONY: run-backend @@ -40,13 +53,29 @@ run-backend: @echo "→ Running backend..." CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'github.com/abhinavxd/libredesk/internal/version.Version=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go -# Run the JS frontend server in development mode. +# Run the JS frontend server in development mode (main app only). .PHONY: run-frontend run-frontend: @echo "→ Installing frontend dependencies (if not already installed)..." @cd ${FRONTEND_DIR} && pnpm install - @echo "→ Running frontend..." - @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev + @echo "→ Running main frontend app..." + @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev:main + +# Run the main frontend app in development mode. +.PHONY: run-frontend-main +run-frontend-main: + @echo "→ Installing frontend dependencies (if not already installed)..." + @cd ${FRONTEND_DIR} && pnpm install + @echo "→ Running main frontend app..." + @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev:main + +# Run the widget frontend app in development mode. +.PHONY: run-frontend-widget +run-frontend-widget: + @echo "→ Installing frontend dependencies (if not already installed)..." + @cd ${FRONTEND_DIR} && pnpm install + @echo "→ Running widget frontend app..." + @export VITE_APP_VERSION="${VERSION}" && cd ${FRONTEND_DIR} && pnpm dev:widget # Build the backend binary. .PHONY: build-backend diff --git a/cmd/chat.go b/cmd/chat.go new file mode 100644 index 00000000..d290f62c --- /dev/null +++ b/cmd/chat.go @@ -0,0 +1,564 @@ +package main + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/abhinavxd/libredesk/internal/conversation/models" + cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" + "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" + umodels "github.com/abhinavxd/libredesk/internal/user/models" + "github.com/golang-jwt/jwt/v5" + "github.com/valyala/fasthttp" + "github.com/volatiletech/null/v9" + "github.com/zerodha/fastglue" +) + +type onlyJWT struct { + JWT string `json:"jwt"` +} + +// Define JWT claims structure +type Claims struct { + UserID int `json:"user_id,omitempty"` + IsGuest bool `json:"is_guest,omitempty"` + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + jwt.RegisteredClaims +} + +// Chat widget initialization request +type chatInitReq struct { + onlyJWT + + // For guest users + GuestName string `json:"guest_name,omitempty"` + GuestEmail string `json:"guest_email,omitempty"` + Message string `json:"message,omitempty"` + + InboxID int `json:"inbox_id"` +} + +type conversationResp struct { + Conversation Conversation `json:"conversation"` + Messages []chatMessage `json:"messages"` +} + +type Conversation struct { + UUID string `json:"uuid"` +} + +type chatMessage struct { + CreatedAt time.Time `json:"created_at"` + UUID string `json:"uuid"` + Content string `json:"content"` + SenderType string `json:"sender_type"` + SenderName string `json:"sender_name"` + ConversationID string `json:"conversation_id"` +} + +type chatMessageReq struct { + Message string `json:"message"` + onlyJWT +} + +// handleGetChatSettings returns the live chat settings for the widget +func handleGetChatSettings(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + inboxID = r.RequestCtx.QueryArgs().GetUintOrZero("inbox_id") + ) + + if inboxID <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Inbox ID is required", nil, envelope.InputError) + } + + // Get inbox configuration + inbox, err := app.inbox.GetDBRecord(inboxID) + if err != nil { + app.lo.Error("error fetching inbox", "inbox_id", inboxID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.NotFoundError) + } + + if inbox.Channel != livechat.ChannelLiveChat { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid inbox type for chat", nil, envelope.InputError) + } + + if !inbox.Enabled { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Inbox is disabled", nil, envelope.InputError) + } + + var config livechat.Config + if err := json.Unmarshal(inbox.Config, &config); err != nil { + app.lo.Error("error parsing live chat config", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Invalid inbox configuration", nil, envelope.GeneralError) + } + + return r.SendEnvelope(config) +} + +// handleChatInit initializes a new chat session. +func handleChatInit(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + req = chatInitReq{} + ) + + if err := r.Decode(&req, "json"); err != nil { + app.lo.Error("error unmarshalling chat init request", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) + } + + if req.Message == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Message is required", nil, envelope.InputError) + } + + // Get inbox configuration + inbox, err := app.inbox.GetDBRecord(req.InboxID) + if err != nil { + app.lo.Error("error fetching inbox", "inbox_id", req.InboxID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.NotFoundError) + } + + // Make sure the inbox is enabled and of the correct type + if !inbox.Enabled { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Inbox is disabled", nil, envelope.InputError) + } + + if inbox.Channel != livechat.ChannelLiveChat { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid inbox type for chat", nil, envelope.InputError) + } + + // Parse inbox config + var config livechat.Config + if err := json.Unmarshal(inbox.Config, &config); err != nil { + app.lo.Error("error parsing live chat config", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Invalid inbox configuration", nil, envelope.GeneralError) + } + + var contactID int + var conversationUUID string + var isGuest bool + + // Handle authenticated user + if req.JWT != "" { + claims, err := verifyStandardJWT(req.JWT) + if err != nil { + app.lo.Error("invalid JWT", "jwt", req.JWT, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, "Invalid JWT", nil, envelope.InputError) + } + userID := claims.UserID + isGuest = claims.IsGuest + + user := umodels.User{ + Email: null.StringFrom(claims.Email), + FirstName: claims.Username, + LastName: "", + } + + // Get or Create contact / visitor user. + if !isGuest { + if err = app.user.CreateContact(&user); err != nil { + app.lo.Error("error fetching authenticated user contact", "user_id", userID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusNotFound, "User not found", nil, envelope.NotFoundError) + } + } else { + if err = app.user.CreateVisitor(&user); err != nil { + app.lo.Error("error creating guest contact", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error creating user, Please try again.", nil, envelope.GeneralError) + } + } + contactID = user.ID + } else { + isGuest = true + visitor := umodels.User{ + Email: null.NewString(req.GuestEmail, req.GuestEmail != ""), + FirstName: req.GuestName, + } + + if err := app.user.CreateVisitor(&visitor); err != nil { + app.lo.Error("error creating guest contact", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error creating user, Please try again.", nil, envelope.GeneralError) + } + contactID = visitor.ID + + // Generate guest JWT + req.JWT, err = generateUserJWT(contactID, isGuest, time.Now().Add(24*time.Hour)) + if err != nil { + app.lo.Error("error generating guest JWT", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Failed to generate JWT, Please try again.", nil, envelope.GeneralError) + } + } + + app.lo.Info("creating new conversation for user", "user_id", contactID, "inbox_id", req.InboxID) + + // Create conversation. + _, conversationUUID, err = app.conversation.CreateConversation( + contactID, + req.InboxID, + "", + time.Now(), + "", + false, + ) + if err != nil { + app.lo.Error("error creating conversation", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error creating conversation, Please try again.", nil, envelope.GeneralError) + } + + // Send message to the just created conversation as user. + message := models.Message{ + ConversationUUID: conversationUUID, + SenderID: contactID, + Type: models.MessageIncoming, + SenderType: models.SenderTypeContact, + Status: models.MessageStatusReceived, + Content: req.Message, + ContentType: models.ContentTypeText, + Private: false, + } + if err := app.conversation.InsertMessage(&message); err != nil { + app.lo.Error("error inserting initial message", "conversation_uuid", conversationUUID, "error", err) + } + + // Build response with conversation and messages. + resp, err := buildConversationResponse(app, conversationUUID, contactID) + if err != nil { + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error creating conversation, Please try again.", nil, envelope.GeneralError) + } + + return r.SendEnvelope(map[string]interface{}{ + "conversation": resp.Conversation, + "messages": resp.Messages, + "jwt": req.JWT, + }) +} + +// buildConversationResponse builds the response for a conversation including its messages +func buildConversationResponse(app *App, conversationUUID string, contactID int) (*conversationResp, error) { + // Fetch last 2000 messages, this should suffice as chats shouldn't have too many messages. + private := false + messages, _, err := app.conversation.GetConversationMessages(conversationUUID, []string{cmodels.MessageIncoming, cmodels.MessageOutgoing}, &private, 1, 2000) + if err != nil { + app.lo.Error("error fetching conversation messages", "conversation_uuid", conversationUUID, "error", err) + return nil, fmt.Errorf("failed to fetch conversation messages: %v", err) + } + + // Convert to chat message format + chatMessages := make([]chatMessage, len(messages)) + nameMap := make(map[int]string) + for i, msg := range messages { + // Get sender name + senderName := nameMap[msg.SenderID] + if msg.SenderType == models.SenderTypeContact { + if senderName == "" { + if contact, err := app.user.GetContact(contactID, ""); err == nil { + senderName = contact.FullName() + nameMap[msg.SenderID] = senderName + } + } + } + chatMessages[i] = chatMessage{ + UUID: msg.UUID, + Content: msg.TextContent, + CreatedAt: msg.CreatedAt, + SenderType: msg.SenderType, + SenderName: senderName, + ConversationID: msg.ConversationUUID, + } + } + + resp := &conversationResp{ + Conversation: Conversation{ + UUID: conversationUUID, + }, + Messages: chatMessages, + } + + return resp, nil +} + +// handleChatGetConversation fetches a chat conversation by ID +func handleChatGetConversation(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + conversationUUID = r.RequestCtx.UserValue("uuid").(string) + chatReq = chatInitReq{} + ) + + if conversationUUID == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "conversation_id is required", nil, envelope.InputError) + } + + // Decode chat request if present + if err := r.Decode(&chatReq, "json"); err != nil { + app.lo.Error("error unmarshalling chat request", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) + } + + // Verify JWT. + if chatReq.JWT == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "JWT is required", nil, envelope.InputError) + } + + claims, err := verifyStandardJWT(chatReq.JWT) + if err != nil { + app.lo.Error("invalid JWT", "jwt", chatReq.JWT, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, "Invalid JWT", nil, + envelope.InputError) + } + + contactID := claims.UserID + if contactID <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid user ID in JWT", nil, envelope.InputError) + } + + // Fetch conversation details + conversation, err := app.conversation.GetConversation(0, conversationUUID) + if err != nil { + app.lo.Error("error fetching conversation", "conversation_uuid", conversationUUID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusNotFound, "Conversation not found", nil, envelope.NotFoundError) + } + + // Make sure the conversation belongs to the contact + if conversation.ContactID != contactID { + app.lo.Error("unauthorized access to conversation", "conversation_uuid", conversationUUID, "contact_id", contactID) + return r.SendErrorEnvelope(fasthttp.StatusForbidden, "You do not have access to this conversation", nil, envelope.PermissionError) + } + + // Build conversation response with messages + resp, err := buildConversationResponse(app, conversation.UUID, conversation.ContactID) + if err != nil { + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Failed to fetch conversation messages", nil, envelope.GeneralError) + } + + return r.SendEnvelope(*resp) +} + +// handleGetConversations fetches all chat conversations for a widget user +func handleGetConversations(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + req = onlyJWT{} + ) + + if err := r.Decode(&req, "json"); err != nil { + app.lo.Error("error unmarshalling chat conversations request", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) + } + + if req.JWT == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "JWT is required", nil, envelope.InputError) + } + + claims, err := verifyStandardJWT(req.JWT) + if err != nil { + app.lo.Error("invalid JWT", "jwt", req.JWT, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, "Invalid JWT", nil, envelope.InputError) + } + + contactID := claims.UserID + if contactID <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid user ID in JWT", nil, envelope.InputError) + } + + conversations, err := app.conversation.GetContactConversations(contactID) + if err != nil { + app.lo.Error("error fetching conversations for contact", "contact_id", contactID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Failed to fetch conversations", nil, envelope.GeneralError) + } + + return r.SendEnvelope(conversations) +} + +// handleChatSendMessage sends a message in a chat conversation +func handleChatSendMessage(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + conversationUUID = r.RequestCtx.UserValue("uuid").(string) + req = chatMessageReq{} + ) + + if err := r.Decode(&req, "json"); err != nil { + app.lo.Error("error unmarshalling chat message request", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) + } + + var senderID int + var senderType = models.SenderTypeContact + if req.JWT == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "JWT is required", nil, envelope.InputError) + } + + if req.Message == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Message content is required", nil, envelope.InputError) + } + + claims, err := verifyStandardJWT(req.JWT) + if err != nil { + app.lo.Error("invalid JWT", "jwt", req.JWT, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, "Invalid JWT", nil, envelope.InputError) + } + senderID = claims.UserID + + if senderID <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid user ID in JWT", nil, envelope.InputError) + } + + // Fetch conversation to ensure it exists + conversation, err := app.conversation.GetConversation(0, conversationUUID) + if err != nil { + app.lo.Error("error fetching conversation", "conversation_uuid", conversationUUID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusNotFound, "Conversation not found", nil, envelope.NotFoundError) + } + + // Make sure the conversation belongs to the sender + if conversation.ContactID != senderID { + app.lo.Error("unauthorized access to conversation", "conversation_uuid", conversationUUID, "contact_id", senderID) + return r.SendErrorEnvelope(fasthttp.StatusForbidden, "You do not have access to this conversation", nil, envelope.PermissionError) + } + + // Create and insert message + message := models.Message{ + ConversationUUID: conversationUUID, + SenderID: senderID, + Type: models.MessageIncoming, + SenderType: senderType, + Status: models.MessageStatusReceived, + Content: req.Message, + ContentType: models.ContentTypeText, + Private: false, + } + + if err := app.conversation.InsertMessage(&message); err != nil { + app.lo.Error("error inserting chat message", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Failed to send message", nil, envelope.GeneralError) + } + + return r.SendEnvelope(map[string]bool{"success": true}) +} + +// handleChatArchiveConversation archives a chat conversation +func handleChatCloseConversation(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + conversationUUID = r.RequestCtx.UserValue("uuid").(string) + onlyJWT = onlyJWT{} + ) + + if conversationUUID == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "conversation_id is required", nil, envelope.InputError) + } + + // Decode + if err := r.Decode(&onlyJWT, "json"); err != nil { + app.lo.Error("error unmarshalling chat close request", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) + } + + // Verify JWT + if onlyJWT.JWT == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "JWT is required", nil, envelope.InputError) + } + + claims, err := verifyStandardJWT(onlyJWT.JWT) + if err != nil { + app.lo.Error("invalid JWT", "jwt", onlyJWT.JWT, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, "Invalid JWT", nil, envelope.InputError) + } + contactID := claims.UserID + if contactID <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid user ID in JWT", nil, envelope.InputError) + } + + // Fetch conversation to ensure it exists + conversation, err := app.conversation.GetConversation(0, conversationUUID) + if err != nil { + app.lo.Error("error fetching conversation for closing", "conversation_uuid", conversationUUID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusNotFound, "Conversation not found", nil, envelope.NotFoundError) + } + + // Make sure the conversation belongs to the contact + if conversation.ContactID != contactID { + app.lo.Error("unauthorized access to conversation for closing", "conversation_uuid", conversationUUID, "contact_id", contactID) + return r.SendErrorEnvelope(fasthttp.StatusForbidden, "You do not have access to this conversation", nil, envelope.PermissionError) + } + + contact, err := app.user.GetContact(contactID, "") + if err != nil { + app.lo.Error("error fetching contact for closing conversation", "contact_id", contactID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusNotFound, "Contact not found", nil, envelope.NotFoundError) + } + + err = app.conversation.UpdateConversationStatus(conversationUUID, 0, models.StatusClosed, "", contact) + if err != nil { + app.lo.Error("error archiving chat conversation", "conversation_uuid", conversationUUID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Failed to archive conversation", nil, envelope.GeneralError) + } + + return r.SendEnvelope(map[string]bool{"success": true}) +} + +// verifyJWT verifies and validates a JWT token with proper signature verification +func verifyJWT(tokenString string, secretKey []byte) (*Claims, error) { + // Parse and verify the token + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + // Verify the signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return secretKey, nil + }) + + if err != nil { + return nil, err + } + + // Extract claims if token is valid + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, fmt.Errorf("invalid token") +} + +// verifyStandardJWT verifies a standard JWT token using proper JWT library +func verifyStandardJWT(jwtToken string) (Claims, error) { + if jwtToken == "" { + return Claims{}, fmt.Errorf("JWT token is empty") + } + + claims, err := verifyJWT(jwtToken, getJWTSecret()) + if err != nil { + return Claims{}, err + } + + return *claims, nil +} + +// getJWTSecret gets the JWT secret key from configuration or uses a default +func getJWTSecret() []byte { + // TODO: Update this to pick from inbox config in db. + return []byte("your-secret-key-change-this-in-production") +} + +// generateUserJWT generates a JWT token for a user +func generateUserJWT(userID int, isGuest bool, expirationTime time.Time) (string, error) { + claims := &Claims{ + UserID: userID, + IsGuest: isGuest, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationTime), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString(getJWTSecret()) + if err != nil { + return "", err + } + return tokenString, nil +} diff --git a/cmd/conversation.go b/cmd/conversation.go index dbd04e18..c8b9a2ee 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -708,10 +708,8 @@ func handleCreateConversation(r *fastglue.Request) error { // 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)) @@ -720,7 +718,6 @@ func handleCreateConversation(r *fastglue.Request) error { // Create conversation conversationID, conversationUUID, err := app.conversation.CreateConversation( contact.ID, - contact.ContactChannelID, req.InboxID, "", /** last_message **/ time.Now(), /** last_message_at **/ diff --git a/cmd/handlers.go b/cmd/handlers.go index 6d803089..9a400ce2 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -5,6 +5,7 @@ import ( "net/http" "path" "path/filepath" + "strings" "github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/ws" @@ -214,8 +215,18 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { return handleWS(r, hub) })) + // Live chat widget. + g.GET("/widget/ws", handleWidgetWS) + g.GET("/api/v1/widget/chat/settings", handleGetChatSettings) + g.POST("/api/v1/widget/chat/conversations/init", handleChatInit) + g.POST("/api/v1/widget/chat/conversations", handleGetConversations) + g.POST("/api/v1/widget/chat/conversations/{uuid}", handleChatGetConversation) + g.POST("/api/v1/widget/chat/conversations/{uuid}/message", handleChatSendMessage) + g.POST("/api/v1/widget/chat/conversations/{uuid}/close", handleChatCloseConversation) + // Frontend pages. g.GET("/", notAuthPage(serveIndexPage)) + g.GET("/widget", serveWidgetIndexPage) g.GET("/inboxes/{all:*}", authPage(serveIndexPage)) g.GET("/teams/{all:*}", authPage(serveIndexPage)) g.GET("/views/{all:*}", authPage(serveIndexPage)) @@ -225,8 +236,12 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.GET("/account/{all:*}", authPage(serveIndexPage)) g.GET("/reset-password", notAuthPage(serveIndexPage)) g.GET("/set-password", notAuthPage(serveIndexPage)) - // FIXME: Don't need three separate routes for the same thing. + + // Assets and static files. + // FIXME: Reduce the number of routes. + g.GET("/widget.js", serveWidgetJS) g.GET("/assets/{all:*}", serveFrontendStaticFiles) + g.GET("/widget/assets/{all:*}", serveWidgetStaticFiles) g.GET("/images/{all:*}", serveFrontendStaticFiles) g.GET("/static/public/{all:*}", serveStaticFiles) @@ -263,6 +278,26 @@ func serveIndexPage(r *fastglue.Request) error { return nil } +// serveWidgetIndexPage serves the widget index page of the application. +func serveWidgetIndexPage(r *fastglue.Request) error { + app := r.Context.(*App) + + // Prevent caching of the index page. + r.RequestCtx.Response.Header.Add("Cache-Control", "no-store, no-cache, must-revalidate, post-check=0, pre-check=0") + r.RequestCtx.Response.Header.Add("Pragma", "no-cache") + r.RequestCtx.Response.Header.Add("Expires", "-1") + + // Serve the index.html file from the embedded filesystem. + file, err := app.fs.Get(path.Join(widgetDir, "index.html")) + if err != nil { + return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError) + } + r.RequestCtx.Response.Header.Set("Content-Type", "text/html") + r.RequestCtx.SetBody(file.ReadBytes()) + + return nil +} + // serveStaticFiles serves static assets from the embedded filesystem. func serveStaticFiles(r *fastglue.Request) error { app := r.Context.(*App) @@ -311,6 +346,47 @@ func serveFrontendStaticFiles(r *fastglue.Request) error { return nil } +// serveWidgetStaticFiles serves widget static assets from the embedded filesystem. +func serveWidgetStaticFiles(r *fastglue.Request) error { + app := r.Context.(*App) + + filePath := string(r.RequestCtx.Path()) + finalPath := filepath.Join(widgetDir, strings.TrimPrefix(filePath, "/widget")) + + file, err := app.fs.Get(finalPath) + if err != nil { + return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError) + } + + // Set the appropriate Content-Type based on the file extension. + ext := filepath.Ext(filePath) + contentType := mime.TypeByExtension(ext) + if contentType == "" { + contentType = http.DetectContentType(file.ReadBytes()) + } + r.RequestCtx.Response.Header.Set("Content-Type", contentType) + r.RequestCtx.SetBody(file.ReadBytes()) + return nil +} + +// serveWidgetJS serves the widget JavaScript file. +func serveWidgetJS(r *fastglue.Request) error { + app := r.Context.(*App) + + // Set appropriate headers for JavaScript + r.RequestCtx.Response.Header.Set("Content-Type", "application/javascript") + r.RequestCtx.Response.Header.Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour + + // Serve the widget.js file from the embedded filesystem. + file, err := app.fs.Get("static/widget.js") + if err != nil { + return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.NotFoundError) + } + + r.RequestCtx.SetBody(file.ReadBytes()) + return nil +} + // sendErrorEnvelope sends a standardized error response to the client. func sendErrorEnvelope(r *fastglue.Request, err error) error { e, ok := err.(envelope.Error) diff --git a/cmd/inboxes.go b/cmd/inboxes.go index 1c8edbaf..1bac4c7b 100644 --- a/cmd/inboxes.go +++ b/cmd/inboxes.go @@ -154,9 +154,11 @@ func handleDeleteInbox(r *fastglue.Request) error { // 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) + // Validate from address only for email channels. + if inbox.Channel == "email" { + if _, err := mail.ParseAddress(inbox.From); err != nil { + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalidFromAddress"), nil) + } } if len(inbox.Config) == 0 { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "config"), nil) diff --git a/cmd/init.go b/cmd/init.go index c38a10f5..29301e89 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -27,6 +27,7 @@ import ( customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute" "github.com/abhinavxd/libredesk/internal/inbox" "github.com/abhinavxd/libredesk/internal/inbox/channel/email" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" imodels "github.com/abhinavxd/libredesk/internal/inbox/models" "github.com/abhinavxd/libredesk/internal/macro" "github.com/abhinavxd/libredesk/internal/media" @@ -132,7 +133,8 @@ func initConstants() *constants { // initFS initializes the stuffbin FileSystem. func initFS() stuffbin.FileSystem { var files = []string{ - "frontend/dist", + "frontend/dist/main", + "frontend/dist/widget", "i18n", "static", } @@ -572,11 +574,41 @@ func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrS return inbox, nil } +// initLiveChatInbox initializes the live chat inbox. +func initLiveChatInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) { + var config livechat.Config + + // Load JSON data into Koanf. + if err := ko.Load(rawbytes.Provider([]byte(inboxRecord.Config)), kjson.Parser()); err != nil { + return nil, fmt.Errorf("loading config: %w", err) + } + + if err := ko.UnmarshalWithConf("", &config, koanf.UnmarshalConf{Tag: "json"}); err != nil { + return nil, fmt.Errorf("unmarshalling `%s` %s config: %w", inboxRecord.Channel, inboxRecord.Name, err) + } + + inbox, err := livechat.New(msgStore, usrStore, livechat.Opts{ + ID: inboxRecord.ID, + Config: config, + Lo: initLogger("livechat_inbox"), + }) + + if err != nil { + return nil, fmt.Errorf("initializing `%s` inbox: `%s` error : %w", inboxRecord.Channel, inboxRecord.Name, err) + } + + log.Printf("`%s` inbox successfully initialized", inboxRecord.Name) + + return inbox, nil +} + // initializeInboxes handles inbox initialization. func initializeInboxes(inboxR imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) { switch inboxR.Channel { case "email": return initEmailInbox(inboxR, msgStore, usrStore) + case "livechat": + return initLiveChatInbox(inboxR, msgStore, usrStore) default: return nil, fmt.Errorf("unknown inbox channel: %s", inboxR.Channel) } diff --git a/cmd/main.go b/cmd/main.go index 1fd75fe8..496e2d5b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -54,7 +54,8 @@ var ( ko = koanf.New(".") ctx = context.Background() appName = "libredesk" - frontendDir = "frontend/dist" + frontendDir = "frontend/dist/main" + widgetDir = "frontend/dist/widget" // Injected at build time. buildString string diff --git a/cmd/messages.go b/cmd/messages.go index b9015bd9..1779c18f 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -4,6 +4,7 @@ import ( "strconv" amodels "github.com/abhinavxd/libredesk/internal/auth/models" + cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" "github.com/abhinavxd/libredesk/internal/envelope" medModels "github.com/abhinavxd/libredesk/internal/media/models" "github.com/valyala/fasthttp" @@ -41,7 +42,7 @@ func handleGetMessages(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - messages, pageSize, err := app.conversation.GetConversationMessages(uuid, page, pageSize) + messages, pageSize, err := app.conversation.GetConversationMessages(uuid, []string{cmodels.MessageIncoming, cmodels.MessageOutgoing, cmodels.MessageActivity}, nil, page, pageSize) if err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 8e9ec503..e56ff3d5 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -35,6 +35,7 @@ var migList = []migFunc{ {"v0.5.0", migrations.V0_5_0}, {"v0.6.0", migrations.V0_6_0}, {"v0.7.0", migrations.V0_7_0}, + {"v0.8.0", migrations.V0_8_0}, } // upgrade upgrades the database to the current version by running SQL migration files diff --git a/cmd/widget_ws.go b/cmd/widget_ws.go new file mode 100644 index 00000000..d5bdc1eb --- /dev/null +++ b/cmd/widget_ws.go @@ -0,0 +1,208 @@ +package main + +import ( + "encoding/json" + "fmt" + + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" + "github.com/fasthttp/websocket" + "github.com/zerodha/fastglue" +) + +// Widget WebSocket message types +const ( + WidgetMsgTypeJoin = "join" + WidgetMsgTypeMessage = "message" + WidgetMsgTypeTyping = "typing" + WidgetMsgTypeError = "error" + WidgetMsgTypeNewMsg = "new_message" + WidgetMsgTypeStatus = "status" +) + +// WidgetMessage represents a message sent through the widget WebSocket +type WidgetMessage struct { + Type string `json:"type"` + JWT string `json:"jwt,omitempty"` + Data interface{} `json:"data"` +} + +// WidgetJoinData represents data for joining a conversation +type WidgetJoinData struct { + ConversationUUID string `json:"conversation_uuid"` +} + +// WidgetMessageData represents a chat message through the widget +type WidgetMessageData struct { + ConversationID string `json:"conversation_id"` + Content string `json:"content"` + SenderName string `json:"sender_name,omitempty"` + SenderType string `json:"sender_type"` + Timestamp int64 `json:"timestamp"` +} + +// WidgetTypingData represents typing indicator data +type WidgetTypingData struct { + ConversationID string `json:"conversation_id"` + IsTyping bool `json:"is_typing"` +} + +// handleWidgetWS handles the widget WebSocket connection for public live chat +func handleWidgetWS(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + ) + + if err := upgrader.Upgrade(r.RequestCtx, func(conn *websocket.Conn) { + defer conn.Close() + // Handle incoming messages + for { + var msg WidgetMessage + if err := conn.ReadJSON(&msg); err != nil { + app.lo.Error("error reading widget websocket message", "error", err) + break + } + + claims, err := validateWidgetMessageJWT(msg.JWT) + if err != nil { + app.lo.Error("invalid JWT in widget message", "error", err) + sendWidgetError(conn, "Invalid JWT token") + continue + } + + switch msg.Type { + // Join conversation request. + case WidgetMsgTypeJoin: + if err := handleWidgetJoin(app, conn, &msg, claims); err != nil { + app.lo.Error("error handling widget join", "error", err) + sendWidgetError(conn, "Failed to join conversation") + continue + } + // Typing. + case WidgetMsgTypeTyping: + if err := handleWidgetTyping(app, &msg, claims); err != nil { + app.lo.Error("error handling widget typing", "error", err) + continue + } + } + } + }); err != nil { + app.lo.Error("error upgrading widget websocket connection", "error", err) + } + return nil +} + +// handleWidgetJoin handles a client joining a conversation +func handleWidgetJoin(app *App, conn *websocket.Conn, msg *WidgetMessage, claims Claims) error { + userID := claims.UserID + + joinDataBytes, err := json.Marshal(msg.Data) + if err != nil { + return fmt.Errorf("invalid join data: %w", err) + } + + var joinData WidgetJoinData + if err := json.Unmarshal(joinDataBytes, &joinData); err != nil { + return fmt.Errorf("invalid join data format: %w", err) + } + + // Get conversation to find the inbox + conversation, err := app.conversation.GetConversation(0, joinData.ConversationUUID) + if err != nil { + return fmt.Errorf("conversation not found: %w", err) + } + + // Make sure conversation belongs to the user. + if conversation.ContactID != userID { + return fmt.Errorf("conversation does not belong to the user") + } + + // Make sure inbox is active. + inbox, err := app.inbox.GetDBRecord(conversation.InboxID) + if err != nil { + return fmt.Errorf("inbox not found: %w", err) + } + + if !inbox.Enabled { + return fmt.Errorf("inbox is not enabled") + } + + // Get live chat inbox + lcInbox, err := app.inbox.Get(inbox.ID) + if err != nil { + return fmt.Errorf("live chat inbox not found: %w", err) + } + + // Assert type. + chatInbox, ok := lcInbox.(*livechat.LiveChat) + if !ok { + return fmt.Errorf("inbox is not a live chat inbox") + } + + // Add client to live chat session + userIDStr := fmt.Sprintf("%d", userID) + client := chatInbox.AddClient(userIDStr, joinData.ConversationUUID) + + // Start listening for messages from the live chat channel + go func() { + for msgData := range client.Channel { + // Forward message to WebSocket client + if err := conn.WriteMessage(websocket.TextMessage, msgData); err != nil { + app.lo.Error("error forwarding message to widget client", "error", err) + return + } + } + }() + + // Send join confirmation + joinResp := WidgetMessage{ + Type: WidgetMsgTypeStatus, + Data: map[string]string{ + "message": "Joined conversation successfully", + "conversation_uuid": joinData.ConversationUUID, + }, + } + + return conn.WriteJSON(joinResp) +} + +// handleWidgetTyping handles typing indicators +func handleWidgetTyping(app *App, msg *WidgetMessage, claims Claims) error { + userID := claims.UserID + typingDataBytes, err := json.Marshal(msg.Data) + if err != nil { + app.lo.Error("error marshalling typing data", "error", err) + return fmt.Errorf("invalid typing data: %w", err) + } + + var typingData WidgetTypingData + if err := json.Unmarshal(typingDataBytes, &typingData); err != nil { + app.lo.Error("error unmarshalling typing data", "error", err) + return fmt.Errorf("invalid typing data format: %w", err) + } + // TODO: broadcast typing data to all clients in the conversation. + app.lo.Debug("Received typing data for user", "user_id", userID, "is_typing", typingData.IsTyping) + return nil +} + +// validateWidgetMessageJWT validates the incoming widget message JWT and returns the claims +func validateWidgetMessageJWT(jwt string) (Claims, error) { + // Verify JWT + claims, err := verifyStandardJWT(jwt) + if err != nil { + return Claims{}, fmt.Errorf("invalid JWT token: %w", err) + } + + // Return claims as a map + return claims, nil +} + +// sendWidgetError sends an error message to the widget client +func sendWidgetError(conn *websocket.Conn, message string) { + errorMsg := WidgetMessage{ + Type: WidgetMsgTypeError, + Data: map[string]string{ + "message": message, + }, + } + conn.WriteJSON(errorMsg) +} diff --git a/frontend/README-SETUP.md b/frontend/README-SETUP.md new file mode 100644 index 00000000..2f30baa2 --- /dev/null +++ b/frontend/README-SETUP.md @@ -0,0 +1,59 @@ +# Libredesk Frontend - Multi-App Setup + +This frontend supports both the main Libredesk application and a chat widget as separate Vue applications sharing common UI components. + +## Project Structure + +``` +frontend/ +├── apps/ +│ ├── main/ # Main Libredesk application +│ │ ├── src/ +│ │ └── index.html +│ └── widget/ # Chat widget application +│ ├── src/ +│ └── index.html +├── shared-ui/ # Shared UI components (shadcn/ui) +│ ├── components/ +│ │ └── ui/ # shadcn/ui components +│ ├── lib/ # Utility functions +│ └── assets/ # Shared styles +└── package.json +``` + +## Development + +Check Makefile for available commands. + +## Shared UI Components + +The `shared-ui` directory contains all the shadcn/ui components that can be used in both apps. + +### Using Shared Components + +```vue + + + +``` + +### Path Aliases + +- `@shared-ui` - Points to the shared-ui directory +- `@main` - Points to apps/main/src +- `@widget` - Points to apps/widget/src +- `@` - Points to the current app's src directory (context-dependent) diff --git a/frontend/index.html b/frontend/apps/main/index.html similarity index 100% rename from frontend/index.html rename to frontend/apps/main/index.html diff --git a/frontend/src/App.vue b/frontend/apps/main/src/App.vue similarity index 88% rename from frontend/src/App.vue rename to frontend/apps/main/src/App.vue index 78ee23db..2c3099db 100644 --- a/frontend/src/App.vue +++ b/frontend/apps/main/src/App.vue @@ -112,26 +112,26 @@ diff --git a/frontend/src/api/index.js b/frontend/apps/main/src/api/index.js similarity index 100% rename from frontend/src/api/index.js rename to frontend/apps/main/src/api/index.js diff --git a/frontend/src/assets/styles/main.scss b/frontend/apps/main/src/assets/styles/main.scss similarity index 100% rename from frontend/src/assets/styles/main.scss rename to frontend/apps/main/src/assets/styles/main.scss diff --git a/frontend/src/components/button/CloseButton.vue b/frontend/apps/main/src/components/button/CloseButton.vue similarity index 87% rename from frontend/src/components/button/CloseButton.vue rename to frontend/apps/main/src/components/button/CloseButton.vue index c48b63b5..45f8ad97 100644 --- a/frontend/src/components/button/CloseButton.vue +++ b/frontend/apps/main/src/components/button/CloseButton.vue @@ -12,7 +12,7 @@ diff --git a/frontend/src/composables/useActivityLogFilters.js b/frontend/apps/main/src/composables/useActivityLogFilters.js similarity index 91% rename from frontend/src/composables/useActivityLogFilters.js rename to frontend/apps/main/src/composables/useActivityLogFilters.js index 5ee401ef..7dfdabfe 100644 --- a/frontend/src/composables/useActivityLogFilters.js +++ b/frontend/apps/main/src/composables/useActivityLogFilters.js @@ -1,6 +1,6 @@ import { computed } from 'vue' -import { useUsersStore } from '@/stores/users' -import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig' +import { useUsersStore } from '../stores/users' +import { FIELD_TYPE, FIELD_OPERATORS } from '../constants/filterConfig' import { useI18n } from 'vue-i18n' export function useActivityLogFilters () { diff --git a/frontend/src/composables/useConversationFilters.js b/frontend/apps/main/src/composables/useConversationFilters.js similarity index 96% rename from frontend/src/composables/useConversationFilters.js rename to frontend/apps/main/src/composables/useConversationFilters.js index 97dd0a6c..271976ea 100644 --- a/frontend/src/composables/useConversationFilters.js +++ b/frontend/apps/main/src/composables/useConversationFilters.js @@ -1,11 +1,11 @@ import { computed } from 'vue' -import { useConversationStore } from '@/stores/conversation' -import { useInboxStore } from '@/stores/inbox' -import { useUsersStore } from '@/stores/users' -import { useTeamStore } from '@/stores/team' -import { useSlaStore } from '@/stores/sla' -import { useCustomAttributeStore } from '@/stores/customAttributes' -import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig' +import { useConversationStore } from '../stores/conversation' +import { useInboxStore } from '../stores/inbox' +import { useUsersStore } from '../stores/users' +import { useTeamStore } from '../stores/team' +import { useSlaStore } from '../stores/sla' +import { useCustomAttributeStore } from '../stores/customAttributes' +import { FIELD_TYPE, FIELD_OPERATORS } from '../constants/filterConfig' import { useI18n } from 'vue-i18n' export function useConversationFilters () { diff --git a/frontend/src/composables/useEmitter.js b/frontend/apps/main/src/composables/useEmitter.js similarity index 100% rename from frontend/src/composables/useEmitter.js rename to frontend/apps/main/src/composables/useEmitter.js diff --git a/frontend/src/composables/useFileUpload.js b/frontend/apps/main/src/composables/useFileUpload.js similarity index 96% rename from frontend/src/composables/useFileUpload.js rename to frontend/apps/main/src/composables/useFileUpload.js index 5f43f3a4..7733781d 100644 --- a/frontend/src/composables/useFileUpload.js +++ b/frontend/apps/main/src/composables/useFileUpload.js @@ -1,8 +1,8 @@ import { ref, readonly } from 'vue' -import { useEmitter } from '@/composables/useEmitter' -import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' -import { handleHTTPError } from '@/utils/http' -import api from '@/api' +import { useEmitter } from './useEmitter' +import { EMITTER_EVENTS } from '../constants/emitterEvents.js' +import { handleHTTPError } from '../utils/http' +import api from '../api' /** * Composable for handling file uploads diff --git a/frontend/src/composables/useIdleDetection.js b/frontend/apps/main/src/composables/useIdleDetection.js similarity index 94% rename from frontend/src/composables/useIdleDetection.js rename to frontend/apps/main/src/composables/useIdleDetection.js index 7254c2f9..38a8ea97 100644 --- a/frontend/src/composables/useIdleDetection.js +++ b/frontend/apps/main/src/composables/useIdleDetection.js @@ -1,6 +1,6 @@ import { ref, onMounted, onBeforeUnmount, watch } from 'vue' -import { useUserStore } from '@/stores/user' -import { debounce } from '@/utils/debounce' +import { useUserStore } from '../stores/user' +import { debounce } from '../utils/debounce' import { useStorage } from '@vueuse/core' export function useIdleDetection () { diff --git a/frontend/src/composables/useSla.js b/frontend/apps/main/src/composables/useSla.js similarity index 92% rename from frontend/src/composables/useSla.js rename to frontend/apps/main/src/composables/useSla.js index 7fdad771..958f5f85 100644 --- a/frontend/src/composables/useSla.js +++ b/frontend/apps/main/src/composables/useSla.js @@ -1,5 +1,5 @@ import { ref, onMounted, onUnmounted } from 'vue' -import { calculateSla } from '@/utils/sla' +import { calculateSla } from '../utils/sla' export function useSla (dueAt, actualAt) { const sla = ref(null) diff --git a/frontend/src/composables/useTemporaryClass.js b/frontend/apps/main/src/composables/useTemporaryClass.js similarity index 100% rename from frontend/src/composables/useTemporaryClass.js rename to frontend/apps/main/src/composables/useTemporaryClass.js diff --git a/frontend/src/constants/conversation.js b/frontend/apps/main/src/constants/conversation.js similarity index 100% rename from frontend/src/constants/conversation.js rename to frontend/apps/main/src/constants/conversation.js diff --git a/frontend/src/constants/countries.js b/frontend/apps/main/src/constants/countries.js similarity index 100% rename from frontend/src/constants/countries.js rename to frontend/apps/main/src/constants/countries.js diff --git a/frontend/src/constants/date.js b/frontend/apps/main/src/constants/date.js similarity index 100% rename from frontend/src/constants/date.js rename to frontend/apps/main/src/constants/date.js diff --git a/frontend/src/constants/emitterEvents.js b/frontend/apps/main/src/constants/emitterEvents.js similarity index 100% rename from frontend/src/constants/emitterEvents.js rename to frontend/apps/main/src/constants/emitterEvents.js diff --git a/frontend/src/constants/filterConfig.js b/frontend/apps/main/src/constants/filterConfig.js similarity index 100% rename from frontend/src/constants/filterConfig.js rename to frontend/apps/main/src/constants/filterConfig.js diff --git a/frontend/src/constants/navigation.js b/frontend/apps/main/src/constants/navigation.js similarity index 100% rename from frontend/src/constants/navigation.js rename to frontend/apps/main/src/constants/navigation.js diff --git a/frontend/src/constants/permissions.js b/frontend/apps/main/src/constants/permissions.js similarity index 100% rename from frontend/src/constants/permissions.js rename to frontend/apps/main/src/constants/permissions.js diff --git a/frontend/src/constants/timezones.js b/frontend/apps/main/src/constants/timezones.js similarity index 100% rename from frontend/src/constants/timezones.js rename to frontend/apps/main/src/constants/timezones.js diff --git a/frontend/src/constants/user.js b/frontend/apps/main/src/constants/user.js similarity index 100% rename from frontend/src/constants/user.js rename to frontend/apps/main/src/constants/user.js diff --git a/frontend/src/constants/websocket.js b/frontend/apps/main/src/constants/websocket.js similarity index 100% rename from frontend/src/constants/websocket.js rename to frontend/apps/main/src/constants/websocket.js diff --git a/frontend/src/features/admin/activity-log/ActivityLog.vue b/frontend/apps/main/src/features/admin/activity-log/ActivityLog.vue similarity index 93% rename from frontend/src/features/admin/activity-log/ActivityLog.vue rename to frontend/apps/main/src/features/admin/activity-log/ActivityLog.vue index 8f3fc5f7..8ff8d827 100644 --- a/frontend/src/features/admin/activity-log/ActivityLog.vue +++ b/frontend/apps/main/src/features/admin/activity-log/ActivityLog.vue @@ -148,7 +148,7 @@ diff --git a/frontend/src/features/admin/inbox/formSchema.js b/frontend/apps/main/src/features/admin/inbox/formSchema.js similarity index 97% rename from frontend/src/features/admin/inbox/formSchema.js rename to frontend/apps/main/src/features/admin/inbox/formSchema.js index f6759ac9..f7b039c3 100644 --- a/frontend/src/features/admin/inbox/formSchema.js +++ b/frontend/apps/main/src/features/admin/inbox/formSchema.js @@ -1,5 +1,5 @@ import * as z from 'zod' -import { isGoDuration } from '@/utils/strings' +import { isGoDuration } from '../../../utils/strings' export const createFormSchema = (t) => z.object({ name: z.string().min(1, t('globals.messages.required')), diff --git a/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.js b/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.js new file mode 100644 index 00000000..6805d75d --- /dev/null +++ b/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.js @@ -0,0 +1,57 @@ +import { z } from 'zod' + +export const createFormSchema = (t) => z.object({ + name: z.string().min(1, { message: t('globals.messages.required') }), + enabled: z.boolean(), + csat_enabled: z.boolean(), + brand_name: z.string().min(1, { message: t('globals.messages.required') }), + config: z.object({ + logo_url: z.string().url({ message: t('globals.messages.invalidUrl') }).optional().or(z.literal('')), + secret_key: z.string().optional(), + launcher: z.object({ + position: z.enum(['left', 'right']), + logo_url: z.string().url({ message: t('globals.messages.invalidUrl') }).optional().or(z.literal('')), + spacing: z.object({ + side: z.number().min(0), + bottom: z.number().min(0), + }) + }), + greeting_message: z.string().optional(), + introduction_message: z.string().optional(), + chat_introduction: z.string(), + show_office_hours_in_chat: z.boolean(), + show_office_hours_after_assignment: z.boolean(), + notice_banner: z.object({ + enabled: z.boolean(), + text: z.string() + }), + colors: z.object({ + primary: z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, { + message: t('globals.messages.invalidColor') + }), + background: z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, { + message: t('globals.messages.invalidColor') + }) + }), + features: z.object({ + file_upload: z.boolean(), + emoji: z.boolean(), + allow_close_conversation: z.boolean() + }), + trusted_domains: z.string().optional(), + external_links: z.array(z.object({ + text: z.string().min(1), + url: z.string().url({ message: t('globals.messages.invalidUrl') }) + })), + visitors: z.object({ + start_conversation_button_text: z.string(), + allow_start_conversation: z.boolean(), + prevent_multiple_conversations: z.boolean() + }), + users: z.object({ + start_conversation_button_text: z.string(), + allow_start_conversation: z.boolean(), + prevent_multiple_conversations: z.boolean() + }) + }) +}) diff --git a/frontend/src/features/admin/macros/ActionBuilder.vue b/frontend/apps/main/src/features/admin/macros/ActionBuilder.vue similarity index 94% rename from frontend/src/features/admin/macros/ActionBuilder.vue rename to frontend/apps/main/src/features/admin/macros/ActionBuilder.vue index e2dbd177..c93f8d63 100644 --- a/frontend/src/features/admin/macros/ActionBuilder.vue +++ b/frontend/apps/main/src/features/admin/macros/ActionBuilder.vue @@ -129,7 +129,7 @@ diff --git a/frontend/src/features/admin/status/dataTableColumns.js b/frontend/apps/main/src/features/admin/status/dataTableColumns.js similarity index 100% rename from frontend/src/features/admin/status/dataTableColumns.js rename to frontend/apps/main/src/features/admin/status/dataTableColumns.js diff --git a/frontend/src/features/admin/status/dataTableDropdown.vue b/frontend/apps/main/src/features/admin/status/dataTableDropdown.vue similarity index 88% rename from frontend/src/features/admin/status/dataTableDropdown.vue rename to frontend/apps/main/src/features/admin/status/dataTableDropdown.vue index 5f2cf9a5..2ed86443 100644 --- a/frontend/src/features/admin/status/dataTableDropdown.vue +++ b/frontend/apps/main/src/features/admin/status/dataTableDropdown.vue @@ -76,7 +76,7 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' +} from '@shared-ui/components/ui/dropdown-menu/index.js' import { AlertDialog, AlertDialogAction, @@ -86,8 +86,8 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle -} from '@/components/ui/alert-dialog' -import { Button } from '@/components/ui/button' +} from '@shared-ui/components/ui/alert-dialog/index.js' +import { Button } from '@shared-ui/components/ui/button/index.js' import { useForm } from 'vee-validate' import { toTypedSchema } from '@vee-validate/zod' import { createFormSchema } from './formSchema.js' @@ -100,13 +100,13 @@ import { DialogHeader, DialogTitle, DialogTrigger -} from '@/components/ui/dialog' -import { CONVERSATION_DEFAULT_STATUSES_LIST } from '@/constants/conversation.js' -import { useEmitter } from '@/composables/useEmitter' -import { handleHTTPError } from '@/utils/http' -import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' +} from '@shared-ui/components/ui/dialog/index.js' +import { CONVERSATION_DEFAULT_STATUSES_LIST } from '../../../constants/conversation.js' +import { useEmitter } from '../../../composables/useEmitter.js' +import { handleHTTPError } from '../../../utils/http.js' +import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js' import { useI18n } from 'vue-i18n' -import api from '@/api/index.js' +import api from '../../../api/index.js' const { t } = useI18n() const isLoading = ref(false) diff --git a/frontend/src/features/admin/status/formSchema.js b/frontend/apps/main/src/features/admin/status/formSchema.js similarity index 100% rename from frontend/src/features/admin/status/formSchema.js rename to frontend/apps/main/src/features/admin/status/formSchema.js diff --git a/frontend/src/features/admin/tags/TagsForm.vue b/frontend/apps/main/src/features/admin/tags/TagsForm.vue similarity index 87% rename from frontend/src/features/admin/tags/TagsForm.vue rename to frontend/apps/main/src/features/admin/tags/TagsForm.vue index b485c207..d1a23efc 100644 --- a/frontend/src/features/admin/tags/TagsForm.vue +++ b/frontend/apps/main/src/features/admin/tags/TagsForm.vue @@ -23,6 +23,6 @@ import { FormItem, FormLabel, FormMessage -} from '@/components/ui/form' -import { Input } from '@/components/ui/input' +} from '@shared-ui/components/ui/form' +import { Input } from '@shared-ui/components/ui/input' \ No newline at end of file diff --git a/frontend/src/features/admin/tags/dataTableColumns.js b/frontend/apps/main/src/features/admin/tags/dataTableColumns.js similarity index 100% rename from frontend/src/features/admin/tags/dataTableColumns.js rename to frontend/apps/main/src/features/admin/tags/dataTableColumns.js diff --git a/frontend/src/features/admin/tags/dataTableDropdown.vue b/frontend/apps/main/src/features/admin/tags/dataTableDropdown.vue similarity index 90% rename from frontend/src/features/admin/tags/dataTableDropdown.vue rename to frontend/apps/main/src/features/admin/tags/dataTableDropdown.vue index 7d403c85..1c24462e 100644 --- a/frontend/src/features/admin/tags/dataTableDropdown.vue +++ b/frontend/apps/main/src/features/admin/tags/dataTableDropdown.vue @@ -61,8 +61,8 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { Button } from '@/components/ui/button' +} from '@shared-ui/components/ui/dropdown-menu/index.js' +import { Button } from '@shared-ui/components/ui/button/index.js' import { useForm } from 'vee-validate' import { toTypedSchema } from '@vee-validate/zod' import { createFormSchema } from './formSchema.js' @@ -74,7 +74,7 @@ import { DialogHeader, DialogTitle, DialogTrigger -} from '@/components/ui/dialog' +} from '@shared-ui/components/ui/dialog/index.js' import { AlertDialog, AlertDialogAction, @@ -84,12 +84,12 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle -} from '@/components/ui/alert-dialog' -import { useEmitter } from '@/composables/useEmitter' -import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' +} from '@shared-ui/components/ui/alert-dialog/index.js' +import { useEmitter } from '../../../composables/useEmitter.js' +import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js' import TagsForm from './TagsForm.vue' import { useI18n } from 'vue-i18n' -import api from '@/api/index.js' +import api from '../../../api/index.js' const { t } = useI18n() const dialogOpen = ref(false) diff --git a/frontend/src/features/admin/tags/formSchema.js b/frontend/apps/main/src/features/admin/tags/formSchema.js similarity index 100% rename from frontend/src/features/admin/tags/formSchema.js rename to frontend/apps/main/src/features/admin/tags/formSchema.js diff --git a/frontend/src/features/admin/teams/TeamDataTableDropdown.vue b/frontend/apps/main/src/features/admin/teams/TeamDataTableDropdown.vue similarity index 85% rename from frontend/src/features/admin/teams/TeamDataTableDropdown.vue rename to frontend/apps/main/src/features/admin/teams/TeamDataTableDropdown.vue index d12310ac..c8d5198b 100644 --- a/frontend/src/features/admin/teams/TeamDataTableDropdown.vue +++ b/frontend/apps/main/src/features/admin/teams/TeamDataTableDropdown.vue @@ -36,7 +36,7 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' +} from '@shared-ui/components/ui/dropdown-menu' import { AlertDialog, AlertDialogAction, @@ -46,13 +46,13 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle -} from '@/components/ui/alert-dialog' -import { Button } from '@/components/ui/button' +} from '@shared-ui/components/ui/alert-dialog' +import { Button } from '@shared-ui/components/ui/button' import { useRouter } from 'vue-router' -import { useEmitter } from '@/composables/useEmitter' -import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' -import { handleHTTPError } from '@/utils/http' -import api from '@/api' +import { useEmitter } from '../../../composables/useEmitter' +import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js' +import { handleHTTPError } from '../../../utils/http' +import api from '../../../api' const alertOpen = ref(false) const router = useRouter() diff --git a/frontend/src/features/admin/teams/TeamForm.vue b/frontend/apps/main/src/features/admin/teams/TeamForm.vue similarity index 92% rename from frontend/src/features/admin/teams/TeamForm.vue rename to frontend/apps/main/src/features/admin/teams/TeamForm.vue index cffac179..084106bd 100644 --- a/frontend/src/features/admin/teams/TeamForm.vue +++ b/frontend/apps/main/src/features/admin/teams/TeamForm.vue @@ -147,7 +147,7 @@ diff --git a/frontend/src/features/conversation/message/ActivityMessageBubble.vue b/frontend/apps/main/src/features/conversation/message/ActivityMessageBubble.vue similarity index 86% rename from frontend/src/features/conversation/message/ActivityMessageBubble.vue rename to frontend/apps/main/src/features/conversation/message/ActivityMessageBubble.vue index d4bd641e..cca80e79 100644 --- a/frontend/src/features/conversation/message/ActivityMessageBubble.vue +++ b/frontend/apps/main/src/features/conversation/message/ActivityMessageBubble.vue @@ -18,7 +18,7 @@ + + + \ No newline at end of file diff --git a/frontend/apps/widget/src/App.vue b/frontend/apps/widget/src/App.vue new file mode 100644 index 00000000..94b8fb42 --- /dev/null +++ b/frontend/apps/widget/src/App.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/frontend/apps/widget/src/api/index.js b/frontend/apps/widget/src/api/index.js new file mode 100644 index 00000000..2a6904b9 --- /dev/null +++ b/frontend/apps/widget/src/api/index.js @@ -0,0 +1,43 @@ +import axios from 'axios' + +const http = axios.create({ + timeout: 10000, + responseType: 'json' +}) + +// Set content type if not specified and add libredesk_session to POST/PUT requests +http.interceptors.request.use((request) => { + if ((request.method === 'post' || request.method === 'put') && !request.headers['Content-Type']) { + request.headers['Content-Type'] = 'application/json' + } + + // Add libredesk_session to POST/PUT request data + if (request.method === 'post' || request.method === 'put') { + const libredeskSession = localStorage.getItem('libredesk_session') + request.data = { + ...request.data, + inbox_id: 11, + jwt: libredeskSession + } + } + + return request +}) + +const getWidgetSettings = (inboxID) => http.get('/api/v1/widget/chat/settings', { + params: { inbox_id: inboxID } +}) +const initChatConversation = (data) => http.post('/api/v1/widget/chat/conversations/init', data) +const getChatConversations = () => http.post('/api/v1/widget/chat/conversations') +const getChatConversation = (uuid) => http.post(`/api/v1/widget/chat/conversations/${uuid}`) +const sendChatMessage = (uuid, data) => http.post(`/api/v1/widget/chat/conversations/${uuid}/message`, data) +const closeChatConversation = (uuid) => http.post(`/api/v1/widget/chat/conversations/${uuid}/close`) + +export default { + getWidgetSettings, + initChatConversation, + getChatConversations, + getChatConversation, + sendChatMessage, + closeChatConversation +} diff --git a/frontend/apps/widget/src/assets/widget.css b/frontend/apps/widget/src/assets/widget.css new file mode 100644 index 00000000..e69de29b diff --git a/frontend/apps/widget/src/main.js b/frontend/apps/widget/src/main.js new file mode 100644 index 00000000..92f612f5 --- /dev/null +++ b/frontend/apps/widget/src/main.js @@ -0,0 +1,12 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import '@shared-ui/assets/styles/main.scss' +import './assets/widget.css' + +const app = createApp(App) +const pinia = createPinia() + +app.use(pinia) + +app.mount('#app') diff --git a/frontend/apps/widget/src/router/index.js b/frontend/apps/widget/src/router/index.js new file mode 100644 index 00000000..7584defa --- /dev/null +++ b/frontend/apps/widget/src/router/index.js @@ -0,0 +1,23 @@ +import { createRouter, createWebHistory } from 'vue-router' +import WelcomeView from '../views/WelcomeView.vue' +import ChatView from '../views/ChatView.vue' + +const routes = [ + { + path: '/', + name: 'welcome', + component: WelcomeView + }, + { + path: '/chat', + name: 'chat', + component: ChatView + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +export default router diff --git a/frontend/apps/widget/src/store/chat.js b/frontend/apps/widget/src/store/chat.js new file mode 100644 index 00000000..cbb29160 --- /dev/null +++ b/frontend/apps/widget/src/store/chat.js @@ -0,0 +1,81 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export const useChatStore = defineStore('chat', () => { + // State + const messages = ref([]) + const isTyping = ref(false) + const currentConversationId = ref(null) + + // Getters + const hasMessages = computed(() => messages.value.length > 0) + const messageCount = computed(() => messages.value.length) + const getMessages = () => [...messages.value].reverse() + + + // Actions + const addMessage = (message) => { + messages.value.push(message) + } + + const replaceMessages = (newMessages) => { + // Clear existing messages and replace with new ones + messages.value = [] + + if (Array.isArray(newMessages)) { + messages.value = [...newMessages] + } + } + + const clearMessages = () => { + messages.value = [] + } + + const setTypingStatus = (status) => { + isTyping.value = status + } + + const setCurrentConversationId = (conversationId) => { + currentConversationId.value = conversationId + } + + const findMessageByUuid = (uuid) => { + return messages.value.find(msg => msg.uuid === uuid) + } + + const updateMessageStatus = (uuid, status) => { + const message = findMessageByUuid(uuid) + if (message) { + message.status = status + } + } + + const removeMessage = (uuid) => { + const index = messages.value.findIndex(msg => msg.uuid === uuid) + if (index !== -1) { + messages.value.splice(index, 1) + } + } + + return { + // State + messages, + isTyping, + currentConversationId, + + // Getters + getMessages, + hasMessages, + messageCount, + + // Actions + addMessage, + replaceMessages, + clearMessages, + setTypingStatus, + setCurrentConversationId, + findMessageByUuid, + updateMessageStatus, + removeMessage + } +}) diff --git a/frontend/apps/widget/src/store/widget.js b/frontend/apps/widget/src/store/widget.js new file mode 100644 index 00000000..a0ea3ba2 --- /dev/null +++ b/frontend/apps/widget/src/store/widget.js @@ -0,0 +1,63 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export const useWidgetStore = defineStore('widget', () => { + // State + const isOpen = ref(false) + const currentView = ref('welcome') + const config = ref({}) + + + // Getters + const isWelcomeView = computed(() => currentView.value === 'welcome') + const isChatView = computed(() => currentView.value === 'chat') + + // Actions + const toggleWidget = () => { + isOpen.value = !isOpen.value + if (!isOpen.value) { + // Reset to welcome view when closing + currentView.value = 'welcome' + } + } + + const openWidget = () => { + isOpen.value = true + } + + const closeWidget = () => { + isOpen.value = false + currentView.value = 'welcome' + } + + const navigateToChat = () => { + currentView.value = 'chat' + } + + const navigateToWelcome = () => { + currentView.value = 'welcome' + } + + const updateConfig = (newConfig) => { + config.value = { ...newConfig } + } + + return { + // State + isOpen, + currentView, + config, + + // Getters + isWelcomeView, + isChatView, + + // Actions + toggleWidget, + openWidget, + closeWidget, + navigateToChat, + navigateToWelcome, + updateConfig, + } +}) diff --git a/frontend/apps/widget/src/views/ChatView.vue b/frontend/apps/widget/src/views/ChatView.vue new file mode 100644 index 00000000..652368be --- /dev/null +++ b/frontend/apps/widget/src/views/ChatView.vue @@ -0,0 +1,214 @@ + + + diff --git a/frontend/apps/widget/src/views/WelcomeView.vue b/frontend/apps/widget/src/views/WelcomeView.vue new file mode 100644 index 00000000..399484da --- /dev/null +++ b/frontend/apps/widget/src/views/WelcomeView.vue @@ -0,0 +1,87 @@ + + + diff --git a/frontend/components.json b/frontend/components.json index 38566e41..5115309b 100644 --- a/frontend/components.json +++ b/frontend/components.json @@ -4,12 +4,12 @@ "typescript": false, "tailwind": { "config": "tailwind.config.js", - "css": "src/assets/styles/main.scss", + "css": "shared-ui/assets/styles/main.scss", "baseColor": "gray", "cssVariables": true }, "aliases": { - "components": "@/components", - "utils": "@/lib/utils" + "components": "@shared-ui/components", + "utils": "@shared-ui/lib/utils" } } diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json index 5a1f2d22..31f739b7 100644 --- a/frontend/jsconfig.json +++ b/frontend/jsconfig.json @@ -1,7 +1,10 @@ { "compilerOptions": { + "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@main/*": ["apps/main/src/*"], + "@widget/*": ["apps/widget/src/*"], + "@shared-ui/*": ["shared-ui/*"] } }, "exclude": ["node_modules", "dist"] diff --git a/frontend/package.json b/frontend/package.json index 31b3ed9f..8f3de554 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,10 @@ "type": "module", "scripts": { "dev": "pnpm exec vite", - "build": "vite build", + "dev:main": "pnpm exec vite --mode main", + "dev:widget": "pnpm exec vite --mode widget", + "build:main": "vite build --mode main", + "build:widget": "vite build --mode widget", "preview": "vite preview", "test": "vitest", "test:run": "vitest run", diff --git a/frontend/shared-ui/assets/styles/main.scss b/frontend/shared-ui/assets/styles/main.scss new file mode 100644 index 00000000..e4d646f7 --- /dev/null +++ b/frontend/shared-ui/assets/styles/main.scss @@ -0,0 +1,235 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + font-size: 16px; +} + +@layer base { + html, + body { + font-family: 'Plus Jakarta Sans', sans-serif; + min-height: 100%; + overflow: hidden; + margin: 0; + @apply bg-background text-foreground; + } + + @media (max-width: 768px) { + html, + body { + overflow-x: auto; + } + } + + * { + @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%; + + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 5.9% 10%; + --radius: 0.5rem; + } + + .dark { + --background: 240 5.9% 10%; + --foreground: 0 0% 98%; + + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + } +} + +.message-bubble { + @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; + } +} + +.overlay { + background: rgba(0, 0, 0, 0.6); +} + +.text-sm-muted { + @apply text-muted-foreground text-sm; +} + +.box { + @apply border shadow rounded; +} + +// Scrollbar start +::-webkit-scrollbar { + width: 8px; /* Adjust width */ + height: 8px; /* Adjust height */ +} + +::-webkit-scrollbar-thumb { + background-color: #888; + border-radius: 3px; + border: 2px solid transparent; + background-clip: content-box; +} + +::-webkit-scrollbar-thumb:hover { + background-color: #555; /* Hover effect */ +} + +::-webkit-scrollbar-track { + background: #f0f0f0; + border-radius: 3px; +} +// End Scrollbar + +.code-editor { + @apply rounded border shadow h-[65vh] min-h-[250px] w-full relative; +} + +.show-quoted-text { + blockquote { + @apply block; + } +} + +.hide-quoted-text { + blockquote { + @apply hidden; + } +} + +[data-radix-popper-content-wrapper] { + z-index: 9999 !important; +} + +// Components +@layer components { + .link-style { + @apply text-blue-500 hover:underline; + } +} diff --git a/frontend/shared-ui/components/index.js b/frontend/shared-ui/components/index.js new file mode 100644 index 00000000..a05b21db --- /dev/null +++ b/frontend/shared-ui/components/index.js @@ -0,0 +1,14 @@ +// Button component exports +export { Button, buttonVariants } from './ui/button' + +// Card component exports +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './ui/card' + +// Input component exports +export { Input } from './ui/input' + +// Add other component exports as needed +// Example: +// export { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog' +// export { Badge, badgeVariants } from './ui/badge' +// export { Avatar, AvatarImage, AvatarFallback } from './ui/avatar' diff --git a/frontend/src/components/ui/accordion/Accordion.vue b/frontend/shared-ui/components/ui/accordion/Accordion.vue similarity index 100% rename from frontend/src/components/ui/accordion/Accordion.vue rename to frontend/shared-ui/components/ui/accordion/Accordion.vue diff --git a/frontend/src/components/ui/accordion/AccordionContent.vue b/frontend/shared-ui/components/ui/accordion/AccordionContent.vue similarity index 94% rename from frontend/src/components/ui/accordion/AccordionContent.vue rename to frontend/shared-ui/components/ui/accordion/AccordionContent.vue index 99f5b4a4..bfd11163 100644 --- a/frontend/src/components/ui/accordion/AccordionContent.vue +++ b/frontend/shared-ui/components/ui/accordion/AccordionContent.vue @@ -1,7 +1,7 @@ diff --git a/frontend/apps/widget/src/components/MessageAttachment.vue b/frontend/apps/widget/src/components/MessageAttachment.vue new file mode 100644 index 00000000..711036c7 --- /dev/null +++ b/frontend/apps/widget/src/components/MessageAttachment.vue @@ -0,0 +1,64 @@ + + + diff --git a/frontend/apps/widget/src/components/MessageInputActions.vue b/frontend/apps/widget/src/components/MessageInputActions.vue new file mode 100644 index 00000000..c646f4d8 --- /dev/null +++ b/frontend/apps/widget/src/components/MessageInputActions.vue @@ -0,0 +1,106 @@ + + + diff --git a/frontend/apps/widget/src/components/NoticeBanner.vue b/frontend/apps/widget/src/components/NoticeBanner.vue new file mode 100644 index 00000000..8c2e4734 --- /dev/null +++ b/frontend/apps/widget/src/components/NoticeBanner.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/apps/widget/src/components/WidgetError.vue b/frontend/apps/widget/src/components/WidgetError.vue new file mode 100644 index 00000000..d08085b8 --- /dev/null +++ b/frontend/apps/widget/src/components/WidgetError.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend/apps/widget/src/layouts/MainLayout.vue b/frontend/apps/widget/src/layouts/MainLayout.vue new file mode 100644 index 00000000..4ca23df3 --- /dev/null +++ b/frontend/apps/widget/src/layouts/MainLayout.vue @@ -0,0 +1,40 @@ + + + diff --git a/frontend/apps/widget/src/main.js b/frontend/apps/widget/src/main.js index 92f612f5..a951d834 100644 --- a/frontend/apps/widget/src/main.js +++ b/frontend/apps/widget/src/main.js @@ -6,7 +6,5 @@ import './assets/widget.css' const app = createApp(App) const pinia = createPinia() - app.use(pinia) - app.mount('#app') diff --git a/frontend/apps/widget/src/router/index.js b/frontend/apps/widget/src/router/index.js index 7584defa..d93e66fa 100644 --- a/frontend/apps/widget/src/router/index.js +++ b/frontend/apps/widget/src/router/index.js @@ -1,18 +1,12 @@ import { createRouter, createWebHistory } from 'vue-router' -import WelcomeView from '../views/WelcomeView.vue' -import ChatView from '../views/ChatView.vue' +import MainLayout from '@widget/layouts/MainLayout.vue' const routes = [ { path: '/', - name: 'welcome', - component: WelcomeView + component: MainLayout, }, - { - path: '/chat', - name: 'chat', - component: ChatView - } + ] const router = createRouter({ diff --git a/frontend/apps/widget/src/store/chat.js b/frontend/apps/widget/src/store/chat.js index cbb29160..12dc8448 100644 --- a/frontend/apps/widget/src/store/chat.js +++ b/frontend/apps/widget/src/store/chat.js @@ -1,81 +1,185 @@ import { defineStore } from 'pinia' -import { ref, computed } from 'vue' +import { ref, computed, reactive } from 'vue' +import { initWidgetWS } from '../websocket.js' +import api from '../api/index.js' +import MessageCache from '@main/utils/conversation-message-cache.js' +import { useUserStore } from './user.js' export const useChatStore = defineStore('chat', () => { + const userStore = useUserStore() // State - const messages = ref([]) const isTyping = ref(false) - const currentConversationId = ref(null) + const currentConversation = ref({}) + const conversations = ref(null) + // Conversation messages cache, evict old conversation messages after 50 conversations. + const messageCache = reactive(new MessageCache(50)) + const isLoadingConversations = ref(false) // Getters - const hasMessages = computed(() => messages.value.length > 0) - const messageCount = computed(() => messages.value.length) - const getMessages = () => [...messages.value].reverse() - + const getCurrentConversationMessages = () => { + const convId = currentConversation.value?.uuid + if (!convId) return [] + return messageCache.getAllPagesMessages(convId) + } + const hasConversations = computed(() => conversations.value?.length > 0) + const getConversations = computed(() => { + // Sort by `last_message_at` descending. + if (conversations.value) { + return conversations.value.sort((a, b) => new Date(b.last_message_at) - new Date(a.last_message_at)) + } + return [] + }) // Actions - const addMessage = (message) => { - messages.value.push(message) + const addMessageToConversation = (conversationUUID, message) => { + messageCache.addMessage(conversationUUID, message) + // Update `last_message` for the conversations list. + const conv = conversations.value.find(c => c.uuid === conversationUUID) + if (conv) { + conv.last_message = message.text_content + conv.last_message_at = message.created_at + } } - const replaceMessages = (newMessages) => { - // Clear existing messages and replace with new ones - messages.value = [] + const fetchCurrentConversation = async () => { + const conversationUUID = currentConversation.value?.uuid + if (!conversationUUID) return + + // Set unread message count to 0 for the current conversation + const conv = conversations.value.find(c => c.uuid === conversationUUID) + if (conv) { + conv.unread_message_count = 0 + } + + // If messages are already loaded, do nothing. + if (messageCache.hasConversation(conversationUUID)) { + return + } + + // Fetch entire conversation and replace messages and conversation data + try { + const resp = await api.getChatConversation(conversationUUID) + replaceMessages(resp.data.data.messages) + currentConversation.value = resp.data.data.conversation + } catch (error) { + console.error('Error fetching conversation:', error) + } + } - if (Array.isArray(newMessages)) { - messages.value = [...newMessages] + const fetchAndReplaceConversationAndMessages = async () => { + const conversationUUID = currentConversation.value?.uuid + if (!conversationUUID) return + // Fetch entire conversation and replace messages + try { + const resp = await api.getChatConversation(conversationUUID) + replaceMessages(resp.data.data.messages) + currentConversation.value = resp.data.data.conversation + // Set last message for the conversation + const conv = conversations.value.find(c => c.uuid === conversationUUID) + if (conv) { + conv.last_message = resp.data.data.messages[0]?.content || '' + conv.last_message_at = resp.data.data.messages[0]?.created_at || new Date().toISOString() + } + } catch (error) { + console.error('Error fetching conversation:', error) + } + } + + const replaceMessages = (newMessages) => { + const convId = currentConversation.value?.uuid + if (!convId) return + if (Array.isArray(newMessages) && newMessages.length > 0) { + // Purge and then add messages. + messageCache.purgeConversation(convId) + messageCache.addMessages(convId, newMessages, 1, 1) } } const clearMessages = () => { - messages.value = [] + const convId = currentConversation.value?.uuid + if (!convId) return + // Clear messages for current conversation by setting empty array. + messageCache.addMessages(convId, [], 1, 1) } const setTypingStatus = (status) => { isTyping.value = status } - const setCurrentConversationId = (conversationId) => { - currentConversationId.value = conversationId + const setCurrentConversation = (conversation) => { + if (conversation === null) { + conversation = {} + } + currentConversation.value = conversation } - const findMessageByUuid = (uuid) => { - return messages.value.find(msg => msg.uuid === uuid) - } + const openConversation = (conversation) => { + // Set the current conversation + setCurrentConversation(conversation) - const updateMessageStatus = (uuid, status) => { - const message = findMessageByUuid(uuid) - if (message) { - message.status = status + // Init WebSocket connection if not already initialized. + const jwt = userStore.userSessionToken + if (jwt) { + initWidgetWS(jwt) } } - const removeMessage = (uuid) => { - const index = messages.value.findIndex(msg => msg.uuid === uuid) - if (index !== -1) { - messages.value.splice(index, 1) + const fetchConversations = async () => { + if (!userStore.userSessionToken) { + conversations.value = [] + return } + + if (conversations.value !== null) { + return + } + + try { + isLoadingConversations.value = true + const response = await api.getChatConversations() + conversations.value = response.data.data || [] + } catch (error) { + // On 401, clear session from user store. + if (error.response && error.response.status === 401) { + userStore.clearSessionToken() + conversations.value = null + return + } + console.error('Error fetching conversations:', error) + } finally { + isLoadingConversations.value = false + } + } + + const updateCurrentConversationLastSeen = async () => { + const conversationUUID = currentConversation.value?.uuid + if (!conversationUUID) return + api.updateConversationLastSeen(conversationUUID) } return { // State - messages, + messageCache, isTyping, - currentConversationId, + conversations, + currentConversation, + isLoadingConversations, // Getters - getMessages, - hasMessages, - messageCount, + getCurrentConversationMessages, + hasConversations, + getConversations, // Actions - addMessage, + addMessageToConversation, + fetchCurrentConversation, replaceMessages, clearMessages, setTypingStatus, - setCurrentConversationId, - findMessageByUuid, - updateMessageStatus, - removeMessage + setCurrentConversation, + openConversation, + fetchConversations, + fetchAndReplaceConversationAndMessages, + updateCurrentConversationLastSeen } }) diff --git a/frontend/apps/widget/src/store/user.js b/frontend/apps/widget/src/store/user.js new file mode 100644 index 00000000..18268a26 --- /dev/null +++ b/frontend/apps/widget/src/store/user.js @@ -0,0 +1,26 @@ +import { defineStore } from 'pinia' +import { computed } from 'vue' +import { useStorage } from '@vueuse/core' +import { parseJWT } from '@shared-ui/utils/string' + +export const useUserStore = defineStore('user', () => { + const userSessionToken = useStorage('libredesk_session', "") + + const isVisitor = computed(() => { + const token = userSessionToken.value + // Token not set, assume visitor. + if (!token) return true + const jwt = parseJWT(token) + return jwt.is_visitor + }) + + const clearSessionToken = () => { + userSessionToken.value = "" + } + + return { + userSessionToken, + isVisitor, + clearSessionToken + } +}) \ No newline at end of file diff --git a/frontend/apps/widget/src/store/widget.js b/frontend/apps/widget/src/store/widget.js index a0ea3ba2..ec595e86 100644 --- a/frontend/apps/widget/src/store/widget.js +++ b/frontend/apps/widget/src/store/widget.js @@ -4,21 +4,20 @@ import { ref, computed } from 'vue' export const useWidgetStore = defineStore('widget', () => { // State const isOpen = ref(false) - const currentView = ref('welcome') + const currentView = ref('home') const config = ref({}) + const isInChatView = ref(false) // Getters - const isWelcomeView = computed(() => currentView.value === 'welcome') - const isChatView = computed(() => currentView.value === 'chat') + const isHomeView = computed(() => currentView.value === 'home') + const isChatView = computed(() => isInChatView.value) + const isMessagesView = computed(() => currentView.value === 'messages' && !isInChatView.value) // Actions const toggleWidget = () => { isOpen.value = !isOpen.value - if (!isOpen.value) { - // Reset to welcome view when closing - currentView.value = 'welcome' - } + isInChatView.value = false } const openWidget = () => { @@ -27,15 +26,23 @@ export const useWidgetStore = defineStore('widget', () => { const closeWidget = () => { isOpen.value = false - currentView.value = 'welcome' + currentView.value = 'home' + isInChatView.value = false } const navigateToChat = () => { - currentView.value = 'chat' + currentView.value = 'messages' + isInChatView.value = true } - const navigateToWelcome = () => { - currentView.value = 'welcome' + const navigateToMessages = () => { + currentView.value = 'messages' + isInChatView.value = false + } + + const navigateToHome = () => { + currentView.value = 'home' + isInChatView.value = false } const updateConfig = (newConfig) => { @@ -47,17 +54,20 @@ export const useWidgetStore = defineStore('widget', () => { isOpen, currentView, config, + isInChatView, // Getters - isWelcomeView, + isHomeView, isChatView, + isMessagesView, // Actions toggleWidget, openWidget, closeWidget, navigateToChat, - navigateToWelcome, + navigateToMessages, + navigateToHome, updateConfig, } }) diff --git a/frontend/apps/widget/src/views/ChatView.vue b/frontend/apps/widget/src/views/ChatView.vue index 652368be..e8152a92 100644 --- a/frontend/apps/widget/src/views/ChatView.vue +++ b/frontend/apps/widget/src/views/ChatView.vue @@ -1,26 +1,49 @@ diff --git a/frontend/apps/widget/src/views/WelcomeView.vue b/frontend/apps/widget/src/views/HomeView.vue similarity index 58% rename from frontend/apps/widget/src/views/WelcomeView.vue rename to frontend/apps/widget/src/views/HomeView.vue index 399484da..97a2449d 100644 --- a/frontend/apps/widget/src/views/WelcomeView.vue +++ b/frontend/apps/widget/src/views/HomeView.vue @@ -2,15 +2,12 @@
-
- Logo - -
+ Logo -
-

{{ config.greeting_message || 'Hi there' }}

-

+

+

{{ config.greeting_message || 'Hi there' }}

+

{{ config.introduction_message || 'How can we help?' }}

@@ -41,7 +38,7 @@ rel="noopener noreferrer" class="block no-underline" > - +
@@ -57,31 +54,44 @@ diff --git a/frontend/apps/widget/src/views/MessagesView.vue b/frontend/apps/widget/src/views/MessagesView.vue new file mode 100644 index 00000000..b5b9a364 --- /dev/null +++ b/frontend/apps/widget/src/views/MessagesView.vue @@ -0,0 +1,139 @@ + + + diff --git a/frontend/apps/widget/src/websocket.js b/frontend/apps/widget/src/websocket.js new file mode 100644 index 00000000..a774f0de --- /dev/null +++ b/frontend/apps/widget/src/websocket.js @@ -0,0 +1,261 @@ +import { useChatStore } from './store/chat' + +// Widget WebSocket message types (matching backend constants) +export const WS_EVENT = { + JOIN: 'join', + MESSAGE: 'message', + TYPING: 'typing', + ERROR: 'error', + NEW_MESSAGE: 'new_message', + STATUS: 'status', + JOINED: 'joined', + PONG: 'pong', +} + +export class WidgetWebSocketClient { + constructor() { + this.socket = null + this.reconnectInterval = 1000 + this.maxReconnectInterval = 30000 + this.reconnectAttempts = 0 + this.maxReconnectAttempts = 50 + this.isReconnecting = false + this.manualClose = false + this.pingInterval = null + this.lastPong = Date.now() + this.chatStore = useChatStore() + this.jwt = null + this.isJoined = false + } + + init (jwt) { + this.jwt = jwt + this.connect() + this.setupNetworkListeners() + } + + connect () { + if (this.isReconnecting || this.manualClose) return + + try { + this.socket = new WebSocket('/widget/ws') + this.socket.addEventListener('open', this.handleOpen.bind(this)) + this.socket.addEventListener('message', this.handleMessage.bind(this)) + this.socket.addEventListener('error', this.handleError.bind(this)) + this.socket.addEventListener('close', this.handleClose.bind(this)) + } catch (error) { + console.error('Widget WebSocket connection error:', error) + this.reconnect() + } + } + + handleOpen () { + console.log('Widget WebSocket connected') + this.reconnectInterval = 1000 + this.reconnectAttempts = 0 + this.isReconnecting = false + this.lastPong = Date.now() + this.setupPing() + + // Auto-join conversation after connection if a conversation uuid is set. + if (this.chatStore.currentConversation.uuid && this.jwt && !this.isJoined) { + this.joinConversation() + } + } + + handleMessage (event) { + try { + if (!event.data) return + const data = JSON.parse(event.data) + const handlers = { + [WS_EVENT.JOINED]: () => { + this.isJoined = true + }, + [WS_EVENT.PONG]: () => { + this.lastPong = Date.now() + }, + [WS_EVENT.NEW_MESSAGE]: () => { + // Add new message to chat store + if (data.data) { + this.chatStore.addMessageToConversation(data.data.conversation_uuid, data.data) + } + }, + [WS_EVENT.ERROR]: () => { + console.error('Widget WebSocket error:', data.data) + }, + [WS_EVENT.TYPING]: () => { + // TODO: check conversation uuid and then set typing as true. + // if (data.data && data.data.is_typing !== undefined) { + // this.chatStore.setTypingStatus(data.data.is_typing) + // } + } + } + const handler = handlers[data.type] + if (handler) { + handler() + } else { + console.warn(`Unknown widget websocket event: ${data.type}`) + } + } catch (error) { + console.error('Widget message handling error:', error) + } + } + + handleError (event) { + console.error('Widget WebSocket error:', event) + this.reconnect() + } + + handleClose () { + this.clearPing() + this.isJoined = false + if (!this.manualClose) { + this.reconnect() + } + } + + reconnect () { + if (this.isReconnecting || this.reconnectAttempts >= this.maxReconnectAttempts) return + + this.isReconnecting = true + this.reconnectAttempts++ + + setTimeout(() => { + this.isReconnecting = false + this.connect() + this.reconnectInterval = Math.min(this.reconnectInterval * 1.5, this.maxReconnectInterval) + }, this.reconnectInterval) + } + + setupNetworkListeners () { + window.addEventListener('online', () => { + if (this.socket?.readyState !== WebSocket.OPEN) { + this.reconnectInterval = 1000 + this.reconnect() + } + }) + + window.addEventListener('focus', () => { + if (this.socket?.readyState !== WebSocket.OPEN) { + this.reconnect() + } + }) + } + + setupPing () { + this.clearPing() + this.pingInterval = setInterval(() => { + if (this.socket?.readyState === WebSocket.OPEN) { + try { + this.socket.send(JSON.stringify({ + type: 'ping', + })) + if (Date.now() - this.lastPong > 60000) { + console.warn('No pong received in 60 seconds, closing widget connection') + this.socket.close() + } + } catch (e) { + console.error('Widget ping error:', e) + this.reconnect() + } + } + }, 5000) + } + + clearPing () { + if (this.pingInterval) { + clearInterval(this.pingInterval) + this.pingInterval = null + } + } + + joinConversation () { + const currentConversationUuid = this.chatStore.currentConversation.uuid + if (!currentConversationUuid || !this.jwt) { + console.error('Cannot join conversation: missing conversationUuid or JWT') + return + } + + const joinMessage = { + type: WS_EVENT.JOIN, + jwt: this.jwt, + data: { + conversation_uuid: currentConversationUuid + } + } + + this.send(joinMessage) + } + + sendTyping (isTyping = true) { + if (!this.isJoined) { + console.warn('Cannot send typing indicator: not joined to conversation') + return + } + + const currentConversationUUID = this.chatStore.currentConversation.uuid + const typingMessage = { + type: WS_EVENT.TYPING, + jwt: this.jwt, + data: { + conversation_uuid: currentConversationUUID, + is_typing: isTyping + } + } + + this.send(typingMessage) + } + + send (message) { + if (this.socket?.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify(message)) + } else { + console.warn('Widget WebSocket is not open. Message not sent:', message) + } + } + + // Method to join a new conversation without reinitializing the connection + joinNewConversation () { + if (this.socket?.readyState === WebSocket.OPEN) { + this.isJoined = false + this.joinConversation() + } else { + console.warn('WebSocket not connected, cannot join new conversation') + } + } + + close () { + this.manualClose = true + this.isJoined = false + this.clearPing() + if (this.socket) { + this.socket.close() + } + } +} + +let widgetWSClient + +export function initWidgetWS (jwt) { + if (!widgetWSClient) { + widgetWSClient = new WidgetWebSocketClient() + widgetWSClient.init(jwt) + } else { + // Update JWT and rejoin if connection exists + widgetWSClient.jwt = jwt + if (widgetWSClient.socket?.readyState === WebSocket.OPEN) { + // Reset joined status and join the new conversation + widgetWSClient.isJoined = false + widgetWSClient.joinConversation() + } else { + // If connection is not open, reconnect + widgetWSClient.init(jwt) + } + } + return widgetWSClient +} + +export const sendWidgetMessage = message => widgetWSClient?.send(message) +export const sendWidgetTyping = (isTyping = true) => widgetWSClient?.sendTyping(isTyping) +export const closeWidgetWebSocket = () => widgetWSClient?.close() +export const joinNewConversation = () => widgetWSClient?.joinNewConversation() diff --git a/frontend/shared-ui/utils/debounce.js b/frontend/shared-ui/utils/debounce.js new file mode 100644 index 00000000..7705b03c --- /dev/null +++ b/frontend/shared-ui/utils/debounce.js @@ -0,0 +1,7 @@ +export function debounce (fn, delay) { + let timeout + return function (...args) { + clearTimeout(timeout) + timeout = setTimeout(() => fn(...args), delay) + } +} diff --git a/frontend/shared-ui/utils/file.js b/frontend/shared-ui/utils/file.js new file mode 100644 index 00000000..a2a8b6ff --- /dev/null +++ b/frontend/shared-ui/utils/file.js @@ -0,0 +1,7 @@ +export function formatBytes (bytes) { + if (bytes < 1024 * 1024) { + return (bytes / 1024).toFixed(2) + ' KB' + } else { + return (bytes / (1024 * 1024)).toFixed(2) + ' MB' + } +} diff --git a/frontend/shared-ui/utils/string.js b/frontend/shared-ui/utils/string.js new file mode 100644 index 00000000..a396b319 --- /dev/null +++ b/frontend/shared-ui/utils/string.js @@ -0,0 +1,11 @@ +export function convertTextToHtml (text) { + const div = document.createElement('div') + div.innerText = text + return div.innerHTML.replace(/\n/g, '
') +} + +export function parseJWT (token) { + const base64Url = token.split('.')[1] + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') + return JSON.parse(atob(base64)) +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index b60d584f..5da95bbf 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -39,6 +39,10 @@ export default defineConfig(({ mode }) => { target: 'ws://127.0.0.1:9000', ws: true, }, + '/widget/ws': { + target: 'ws://127.0.0.1:9000', + ws: true, + } }, }, build: { diff --git a/i18n/en.json b/i18n/en.json index ce40e31b..d7acd44b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -33,6 +33,7 @@ "globals.terms.inactive": "Inactive | Inactives", "globals.terms.integration": "Integration | Integrations", "globals.terms.content": "Content | Contents", + "globals.terms.fileUpload": "File Upload | File Uploads", "globals.terms.appRootURL": "App Root URL", "globals.terms.dashboard": "Dashboard | Dashboards", "globals.terms.tag": "Tag | Tags", @@ -188,9 +189,11 @@ "globals.terms.recipient": "Recipient | Recipients", "globals.terms.tls": "TLS | TLSs", "globals.terms.credential": "Credential | Credentials", + "globals.terms.unAuthorized": "Unauthorized", "globals.messages.invalid": "Invalid {name}", "globals.messages.custom": "Custom {name}", "globals.messages.replying": "Replying", + "globals.messages.sessionExpired": "Session expired", "globals.messages.hoursSince": "Hours since", "globals.messages.hoursSinceCreated": "Hours since created", "globals.messages.hoursSinceFirstReply": "Hours since first reply", @@ -306,6 +309,7 @@ "globals.messages.close": "Close", "globals.messages.apply": "Apply {name}", "globals.messages.reset": "Reset {name}", + "globals.messages.disabled": "{name} is disabled", "globals.messages.lastNItems": "Last {n} {name} | Last {n} {name}", "globals.messages.correctEmailErrors": "Please correct the email errors", "form.error.min": "Must be at least {min} characters", @@ -314,15 +318,15 @@ "form.error.minmaxNumber": "Must be between {min} and {max}", "form.error.time.invalid": "Invalid time format (HH:mm)", "form.error.validUrl": "Invalid URL", - "user.resetPasswordTokenExpired": "Token is invalid or expired, please try again by requesting a new password reset link", + "user.resetPasswordTokenExpired": "Token is invalid or expired, Please try again by requesting a new password reset link", "user.userCannotDeleteSelf": "You cannot delete yourself", "user.userAlreadyLoggedIn": "User already logged in", "user.invalidEmailPassword": "Invalid email or password.", - "user.accountDisabled": "Your account is disabled, please contact administrator", + "user.accountDisabled": "Your account is disabled, Please contact administrator", "user.cannotDeleteSystemUser": "Cannot delete system user", "user.sameEmailAlreadyExists": "User with same email already exists", "user.errorGeneratingPasswordToken": "Error generating password token", - "media.fileSizeTooLarge": "File size too large, please upload a file less than {size} ", + "media.fileSizeTooLarge": "File size too large, Please upload a file less than {size} ", "media.fileTypeNotAllowed": "File type not allowed", "inbox.emptyIMAP": "Empty IMAP config", "inbox.emptySMTP": "Empty SMTP config", @@ -509,17 +513,20 @@ "admin.inbox.livechat.externalLinks.description": "Add helpful links that will be displayed in the chat widget", "admin.inbox.livechat.trustedDomains": "Trusted Domains", "admin.inbox.livechat.trustedDomains.list": "Domain List", - "admin.inbox.livechat.trustedDomains.description": "List of domains where the widget can be embedded (one per line)", + "admin.inbox.livechat.trustedDomains.description": "Specify your trusted domains and subdomains, one per line. Use an asterisk wildcard to trust all subdomains: *.example.com. Leaving this field empty will allowing widget to be embedded on any domain.", "admin.inbox.livechat.userSettings": "User Settings", "admin.inbox.livechat.userSettings.visitors": "Visitors", - "admin.inbox.livechat.userSettings.users": "Logged-in Users", + "admin.inbox.livechat.userSettings.users": "Users", "admin.inbox.livechat.startConversationButtonText": "Start Conversation Button Text", "admin.inbox.livechat.allowStartConversation": "Allow Start Conversation", "admin.inbox.livechat.allowStartConversation.visitors.description": "Allow visitors to start new conversations", - "admin.inbox.livechat.allowStartConversation.users.description": "Allow logged-in users to start new conversations", + "admin.inbox.livechat.allowStartConversation.users.description": "Allow users users to start new conversations", "admin.inbox.livechat.preventMultipleConversations": "Prevent Multiple Conversations", "admin.inbox.livechat.preventMultipleConversations.visitors.description": "Prevent visitors from starting multiple conversations simultaneously", - "admin.inbox.livechat.preventMultipleConversations.users.description": "Prevent logged-in users from starting multiple conversations simultaneously", + "admin.inbox.livechat.preventMultipleConversations.users.description": "Prevent users users from starting multiple conversations simultaneously", + "admin.inbox.livechat.preventReplyingToClosedConversations": "Prevent Replying to Closed Conversations", + "admin.inbox.livechat.preventReplyingToClosedConversations.visitors.description": "Prevent visitors from replying to closed conversations", + "admin.inbox.livechat.preventReplyingToClosedConversations.users.description": "Prevent users users from replying to closed conversations", "admin.agent.deleteConfirmation": "This will permanently delete the agent. Consider disabling the account instead.", "admin.agent.apiKey.description": "Generate API keys for this agent to access libredesk programmatically.", "admin.agent.apiKey.noKey": "No API key has been generated for this agent.", diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 9941837e..f3599057 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -103,6 +103,8 @@ type teamStore interface { type userStore interface { GetAgent(int, string) (umodels.User, error) + GetContact(int, string) (umodels.User, error) + GetVisitor(int) (umodels.User, error) GetSystemUser() (umodels.User, error) CreateContact(user *umodels.User) error } @@ -201,18 +203,21 @@ type queries struct { GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"` GetConversations string `query:"get-conversations"` GetContactConversations *sqlx.Stmt `query:"get-contact-conversations"` + GetContactChatConversations *sqlx.Stmt `query:"get-contact-chat-conversations"` GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"` GetUserActiveConversationsCount *sqlx.Stmt `query:"get-user-active-conversations-count"` UpdateConversationFirstReplyAt *sqlx.Stmt `query:"update-conversation-first-reply-at"` UpdateConversationLastReplyAt *sqlx.Stmt `query:"update-conversation-last-reply-at"` UpdateConversationWaitingSince *sqlx.Stmt `query:"update-conversation-waiting-since"` UpdateConversationAssigneeLastSeen *sqlx.Stmt `query:"update-conversation-assignee-last-seen"` + UpdateConversationContactLastSeen *sqlx.Stmt `query:"update-conversation-contact-last-seen"` UpdateConversationAssignedUser *sqlx.Stmt `query:"update-conversation-assigned-user"` UpdateConversationAssignedTeam *sqlx.Stmt `query:"update-conversation-assigned-team"` UpdateConversationCustomAttributes *sqlx.Stmt `query:"update-conversation-custom-attributes"` UpdateConversationPriority *sqlx.Stmt `query:"update-conversation-priority"` UpdateConversationStatus *sqlx.Stmt `query:"update-conversation-status"` UpdateConversationLastMessage *sqlx.Stmt `query:"update-conversation-last-message"` + UpdateConversationMeta *sqlx.Stmt `query:"update-conversation-meta"` InsertConversationParticipant *sqlx.Stmt `query:"insert-conversation-participant"` InsertConversation *sqlx.Stmt `query:"insert-conversation"` AddConversationTags *sqlx.Stmt `query:"add-conversation-tags"` @@ -262,19 +267,19 @@ func (c *Manager) GetConversation(id int, uuid string) (models.Conversation, err if err := c.q.GetConversation.Get(&conversation, id, uuidParam); err != nil { if err == sql.ErrNoRows { - return conversation, envelope.NewError(envelope.InputError, - c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.conversation}"), nil) + return conversation, envelope.NewError(envelope.InputError, c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.conversation}"), nil) } c.lo.Error("error fetching conversation", "error", err) - return conversation, envelope.NewError(envelope.GeneralError, - c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil) + return conversation, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil) } // Strip name and extract plain email from "Name " - var err error - conversation.InboxMail, err = stringutil.ExtractEmail(conversation.InboxMail) - if err != nil { - c.lo.Error("error extracting email from inbox mail", "inbox_mail", conversation.InboxMail, "error", err) + if conversation.InboxMail != "" { + var err error + conversation.InboxMail, err = stringutil.ExtractEmail(conversation.InboxMail) + if err != nil { + c.lo.Error("error extracting email from inbox mail", "inbox_mail", conversation.InboxMail, "error", err) + } } return conversation, nil @@ -290,6 +295,16 @@ func (c *Manager) GetContactConversations(contactID int) ([]models.Conversation, return conversations, nil } +// GetContactChatConversations retrieves conversations for a chat. +func (c *Manager) GetContactChatConversations(contactID int) ([]models.ChatConversation, error) { + var conversations = make([]models.ChatConversation, 0) + if err := c.q.GetContactChatConversations.Select(&conversations, contactID); err != nil { + c.lo.Error("error fetching conversations", "error", err) + return conversations, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil) + } + return conversations, nil +} + // GetConversationsCreatedAfter retrieves conversations created after the specified time. func (c *Manager) GetConversationsCreatedAfter(time time.Time) ([]models.Conversation, error) { var conversations = make([]models.Conversation, 0) @@ -312,6 +327,18 @@ func (c *Manager) UpdateConversationAssigneeLastSeen(uuid string) error { return nil } +// UpdateContactLastSeen updates the last seen timestamp of the contact in the conversation. +func (c *Manager) UpdateConversationContactLastSeen(uuid string) error { + if _, err := c.q.UpdateConversationContactLastSeen.Exec(uuid); err != nil { + c.lo.Error("error updating contact last seen timestamp", "conversation_id", uuid, "error", err) + return envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.conversation}"), nil) + } + + // Broadcast the property update to all subscribers. + c.BroadcastConversationUpdate(uuid, "contact_last_seen_at", time.Now().Format(time.RFC3339)) + return nil +} + // GetConversationParticipants retrieves the participants of a conversation. func (c *Manager) GetConversationParticipants(uuid string) ([]models.ConversationParticipant, error) { conv := make([]models.ConversationParticipant, 0) @@ -431,14 +458,23 @@ func (c *Manager) ActiveUserConversationsCount(userID int) (int, error) { } // UpdateConversationLastMessage updates the last message details for a conversation. -func (c *Manager) UpdateConversationLastMessage(conversation int, conversationUUID, lastMessage, lastMessageSenderType string, lastMessageAt time.Time) error { - if _, err := c.q.UpdateConversationLastMessage.Exec(conversation, conversationUUID, lastMessage, lastMessageSenderType, lastMessageAt); err != nil { +func (c *Manager) UpdateConversationLastMessage(conversation int, conversationUUID, lastMessage, lastMessageSenderType string, lastMessageAt time.Time, lastInteractionAt null.Time, conversationMeta []byte) error { + if _, err := c.q.UpdateConversationLastMessage.Exec(conversation, conversationUUID, lastMessage, lastMessageSenderType, lastMessageAt, lastInteractionAt, conversationMeta); err != nil { c.lo.Error("error updating conversation last message", "error", err) return err } return nil } +// UpdateConversationMeta updates meta data for a conversation. +func (c *Manager) UpdateConversationMeta(uuid string, meta map[string]any) error { + if _, err := c.q.UpdateConversationMeta.Exec(uuid, meta); err != nil { + c.lo.Error("error updating conversation meta", "error", err) + return err + } + return nil +} + // UpdateConversationFirstReplyAt updates the first reply timestamp for a conversation. func (c *Manager) UpdateConversationFirstReplyAt(conversationUUID string, conversationID int, at time.Time) error { res, err := c.q.UpdateConversationFirstReplyAt.Exec(conversationID, at) @@ -931,6 +967,7 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio []mmodels.Media{}, conv.InboxID, user.ID, + conv.ContactID, conv.UUID, action.Value[0], to, @@ -994,7 +1031,7 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio return fmt.Errorf("making recipients for CSAT reply: %w", err) } - return m.SendReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.UUID, message, to, cc, bcc, meta) + return m.SendReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.ContactID, conversation.UUID, message, to, cc, bcc, meta) } // DeleteConversation deletes a conversation. diff --git a/internal/conversation/message.go b/internal/conversation/message.go index e727a60c..51329407 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -18,7 +18,6 @@ import ( "github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/image" "github.com/abhinavxd/libredesk/internal/inbox" - "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" mmodels "github.com/abhinavxd/libredesk/internal/media/models" "github.com/abhinavxd/libredesk/internal/sla" "github.com/abhinavxd/libredesk/internal/stringutil" @@ -140,13 +139,13 @@ func (m *Manager) sendOutgoingMessage(message models.Message) { } // Get inbox - inbox, err := m.inboxStore.Get(message.InboxID) + inb, err := m.inboxStore.Get(message.InboxID) if handleError(err, "error fetching inbox") { return } // Render content in template - if err := m.RenderMessageInTemplate(inbox.Channel(), &message); err != nil { + if err := m.RenderMessageInTemplate(inb.Channel(), &message); err != nil { handleError(err, "error rendering content in template") return } @@ -157,24 +156,26 @@ func (m *Manager) sendOutgoingMessage(message models.Message) { return } - // Set from address of the inbox - message.From = inbox.FromAddress() + if inb.Channel() == inbox.ChannelEmail { + // Set from address of the inbox + message.From = inb.FromAddress() - // Set "In-Reply-To" and "References" headers, logging any errors but continuing to send the message. - // Include only the last 20 messages as references to avoid exceeding header size limits. - message.References, err = m.GetMessageSourceIDs(message.ConversationID, 20) - if err != nil { - m.lo.Error("Error fetching conversation source IDs", "error", err) - } + // Set "In-Reply-To" and "References" headers, logging any errors but continuing to send the message. + // Include only the last 20 messages as references to avoid exceeding header size limits. + message.References, err = m.GetMessageSourceIDs(message.ConversationID, 20) + if err != nil { + m.lo.Error("Error fetching conversation source IDs", "error", err) + } - // References is sorted in DESC i.e newest message first, so reverse it to keep the references in order. - stringutil.ReverseSlice(message.References) + // References is sorted in DESC i.e newest message first, so reverse it to keep the references in order. + stringutil.ReverseSlice(message.References) - // Remove the current message ID from the references. - message.References = stringutil.RemoveItemByValue(message.References, message.SourceID.String) + // Remove the current message ID from the references. + message.References = stringutil.RemoveItemByValue(message.References, message.SourceID.String) - if len(message.References) > 0 { - message.InReplyTo = message.References[len(message.References)-1] + if len(message.References) > 0 { + message.InReplyTo = message.References[len(message.References)-1] + } } // Send message @@ -279,6 +280,9 @@ func (m *Manager) RenderMessageInTemplate(channel string, message *models.Messag m.lo.Error("could not render email content using template", "id", message.ID, "error", err) return fmt.Errorf("could not render email content using template: %w", err) } + case inbox.ChannelLiveChat: + // Live chat doesn't use templates for rendering messages. + return nil default: m.lo.Warn("unknown message channel", "channel", channel) return fmt.Errorf("unknown message channel: %s", channel) @@ -362,9 +366,8 @@ func (m *Manager) MarkMessageAsPending(uuid string) error { return nil } -// SendPrivateNote inserts a private message in a conversation. +// SendPrivateNote inserts a private message for a conversation. func (m *Manager) SendPrivateNote(media []mmodels.Media, senderID int, conversationUUID, content string) error { - // Insert Message. message := models.Message{ ConversationUUID: conversationUUID, SenderID: senderID, @@ -379,80 +382,74 @@ func (m *Manager) SendPrivateNote(media []mmodels.Media, senderID int, conversat return m.InsertMessage(&message) } -// SendReply inserts a reply message in a conversation. -func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID int, conversationUUID, content string, to, cc, bcc []string, meta map[string]interface{}) error { - // Generage unique source ID i.e. message-id for email. - inbox, err := m.inboxStore.GetDBRecord(inboxID) +// SendReply inserts a reply message for a conversation. +func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID, contactID int, conversationUUID, content string, to, cc, bcc []string, metaMap map[string]any) error { + inboxRecord, err := m.inboxStore.GetDBRecord(inboxID) if err != nil { return err } + if !inboxRecord.Enabled { + return envelope.NewError(envelope.InputError, m.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil) + } + // Save to, cc and bcc in meta. to = stringutil.RemoveEmpty(to) cc = stringutil.RemoveEmpty(cc) bcc = stringutil.RemoveEmpty(bcc) - meta["to"] = to + metaMap["to"] = to if len(cc) > 0 { - meta["cc"] = cc + metaMap["cc"] = cc } if len(bcc) > 0 { - meta["bcc"] = bcc - } - - metaJSON, err := json.Marshal(meta) - if err != nil { - return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorMarshalling", "name", "{globals.terms.meta}"), nil) + metaMap["bcc"] = bcc } var sourceID = "" - var msgStatus = "" - if inbox.Channel == "email" { - msgStatus = models.MessageStatusPending + if inboxRecord.Channel == "email" { if len(to) == 0 { return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.empty", "name", "`to`"), nil) } - sourceID, err = stringutil.GenerateEmailMessageID(conversationUUID, inbox.From) + sourceID, err = stringutil.GenerateEmailMessageID(conversationUUID, inboxRecord.From) if err != nil { m.lo.Error("error generating source message id", "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.T("conversation.errorGeneratingMessageID"), nil) } } else { - msgStatus = models.MessageStatusSent sourceID, err = stringutil.RandomAlphanumeric(35) if err != nil { m.lo.Error("error generating random source id", "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.T("conversation.errorGeneratingMessageID"), nil) } + sourceID = "livechat-" + sourceID } - // Insert Message. - message := models.Message{ - ConversationUUID: conversationUUID, - SenderID: senderID, - Type: models.MessageOutgoing, - SenderType: models.SenderTypeAgent, - Status: msgStatus, - Content: content, - ContentType: models.ContentTypeHTML, - Private: false, - Media: media, - Meta: metaJSON, - SourceID: null.StringFrom(sourceID), + // Marshal meta. + metaJSON, err := json.Marshal(metaMap) + if err != nil { + m.lo.Error("error marshalling message meta map to JSON", "error", err) + return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorInserting", "name", "{globals.terms.message}"), nil) } // Insert the message into the database + message := models.Message{ + ConversationUUID: conversationUUID, + SenderID: senderID, + Type: models.MessageOutgoing, + SenderType: models.SenderTypeAgent, + Status: models.MessageStatusPending, + Content: content, + ContentType: models.ContentTypeHTML, + Private: false, + Media: media, + SourceID: null.StringFrom(sourceID), + MessageReceiverID: contactID, + Meta: metaJSON, + } if err := m.InsertMessage(&message); err != nil { return err } - // For live chat, broadcast the message to connected clients - if inbox.Channel == "livechat" { - if err := m.broadcastLiveChatMessage(&message, inboxID); err != nil { - m.lo.Error("error broadcasting live chat message", "conversation_uuid", conversationUUID, "message_uuid", message.UUID, "error", err) - // Don't return error as the message was successfully inserted - } - } - return nil } @@ -467,7 +464,7 @@ func (m *Manager) InsertMessage(message *models.Message) error { message.Meta = json.RawMessage(`{}`) } - // Convert HTML content to text for search. + // Save message as text. message.TextContent = stringutil.HTML2Text(message.Content) // Insert Message. @@ -477,12 +474,12 @@ func (m *Manager) InsertMessage(message *models.Message) error { return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorInserting", "name", "{globals.terms.message}"), nil) } - // Attach message to the media. + // Attach message to the media in DB. for _, media := range message.Media { m.mediaStore.Attach(media.ID, mmodels.ModelMessages, message.ID) } - // Add this user as a participant. + // Add this user as a participant if not already present. m.addConversationParticipant(message.SenderID, message.ConversationUUID) // Hide CSAT message content as it contains a public link to the survey. @@ -491,8 +488,62 @@ func (m *Manager) InsertMessage(message *models.Message) error { lastMessage = "Please rate your experience with us" } - // Update conversation last message details in conversation. - m.UpdateConversationLastMessage(message.ConversationID, message.ConversationUUID, lastMessage, message.SenderType, message.CreatedAt) + // Get sender user and store last message in conversation. + var ( + sender umodels.User + conversationMeta = map[string]any{} + lastInteractionAt = null.Time{} + err error + ) + switch message.SenderType { + case models.SenderTypeAgent: + sender, err = m.userStore.GetAgent(message.SenderID, "") + if err != nil { + m.lo.Error("error fetching message sender user", "sender_id", message.SenderID, "error", err) + } + case models.SenderTypeContact: + sender, err = m.userStore.GetContact(message.SenderID, "") + if err != nil { + m.lo.Error("error fetching message contact user", "contact_id", message.SenderID, "error", err) + sender, err = m.userStore.GetVisitor(message.SenderID) + if err != nil { + m.lo.Error("error fetching message visitor user", "visitor_id", message.SenderID, "error", err) + } + } + } + fmt.Println("NAME", sender.FullName()) + if slices.Contains([]string{models.MessageIncoming, models.MessageOutgoing}, message.Type) && !message.Private { + conversationMeta["last_chat_message"] = map[string]any{ + "uuid": message.UUID, + "created_at": message.CreatedAt, + "text_content": message.TextContent, + "sender": map[string]any{ + "id": sender.ID, + "first_name": sender.FirstName, + "last_name": sender.LastName, + }, + "sender_type": message.SenderType, + } + lastInteractionAt = null.TimeFrom(message.CreatedAt) + } + conversationMeta["last_message"] = map[string]any{ + "uuid": message.UUID, + "created_at": message.CreatedAt, + "text_content": message.TextContent, + "sender": map[string]any{ + "id": sender.ID, + "first_name": sender.FirstName, + "last_name": sender.LastName, + }, + "sender_type": message.SenderType, + } + conversationMetaB, err := json.Marshal(conversationMeta) + if err != nil { + m.lo.Error("error marshalling conversation meta to JSON", "error", err) + conversationMetaB = []byte("{}") + } + + m.UpdateConversationLastMessage(message.ConversationID, message.ConversationUUID, lastMessage, message.SenderType, message.CreatedAt, lastInteractionAt, conversationMetaB) // Broadcast new message. m.BroadcastNewMessage(message) @@ -504,7 +555,6 @@ func (m *Manager) InsertMessage(message *models.Message) error { } else { m.webhookStore.TriggerEvent(wmodels.EventMessageCreated, updatedMessage) } - return nil } @@ -980,67 +1030,3 @@ func (m *Manager) getLatestMessage(conversationID int, typ []string, status []st } return message, nil } - -// broadcastLiveChatMessage broadcasts a message to live chat clients connected to the conversation -func (m *Manager) broadcastLiveChatMessage(message *models.Message, inboxID int) error { - // Get the inbox instance - inboxInstance, err := m.inboxStore.Get(inboxID) - if err != nil { - return fmt.Errorf("error getting inbox instance: %w", err) - } - - // Type assert to live chat inbox - liveChatInbox, ok := inboxInstance.(*livechat.LiveChat) - if !ok { - return fmt.Errorf("inbox is not a live chat inbox") - } - - // Get sender information - sender, err := m.userStore.GetAgent(message.SenderID, "") - if err != nil { - m.lo.Error("error getting message sender", "sender_id", message.SenderID, "error", err) - // Use default values if we can't get sender info - sender = umodels.User{ - FirstName: "Agent", - LastName: "", - } - } - - // Create the message data for WebSocket broadcast - // Use text content for live chat (HTML stripped), or convert HTML to text if not available - var textContent string - if message.TextContent != "" { - textContent = message.TextContent - } else { - textContent = stringutil.HTML2Text(message.Content) - } - - messageData := map[string]interface{}{ - "type": "new_message", - "data": map[string]interface{}{ - "created_at": message.CreatedAt.Format(time.RFC3339), - "conversation_uuid": message.ConversationUUID, - "message_id": message.UUID, - "content": textContent, - "sender_type": "agent", - "sender_name": sender.FullName(), - "status": message.Status, - }, - } - - // Convert to JSON - messageJSON, err := json.Marshal(messageData) - if err != nil { - return fmt.Errorf("error marshaling message data: %w", err) - } - - // Broadcast to all clients in this conversation - liveChatInbox.BroadcastToConversation(message.ConversationUUID, messageJSON) - - m.lo.Info("broadcasted live chat message", - "conversation_uuid", message.ConversationUUID, - "message_uuid", message.UUID, - "sender", sender.FullName()) - - return nil -} diff --git a/internal/conversation/models/models.go b/internal/conversation/models/models.go index 71245023..9f5e468e 100644 --- a/internal/conversation/models/models.go +++ b/internal/conversation/models/models.go @@ -52,6 +52,28 @@ var ( ContentTypeHTML = "html" ) +type ChatConversation struct { + UUID string `db:"uuid" json:"uuid"` + Status string `db:"status" json:"status"` + LastMessage string `db:"last_message" json:"last_message"` + LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"` + LastMessageSenderFirstName string `db:"last_message_sender_first_name" json:"last_message_sender_first_name"` + LastMessageSenderLastName string `db:"last_message_sender_last_name" json:"last_message_sender_last_name"` + LastMessageSenderAvatarURL string `db:"last_message_sender_avatar_url" json:"last_message_sender_avatar_url"` + UnreadMessageCount int `db:"unread_message_count" json:"unread_message_count"` + Assignee umodels.User `db:"assignee" json:"assignee"` +} + +type ChatMessage struct { + CreatedAt time.Time `json:"created_at"` + UUID string `json:"uuid"` + Content string `json:"content"` + SenderType string `json:"sender_type"` + SenderName string `json:"sender_name"` + ConversationID string `json:"conversation_id"` + Attachments attachment.Attachments `json:"attachments"` +} + type Conversation struct { ID int `db:"id" json:"id,omitempty"` CreatedAt time.Time `db:"created_at" json:"created_at"` @@ -78,7 +100,7 @@ type Conversation struct { InboxName string `db:"inbox_name" json:"inbox_name"` InboxChannel string `db:"inbox_channel" json:"inbox_channel"` Tags null.JSON `db:"tags" json:"tags"` - Meta pq.StringArray `db:"meta" json:"meta"` + Meta json.RawMessage `db:"meta" json:"meta"` CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"` LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"` LastMessage null.String `db:"last_message" json:"last_message"` @@ -147,7 +169,9 @@ type Message struct { AltContent string `db:"-" json:"-"` Media []mmodels.Media `db:"-" json:"-"` IsCSAT bool `db:"-" json:"-"` - Total int `db:"total" json:"-"` + // TODO: Figure if this field is needed or there's a better way. + MessageReceiverID int `db:"-" json:"-"` + Total int `db:"total" json:"-"` } // CensorCSATContent redacts the content of a CSAT message to prevent leaking the CSAT survey public link. diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql index eb393090..d5c0a14b 100644 --- a/internal/conversation/queries.sql +++ b/internal/conversation/queries.sql @@ -48,19 +48,28 @@ SELECT conversations.last_reply_at, conversations.resolved_at, conversations.subject, - conversations.last_message, - conversations.last_message_at, - conversations.last_message_sender, + COALESCE( + conversations.meta->'last_message'->>'text_content', + conversations.last_message + ) as last_message, + COALESCE( + (conversations.meta->'last_message'->>'created_at')::timestamptz, + conversations.last_message_at + ) as last_message_at, + COALESCE( + conversations.meta->'last_message'->>'sender_type', + conversations.last_message_sender::TEXT + ) as last_message_sender, conversations.next_sla_deadline_at, conversations.priority_id, ( - SELECT CASE WHEN COUNT(*) > 9 THEN 10 ELSE COUNT(*) END - FROM ( - SELECT 1 FROM conversation_messages - WHERE conversation_id = conversations.id - AND created_at > conversations.assignee_last_seen_at - LIMIT 10 - ) t + SELECT CASE WHEN COUNT(*) > 9 THEN 10 ELSE COUNT(*) END + FROM ( + SELECT 1 FROM conversation_messages + WHERE conversation_id = conversations.id + AND created_at > conversations.assignee_last_seen_at + LIMIT 10 + ) t ) as unread_message_count, conversation_statuses.name as status, conversation_priorities.name as priority, @@ -116,9 +125,18 @@ SELECT c.sla_policy_id, c.meta, sla.name as sla_policy_name, - c.last_message_at, - c.last_message_sender, - c.last_message, + COALESCE( + (c.meta->'last_message'->>'created_at')::TIMESTAMPTZ, + c.last_message_at + ) as last_message_at, + COALESCE( + c.meta->'last_message'->>'sender_type', + c.last_message_sender::TEXT + ) as last_message_sender, + COALESCE( + c.meta->'last_message'->>'text_content', + c.last_message + ) as last_message, c.custom_attributes, (SELECT COALESCE( (SELECT json_agg(t.name) @@ -188,14 +206,43 @@ SELECT u.first_name AS "contact.first_name", u.last_name AS "contact.last_name", u.avatar_url AS "contact.avatar_url", - c.last_message, - c.last_message_at + COALESCE( + c.meta->'last_message'->>'text_content', + c.last_message + ) as last_message, + COALESCE( + (c.meta->'last_message'->>'created_at')::timestamptz, + c.last_message_at + ) as last_message_at FROM users u JOIN conversations c ON c.contact_id = u.id WHERE c.contact_id = $1 ORDER BY c.created_at DESC LIMIT 10; +-- name: get-contact-chat-conversations +SELECT + c.uuid, + COALESCE(c.meta->'last_chat_message'->>'text_content', '') as last_message, + COALESCE((c.meta->'last_chat_message'->>'created_at')::timestamptz, NULL) as last_message_at, + COALESCE(c.meta->'last_chat_message'->'sender'->>'first_name', '') AS last_message_sender_first_name, + COALESCE(c.meta->'last_chat_message'->'sender'->>'last_name', '') AS last_message_sender_last_name, + COALESCE(c.meta->'last_chat_message'->'sender'->>'avatar_url', '') AS last_message_sender_avatar_url, + LEAST(10, COUNT(unread.id)) AS unread_message_count +FROM conversations c +LEFT JOIN conversation_messages unread ON unread.conversation_id = c.id + AND unread.created_at > c.contact_last_seen_at + AND unread.type IN ('incoming', 'outgoing') AND unread.private = false +WHERE c.contact_id = $1 +GROUP BY c.id, c.uuid, + c.meta->'last_chat_message'->>'text_content', + (c.meta->'last_chat_message'->>'created_at')::timestamptz, + c.meta->'last_chat_message'->'sender'->>'first_name', + c.meta->'last_chat_message'->'sender'->>'last_name', + c.meta->'last_chat_message'->'sender'->>'avatar_url' +ORDER BY c.created_at DESC +LIMIT 100; + -- name: get-conversation-uuid SELECT uuid from conversations where id = $1; @@ -207,12 +254,24 @@ assignee_last_seen_at = NULL, updated_at = NOW() WHERE uuid = $1; +-- name: update-conversation-contact-last-seen +UPDATE conversations +SET contact_last_seen_at = NOW(), +updated_at = NOW() +WHERE uuid = $1; + -- name: update-conversation-assigned-team UPDATE conversations SET assigned_team_id = $2, updated_at = NOW() WHERE uuid = $1; +-- name: update-conversation-meta +UPDATE conversations +SET meta = COALESCE(meta, '{}'::jsonb) || $2, + updated_at = NOW() +WHERE uuid = $1; + -- name: update-conversation-status UPDATE conversations SET status_id = (SELECT id FROM conversation_statuses WHERE name = $2), @@ -238,7 +297,14 @@ SET assignee_last_seen_at = NOW(), WHERE uuid = $1; -- name: update-conversation-last-message -UPDATE conversations SET last_message = $3, last_message_sender = $4, last_message_at = $5, updated_at = NOW() WHERE CASE +UPDATE conversations SET +last_message = $3, +last_message_sender = $4, +last_message_at = $5, +last_interaction_at = COALESCE($6, last_interaction_at), +meta = COALESCE(meta, '{}'::jsonb) || $7, +updated_at = NOW() +WHERE CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END diff --git a/internal/inbox/channel/livechat/livechat.go b/internal/inbox/channel/livechat/livechat.go index dc53c9b8..b829aad5 100644 --- a/internal/inbox/channel/livechat/livechat.go +++ b/internal/inbox/channel/livechat/livechat.go @@ -3,6 +3,9 @@ package livechat import ( "context" + "encoding/json" + "fmt" + "strconv" "sync" "time" @@ -11,25 +14,30 @@ import ( "github.com/zerodha/logf" ) +var ( + ErrClientNotConnected = fmt.Errorf("client not connected") +) + const ( - ChannelLiveChat = "livechat" + ChannelLiveChat = "livechat" + MaxConnectionsPerUser = 10 ) // Config holds the live chat inbox configuration. type Config struct { - Users struct { + BrandName string `json:"brand_name"` + Language string `json:"language"` + Users struct { AllowStartConversation bool `json:"allow_start_conversation"` PreventMultipleConversations bool `json:"prevent_multiple_conversations"` StartConversationButtonText string `json:"start_conversation_button_text"` } `json:"users"` Colors struct { - Primary string `json:"primary"` - Background string `json:"background"` + Primary string `json:"primary"` } `json:"colors"` Features struct { - Emoji bool `json:"emoji"` - FileUpload bool `json:"file_upload"` - AllowCloseConversation bool `json:"allow_close_conversation"` + Emoji bool `json:"emoji"` + FileUpload bool `json:"file_upload"` } `json:"features"` Launcher struct { Spacing struct { @@ -45,7 +53,6 @@ type Config struct { PreventMultipleConversations bool `json:"prevent_multiple_conversations"` StartConversationButtonText string `json:"start_conversation_button_text"` } `json:"visitors"` - SecretKey string `json:"secret_key"` NoticeBanner struct { Text string `json:"text"` Enabled bool `json:"enabled"` @@ -64,11 +71,9 @@ type Config struct { // Client represents a connected chat client type Client struct { - ID string - ConversationID string - Channel chan []byte - LastActivity time.Time - mutex sync.RWMutex + ID string + Channel chan []byte + mutex sync.RWMutex } // LiveChat represents the live chat inbox. @@ -79,11 +84,9 @@ type LiveChat struct { lo *logf.Logger messageStore inbox.MessageStore userStore inbox.UserStore - clients map[string]*Client - // conversationClients maps conversation IDs to client IDs. - conversationClients map[string]map[string]*Client - clientsMutex sync.RWMutex - wg sync.WaitGroup + clients map[string][]*Client // Maps user IDs to slices of clients (to handle multiple devices) + clientsMutex sync.RWMutex + wg sync.WaitGroup } // Opts holds the options required for the live chat inbox. @@ -97,14 +100,13 @@ type Opts struct { // New returns a new instance of the live chat inbox. func New(store inbox.MessageStore, userStore inbox.UserStore, opts Opts) (*LiveChat, error) { lc := &LiveChat{ - id: opts.ID, - config: opts.Config, - from: opts.From, - lo: opts.Lo, - messageStore: store, - userStore: userStore, - clients: make(map[string]*Client), - conversationClients: make(map[string]map[string]*Client), + id: opts.ID, + config: opts.Config, + from: opts.From, + lo: opts.Lo, + messageStore: store, + userStore: userStore, + clients: make(map[string][]*Client), } return lc, nil } @@ -114,53 +116,68 @@ func (lc *LiveChat) Identifier() int { return lc.id } -// Receive handles incoming messages for the live chat channel. -// For live chat, this is a no-op as messages come through WebSocket connections. +// Receive is no-op as messages received via api. func (lc *LiveChat) Receive(ctx context.Context) error { - lc.lo.Info("live chat receiver started", "inbox_id", lc.id) - - // Start a cleanup routine for inactive clients - lc.wg.Add(1) - go func() { - defer lc.wg.Done() - ticker := time.NewTicker(5 * time.Minute) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - lc.cleanupInactiveClients() - } - } - }() - - <-ctx.Done() - lc.wg.Wait() return nil } -// Send sends a message through the live chat channel. +// Send sends the passed message to the message receiver if they are connected to the live chat. func (lc *LiveChat) Send(message models.Message) error { - lc.lo.Info("sending live chat message", - "conversation_id", message.ConversationUUID, - "message_id", message.UUID) + if message.MessageReceiverID > 0 { + msgReceiverStr := strconv.Itoa(message.MessageReceiverID) + lc.clientsMutex.RLock() + clients, exists := lc.clients[msgReceiverStr] + lc.clientsMutex.RUnlock() + + if exists { + sender, err := lc.userStore.GetAgent(message.SenderID, "") + if err != nil { + lc.lo.Error("failed to get sender name", "sender_id", message.SenderID, "error", err) + return fmt.Errorf("failed to get sender name: %w", err) + } + + for _, client := range clients { + messageData := map[string]any{ + "type": "new_message", + "data": map[string]any{ + "created_at": message.CreatedAt.Format(time.RFC3339), + "conversation_uuid": message.ConversationUUID, + "uuid": message.UUID, + "content": message.Content, + "text_content": message.TextContent, + "sender_type": message.SenderType, + "sender_name": sender.FullName(), + "status": message.Status, + }, + } + + // Convert messageData to JSON + messageJSON, err := json.Marshal(messageData) + if err != nil { + lc.lo.Error("failed to marshal message data", "error", err) + continue + } + + // Send the message to the client's channel + select { + case client.Channel <- messageJSON: + lc.lo.Info("message sent to live chat client", "client_id", client.ID, "message_id", message.UUID) + default: + lc.lo.Warn("client channel full, dropping message", "client_id", client.ID, "message_id", message.UUID) + } + continue + } + } else { + lc.lo.Debug("client not connected for live chat message", "receiver_id", msgReceiverStr, "message_id", message.UUID) + return ErrClientNotConnected + } + } + lc.lo.Warn("received empty receiver_id for live chat message", "message_id", message.UUID, "receiver_id", message.MessageReceiverID) return nil } // Close closes the live chat channel. func (lc *LiveChat) Close() error { - lc.clientsMutex.Lock() - defer lc.clientsMutex.Unlock() - - // Close all client channels - for _, client := range lc.clients { - close(client.Channel) - } - - lc.clients = make(map[string]*Client) - lc.conversationClients = make(map[string]map[string]*Client) return nil } @@ -175,128 +192,46 @@ func (lc *LiveChat) Channel() string { } // AddClient adds a new client to the live chat session. -// TODO: Limit the number of clients that can be connected for a `clientID`. -func (lc *LiveChat) AddClient(clientID, conversationID string) *Client { +func (lc *LiveChat) AddClient(userID, conversationUUID string) (*Client, error) { lc.clientsMutex.Lock() defer lc.clientsMutex.Unlock() - client := &Client{ - ID: clientID, - ConversationID: conversationID, - Channel: make(chan []byte, 256), - LastActivity: time.Now(), + // Check if the user already has the maximum allowed connections. + if clients, exists := lc.clients[userID]; exists && len(clients) >= MaxConnectionsPerUser { + lc.lo.Warn("maximum connections reached for user", "client_id", userID, "max_connections", MaxConnectionsPerUser) + return nil, fmt.Errorf("maximum connections reached") } - lc.clients[clientID] = client - - // Add to conversation mapping for faster lookup - if lc.conversationClients[conversationID] == nil { - lc.conversationClients[conversationID] = make(map[string]*Client) - } - lc.conversationClients[conversationID][clientID] = client - - lc.lo.Info("client added to live chat", "client_id", clientID, "conversation_id", conversationID) - return client -} - -// RemoveClient removes a client from the live chat session. -func (lc *LiveChat) RemoveClient(clientID string) { - lc.clientsMutex.Lock() - defer lc.clientsMutex.Unlock() - - if client, exists := lc.clients[clientID]; exists { - close(client.Channel) - delete(lc.clients, clientID) - - // Remove from conversation mapping - if conversationClients, exists := lc.conversationClients[client.ConversationID]; exists { - delete(conversationClients, clientID) - // Clean up empty conversation mapping - if len(conversationClients) == 0 { - delete(lc.conversationClients, client.ConversationID) - } - } - - lc.lo.Info("client removed from live chat", "client_id", clientID) - } -} - -// GetClient returns a client by ID. -func (lc *LiveChat) GetClient(clientID string) (*Client, bool) { - lc.clientsMutex.RLock() - defer lc.clientsMutex.RUnlock() - - client, exists := lc.clients[clientID] - return client, exists -} - -// BroadcastToConversation broadcasts a message to all clients in a conversation. -func (lc *LiveChat) BroadcastToConversation(conversationID string, message []byte) { - lc.clientsMutex.RLock() - defer lc.clientsMutex.RUnlock() - - // Use the conversation mapping for O(1) lookup instead of iterating all clients - if conversationClients, exists := lc.conversationClients[conversationID]; exists { - for _, client := range conversationClients { - select { - case client.Channel <- message: - default: - lc.lo.Warn("client channel full, dropping message", "client_id", client.ID) - } - } + client := &Client{ + ID: userID, + Channel: make(chan []byte, 1000), } -} -// GetConfig returns the live chat configuration. -func (lc *LiveChat) GetConfig() Config { - return lc.config -} + // Add the client to the clients map. + lc.clients[userID] = append(lc.clients[userID], client) -// UpdateClientActivity updates the last activity time for a client. -func (lc *LiveChat) UpdateClientActivity(clientID string) { - lc.clientsMutex.Lock() - defer lc.clientsMutex.Unlock() - - if client, exists := lc.clients[clientID]; exists { - client.mutex.Lock() - client.LastActivity = time.Now() - client.mutex.Unlock() - } + lc.lo.Info("client added to live chat", "client_id", userID, "conversation_uuid", conversationUUID) + return client, nil } -// cleanupInactiveClients removes clients that have been inactive for too long. -func (lc *LiveChat) cleanupInactiveClients() { +// RemoveClient removes a client from the live chat session. +func (lc *LiveChat) RemoveClient(c *Client) { lc.clientsMutex.Lock() defer lc.clientsMutex.Unlock() - - cutoff := time.Now().Add(-30 * time.Minute) - - for clientID, client := range lc.clients { - client.mutex.RLock() - lastActivity := client.LastActivity - client.mutex.RUnlock() - - if lastActivity.Before(cutoff) { - close(client.Channel) - delete(lc.clients, clientID) - - // Remove from conversation mapping - if conversationClients, exists := lc.conversationClients[client.ConversationID]; exists { - delete(conversationClients, clientID) - // Clean up empty conversation mapping - if len(conversationClients) == 0 { - delete(lc.conversationClients, client.ConversationID) + if clients, exists := lc.clients[c.ID]; exists { + for i, client := range clients { + if client == c { + // Remove the client from the slice + lc.clients[c.ID] = append(clients[:i], clients[i+1:]...) + + // If no more clients for this user, remove the entry entirely + if len(lc.clients[c.ID]) == 0 { + delete(lc.clients, c.ID) } + + lc.lo.Debug("client removed from live chat", "client_id", c.ID) + return } - - lc.lo.Info("cleaned up inactive client", "client_id", clientID) } } } - -// GetActiveClients returns the number of active clients. -func (lc *LiveChat) GetActiveClients() int { - lc.clientsMutex.RLock() - defer lc.clientsMutex.RUnlock() - return len(lc.clients) -} diff --git a/internal/inbox/inbox.go b/internal/inbox/inbox.go index 8feb00b9..303de2cd 100644 --- a/internal/inbox/inbox.go +++ b/internal/inbox/inbox.go @@ -70,6 +70,7 @@ type MessageStore interface { // UserStore defines methods for fetching user information. type UserStore interface { GetContact(id int, email string) (umodels.User, error) + GetAgent(id int, email string) (umodels.User, error) } // Opts contains the options for initializing the inbox manager. diff --git a/internal/migrations/v0.8.0.go b/internal/migrations/v0.8.0.go index b715c99d..46f2a606 100644 --- a/internal/migrations/v0.8.0.go +++ b/internal/migrations/v0.8.0.go @@ -40,5 +40,29 @@ func V0_8_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { return err } + // Add contact_last_seen_at column if it doesn't exist + _, err = db.Exec(` + ALTER TABLE conversations ADD COLUMN IF NOT EXISTS contact_last_seen_at TIMESTAMPTZ DEFAULT NOW(); + `) + if err != nil { + return err + } + + // Add last_interaction_at column if it doesn't exist + _, err = db.Exec(` + ALTER TABLE conversations ADD COLUMN IF NOT EXISTS last_interaction_at TIMESTAMPTZ NULL; + `) + if err != nil { + return err + } + + // Create index on last_interaction_at column if it doesn't exist + _, err = db.Exec(` + CREATE INDEX IF NOT EXISTS index_conversations_on_last_interaction_at ON conversations (last_interaction_at); + `) + if err != nil { + return err + } + return nil } diff --git a/internal/report/queries.sql b/internal/report/queries.sql index b6c91201..dacb7352 100644 --- a/internal/report/queries.sql +++ b/internal/report/queries.sql @@ -6,7 +6,7 @@ SELECT 'awaiting_response', COUNT( CASE - WHEN c.last_message_sender = 'contact' THEN 1 + WHEN COALESCE(c.meta->'last_message'->>'sender_type', c.last_message_sender) = 'contact' THEN 1 END ), 'unassigned', diff --git a/schema.sql b/schema.sql index 13244343..bfa6b7a9 100644 --- a/schema.sql +++ b/schema.sql @@ -214,6 +214,7 @@ CREATE TABLE conversations ( meta JSONB DEFAULT '{}'::jsonb NOT NULL, custom_attributes JSONB DEFAULT '{}'::jsonb NOT NULL, assignee_last_seen_at TIMESTAMPTZ DEFAULT NOW(), + contact_last_seen_at TIMESTAMPTZ DEFAULT NOW(), first_reply_at TIMESTAMPTZ NULL, last_reply_at TIMESTAMPTZ NULL, closed_at TIMESTAMPTZ NULL, @@ -221,9 +222,12 @@ CREATE TABLE conversations ( "subject" TEXT NULL, waiting_since TIMESTAMPTZ NULL, + -- Fields to track last message details. last_message_at TIMESTAMPTZ NULL, last_message TEXT NULL, last_message_sender message_sender_type NULL, + last_interaction_at TIMESTAMPTZ NULL, + next_sla_deadline_at TIMESTAMPTZ NULL, snoozed_until TIMESTAMPTZ NULL ); @@ -236,6 +240,7 @@ CREATE INDEX index_conversations_on_status_id ON conversations (status_id); CREATE INDEX index_conversations_on_priority_id ON conversations (priority_id); CREATE INDEX index_conversations_on_created_at ON conversations (created_at); CREATE INDEX index_conversations_on_last_message_at ON conversations (last_message_at); +CREATE INDEX index_conversations_on_last_interaction_at ON conversations (last_interaction_at); CREATE INDEX index_conversations_on_next_sla_deadline_at ON conversations (next_sla_deadline_at); CREATE INDEX index_conversations_on_waiting_since ON conversations (waiting_since); From 282dc83439dd5613c745ed69fea502cbce924514 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sun, 6 Jul 2025 18:47:19 +0530 Subject: [PATCH 03/55] fix set correct var name --- internal/conversation/message.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 51329407..1418251b 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -179,7 +179,7 @@ func (m *Manager) sendOutgoingMessage(message models.Message) { } // Send message - err = inbox.Send(message) + err = inb.Send(message) if handleError(err, "error sending message") { return } From 8ee81c2d640f8f0321b332ddacb7337dca658e55 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Thu, 17 Jul 2025 01:06:54 +0530 Subject: [PATCH 04/55] feat: Widget dark mode and chat reply expectation message in chat title. feat: Add HTTP utility functions for trusted origin checks feat: Implement typing status broadcasting for live chat clients and agents. feat: Add support for signed URLs in media manager fix: Update database migration to handle duplicate visitors with same email address. feat: Add conversation subscription and typing message models for WebSocket communication feat: Implement conversation subscription management in WebSocket hub this is used for broadcasting typing indicator. feat: Revamp widget JavaScript to improve mobile responsiveness and show unread messages if any. --- cmd/chat.go | 518 ++++++---- cmd/conversation.go | 5 - cmd/csat.go | 44 +- cmd/handlers.go | 23 +- cmd/inboxes.go | 14 + cmd/init.go | 9 +- cmd/main.go | 4 + cmd/media.go | 58 +- cmd/messages.go | 11 +- cmd/middlewares.go | 17 + cmd/widget_middleware.go | 106 ++ cmd/widget_ws.go | 54 +- .../main/src/components/editor/TextEditor.vue | 15 + frontend/apps/main/src/constants/websocket.js | 10 +- .../admin/inbox/LivechatInboxForm.vue | 951 ++++++++++-------- .../admin/inbox/livechatFormSchema.js | 20 +- .../message/AgentMessageBubble.vue | 4 + .../message/CSATResponseDisplay.vue | 64 ++ .../conversation/message/MessageList.vue | 64 +- frontend/apps/main/src/stores/conversation.js | 47 +- .../src/utils/conversation-message-cache.js | 16 + frontend/apps/main/src/websocket.js | 101 +- frontend/apps/widget/src/App.vue | 73 +- frontend/apps/widget/src/api/index.js | 27 +- .../src/components/CSATMessageBubble.vue | 113 +++ .../apps/widget/src/components/ChatHeader.vue | 21 + .../widget/src/components/ChatMessages.vue | 247 +++++ .../apps/widget/src/components/ChatTitle.vue | 107 ++ .../src/components/CloseWidgetButton.vue | 28 + .../src/components/HomeExternalLinks.vue | 34 + .../apps/widget/src/components/HomeHeader.vue | 27 + .../src/components/MessageAttachment.vue | 2 +- .../widget/src/components/MessageInput.vue | 234 +++++ .../widget/src/components/MessagesList.vue | 89 ++ .../src/components/RecentConversationCard.vue | 69 ++ .../src/components/UnreadCountBadge.vue | 17 + .../widget/src/components/WidgetError.vue | 2 +- .../src/composables/useBusinessHours.js | 228 +++++ .../widget/src/composables/useRelativeTime.js | 13 + .../widget/src/composables/useUnreadCount.js | 40 + .../apps/widget/src/layouts/MainLayout.vue | 8 +- .../apps/widget/src/layouts/WidgetHeader.vue | 13 + frontend/apps/widget/src/main.js | 55 +- frontend/apps/widget/src/store/chat.js | 174 ++-- frontend/apps/widget/src/store/user.js | 18 +- frontend/apps/widget/src/store/widget.js | 7 + frontend/apps/widget/src/views/ChatView.vue | 393 +------- frontend/apps/widget/src/views/HomeView.vue | 107 +- .../apps/widget/src/views/MessagesView.vue | 97 +- frontend/apps/widget/src/websocket.js | 91 +- .../ScrollToBottomButton.vue | 42 + .../components/ScrollToBottomButton/index.js | 1 + .../TypingIndicator/TypingIndicator.vue | 9 + .../components/TypingIndicator/index.js | 1 + frontend/shared-ui/components/index.js | 14 - frontend/shared-ui/composables/index.js | 1 + .../composables/useTypingIndicator.js | 46 + frontend/shared-ui/utils/datetime.js | 6 +- i18n/en.json | 13 +- internal/attachment/attachment.go | 7 + internal/business_hours/queries.sql | 5 +- internal/conversation/conversation.go | 34 + internal/conversation/message.go | 219 ++-- internal/conversation/models/models.go | 175 +++- internal/conversation/queries.sql | 37 +- internal/conversation/ws.go | 54 + internal/csat/csat.go | 3 +- internal/httputil/httputil.go | 90 ++ internal/inbox/channel/email/imap.go | 1 + internal/inbox/channel/livechat/livechat.go | 80 +- internal/media/media.go | 66 +- internal/media/models/models.go | 28 +- internal/migrations/v0.8.0.go | 49 +- internal/user/models/models.go | 13 +- internal/user/queries.sql | 15 +- internal/user/user.go | 6 +- internal/user/visitor.go | 8 +- internal/ws/client.go | 79 +- internal/ws/models/models.go | 15 + internal/ws/ws.go | 129 ++- schema.sql | 8 +- static/widget.js | 159 ++- 82 files changed, 4227 insertions(+), 1675 deletions(-) create mode 100644 cmd/widget_middleware.go create mode 100644 frontend/apps/main/src/features/conversation/message/CSATResponseDisplay.vue create mode 100644 frontend/apps/widget/src/components/CSATMessageBubble.vue create mode 100644 frontend/apps/widget/src/components/ChatHeader.vue create mode 100644 frontend/apps/widget/src/components/ChatMessages.vue create mode 100644 frontend/apps/widget/src/components/ChatTitle.vue create mode 100644 frontend/apps/widget/src/components/CloseWidgetButton.vue create mode 100644 frontend/apps/widget/src/components/HomeExternalLinks.vue create mode 100644 frontend/apps/widget/src/components/HomeHeader.vue create mode 100644 frontend/apps/widget/src/components/MessageInput.vue create mode 100644 frontend/apps/widget/src/components/MessagesList.vue create mode 100644 frontend/apps/widget/src/components/RecentConversationCard.vue create mode 100644 frontend/apps/widget/src/components/UnreadCountBadge.vue create mode 100644 frontend/apps/widget/src/composables/useBusinessHours.js create mode 100644 frontend/apps/widget/src/composables/useRelativeTime.js create mode 100644 frontend/apps/widget/src/composables/useUnreadCount.js create mode 100644 frontend/apps/widget/src/layouts/WidgetHeader.vue create mode 100644 frontend/shared-ui/components/ScrollToBottomButton/ScrollToBottomButton.vue create mode 100644 frontend/shared-ui/components/ScrollToBottomButton/index.js create mode 100644 frontend/shared-ui/components/TypingIndicator/TypingIndicator.vue create mode 100644 frontend/shared-ui/components/TypingIndicator/index.js delete mode 100644 frontend/shared-ui/components/index.js create mode 100644 frontend/shared-ui/composables/index.js create mode 100644 frontend/shared-ui/composables/useTypingIndicator.js create mode 100644 internal/httputil/httputil.go diff --git a/cmd/chat.go b/cmd/chat.go index 76f9b18b..d0262663 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -1,12 +1,8 @@ package main import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" - "net/http" "path/filepath" "slices" "strconv" @@ -14,26 +10,18 @@ import ( "time" "github.com/abhinavxd/libredesk/internal/attachment" - "github.com/abhinavxd/libredesk/internal/conversation/models" + bhmodels "github.com/abhinavxd/libredesk/internal/business_hours/models" cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" "github.com/abhinavxd/libredesk/internal/envelope" - "github.com/abhinavxd/libredesk/internal/image" "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" "github.com/abhinavxd/libredesk/internal/stringutil" umodels "github.com/abhinavxd/libredesk/internal/user/models" "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" "github.com/valyala/fasthttp" "github.com/volatiletech/null/v9" "github.com/zerodha/fastglue" ) -const ( - // TODO: Can have a global route that serves media files with a signature and expiry. - // Or use the same existing `/uploads` - chatWidgetMediaURL = "/api/v1/widget/media/%s?signature=%s&expires=%d" -) - type onlyJWT struct { JWT string `json:"jwt"` } @@ -57,8 +45,8 @@ type chatInitReq struct { } type conversationResp struct { - Conversation models.ChatConversation `json:"conversation"` - Messages []models.ChatMessage `json:"messages"` + Conversation cmodels.ChatConversation `json:"conversation"` + Messages []cmodels.ChatMessage `json:"messages"` } type chatMessageReq struct { @@ -66,6 +54,59 @@ type chatMessageReq struct { onlyJWT } +type chatSettingsResponse struct { + livechat.Config + BusinessHours []bhmodels.BusinessHours `json:"business_hours,omitempty"` + DefaultBusinessHoursID int `json:"default_business_hours_id,omitempty"` +} + +// conversationResponseWithBusinessHours includes business hours info for the widget +type conversationResponseWithBusinessHours struct { + conversationResp + BusinessHoursID *int `json:"business_hours_id,omitempty"` + WorkingHoursUTCOffset *int `json:"working_hours_utc_offset,omitempty"` +} + +// TODO: live chat widget can have a different language setting than the main app, handle this. +// +// handleGetChatLauncherSettings returns the live chat launcher settings for the widget +func handleGetChatLauncherSettings(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + inboxID = r.RequestCtx.QueryArgs().GetUintOrZero("inbox_id") + ) + + if inboxID <= 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.inbox}"), nil, envelope.InputError) + } + + // Get inbox configuration + inbox, err := app.inbox.GetDBRecord(inboxID) + if err != nil { + app.lo.Error("error fetching inbox", "inbox_id", inboxID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.NotFoundError) + } + + if inbox.Channel != livechat.ChannelLiveChat { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.InputError) + } + + if !inbox.Enabled { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError) + } + + var config livechat.Config + if err := json.Unmarshal(inbox.Config, &config); err != nil { + app.lo.Error("error parsing live chat config", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) + } + + return r.SendEnvelope(map[string]any{ + "launcher": config.Launcher, + "colors": config.Colors, + }) +} + // handleGetChatSettings returns the live chat settings for the widget func handleGetChatSettings(r *fastglue.Request) error { var ( @@ -98,7 +139,35 @@ func handleGetChatSettings(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) } - return r.SendEnvelope(config) + // Get business hours data if office hours feature is enabled. + response := chatSettingsResponse{ + Config: config, + } + + if config.ShowOfficeHoursInChat { + // Get all business hours. + businessHours, err := app.businessHours.GetAll() + if err != nil { + app.lo.Error("error fetching business hours", "error", err) + } else { + response.BusinessHours = businessHours + } + + // Get default business hours ID from general settings which is the default / fallback. + out, err := app.setting.GetByPrefix("app") + if err != nil { + app.lo.Error("error fetching general settings", "error", err) + } else { + var settings map[string]any + if err := json.Unmarshal(out, &settings); err == nil { + if bhID, ok := settings["app.business_hours_id"].(string); ok { + response.DefaultBusinessHoursID, _ = strconv.Atoi(bhID) + } + } + } + } + + return r.SendEnvelope(response) } // handleChatInit initializes a new chat session. @@ -128,7 +197,6 @@ func handleChatInit(r *fastglue.Request) error { if !inbox.Enabled { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError) } - if inbox.Channel != livechat.ChannelLiveChat { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.InputError) } @@ -139,11 +207,11 @@ func handleChatInit(r *fastglue.Request) error { app.lo.Error("error parsing live chat config", "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) } - - var contactID int - var conversationUUID string - var isVisitor bool - + var ( + contactID int + conversationUUID string + isVisitor bool + ) // Handle authenticated user if req.JWT != "" { claims, err := verifyStandardJWT(req.JWT) @@ -174,6 +242,36 @@ func handleChatInit(r *fastglue.Request) error { } } + // Check conversation permissions based on user type. + userConfig := config.Visitors + if !isVisitor { + userConfig = config.Users + } + + if !userConfig.AllowStartConversation { + return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("globals.messages.notAllowed}"), nil, envelope.PermissionError) + } + + if userConfig.PreventMultipleConversations { + conversations, err := app.conversation.GetContactChatConversations(contactID) + if err != nil { + userType := "visitor" + if !isVisitor { + userType = "user" + } + app.lo.Error("error fetching "+userType+" conversations", "contact_id", contactID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil, envelope.GeneralError) + } + if len(conversations) > 0 { + userType := "visitor" + if !isVisitor { + userType = "user" + } + app.lo.Info(userType+" attempted to start new conversation but already has one", "contact_id", contactID, "conversations_count", len(conversations)) + return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("globals.messages.notAllowed}"), nil, envelope.PermissionError) + } + } + app.lo.Info("creating new live chat conversation for user", "user_id", contactID, "inbox_id", req.InboxID, "is_visitor", isVisitor) // Create conversation. @@ -191,14 +289,14 @@ func handleChatInit(r *fastglue.Request) error { } // Insert initial message. - message := models.Message{ + message := cmodels.Message{ ConversationUUID: conversationUUID, SenderID: contactID, - Type: models.MessageIncoming, - SenderType: models.SenderTypeContact, - Status: models.MessageStatusReceived, + Type: cmodels.MessageIncoming, + SenderType: cmodels.SenderTypeContact, + Status: cmodels.MessageStatusReceived, Content: req.Message, - ContentType: models.ContentTypeText, + ContentType: cmodels.ContentTypeText, Private: false, } if err := app.conversation.InsertMessage(&message); err != nil { @@ -211,6 +309,11 @@ func handleChatInit(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil, envelope.GeneralError) } + // Process post-message hooks for the new conversation and initial message. + if err := app.conversation.ProcessIncomingMessageHooks(conversationUUID, true); err != nil { + app.lo.Error("error processing incoming message hooks for initial message", "conversation_uuid", conversationUUID, "error", err) + } + conversation, err := app.conversation.GetConversation(0, conversationUUID) if err != nil { app.lo.Error("error fetching created conversation", "conversation_uuid", conversationUUID, "error", err) @@ -218,15 +321,17 @@ func handleChatInit(r *fastglue.Request) error { } // Build response with conversation and messages. - resp, err := buildConversationResponse(app, conversation) + resp, err := buildConversationResponseWithBusinessHours(app, conversation) if err != nil { return sendErrorEnvelope(r, err) } return r.SendEnvelope(map[string]any{ - "conversation": resp.Conversation, - "messages": resp.Messages, - "jwt": req.JWT, + "conversation": resp.Conversation, + "messages": resp.Messages, + "business_hours_id": resp.BusinessHoursID, + "working_hours_utc_offset": resp.WorkingHoursUTCOffset, + "jwt": req.JWT, }) } @@ -323,7 +428,7 @@ func handleChatGetConversation(r *fastglue.Request) error { } // Build conversation response with messages and attachments. - resp, err := buildConversationResponse(app, conversation) + resp, err := buildConversationResponseWithBusinessHours(app, conversation) if err != nil { return sendErrorEnvelope(r, err) } @@ -366,7 +471,7 @@ func handleChatSendMessage(r *fastglue.Request) error { app = r.Context.(*App) conversationUUID = r.RequestCtx.UserValue("uuid").(string) req = chatMessageReq{} - senderType = models.SenderTypeContact + senderType = cmodels.SenderTypeContact senderID = 0 ) @@ -386,6 +491,13 @@ func handleChatSendMessage(r *fastglue.Request) error { } senderID = claims.UserID + // Fetch sender. + sender, err := app.user.Get(senderID, "", "") + if err != nil { + app.lo.Error("error fetching sender user", "sender_id", senderID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError) + } + // Fetch conversation to ensure it exists. conversation, err := app.conversation.GetConversation(0, conversationUUID) if err != nil { @@ -408,31 +520,57 @@ func handleChatSendMessage(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError) } - // Insert message. - message := models.Message{ + // Insert incoming message and run post processing hooks. + message := cmodels.Message{ ConversationUUID: conversationUUID, SenderID: senderID, - Type: models.MessageIncoming, + Type: cmodels.MessageIncoming, SenderType: senderType, - Status: models.MessageStatusReceived, + Status: cmodels.MessageStatusReceived, Content: req.Message, - ContentType: models.ContentTypeText, + ContentType: cmodels.ContentTypeText, Private: false, } - - if err := app.conversation.InsertMessage(&message); err != nil { - app.lo.Error("error inserting chat message", "error", err) + if message, err = app.conversation.ProcessIncomingMessage(cmodels.IncomingMessage{ + Channel: livechat.ChannelLiveChat, + Message: message, + Contact: sender, + InboxID: inbox.ID, + }); err != nil { + app.lo.Error("error processing incoming message", "conversation_uuid", conversationUUID, "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil, envelope.GeneralError) } - return r.SendEnvelope(map[string]bool{"success": true}) + // Fetch just inserted message to return. + message, err = app.conversation.GetMessage(message.UUID) + if err != nil { + app.lo.Error("error fetching inserted message", "message_uuid", message.UUID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.message}"), nil, envelope.GeneralError) + } + + return r.SendEnvelope(cmodels.ChatMessage{ + UUID: message.UUID, + CreatedAt: message.CreatedAt, + Content: message.Content, + TextContent: message.TextContent, + ConversationUUID: message.ConversationUUID, + Status: message.Status, + Author: umodels.ChatUser{ + ID: sender.ID, + FirstName: sender.FirstName, + LastName: sender.LastName, + AvatarURL: sender.AvatarURL, + AvailabilityStatus: sender.AvailabilityStatus, + Type: sender.Type, + }, + Attachments: message.Attachments, + }) } // handleWidgetMediaUpload handles media uploads for the widget. func handleWidgetMediaUpload(r *fastglue.Request) error { var ( app = r.Context.(*App) - cleanUp = false senderID = 0 ) @@ -466,14 +604,12 @@ func handleWidgetMediaUpload(r *fastglue.Request) error { // Set sender ID from JWT claims. senderID = claims.UserID - // Verify conversation exists and user has access + // Make sure the conversation belongs to the sender conversation, err := app.conversation.GetConversation(0, conversationUUID) if err != nil { app.lo.Error("error fetching conversation", "conversation_uuid", conversationUUID, "error", err) return sendErrorEnvelope(r, err) } - - // Make sure the conversation belongs to the sender if conversation.ContactID != senderID { app.lo.Error("access denied: user attempted to access conversation owned by different contact", "conversation_uuid", conversationUUID, "requesting_contact_id", senderID, "conversation_owner_id", conversation.ContactID) return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError) @@ -535,130 +671,65 @@ func handleWidgetMediaUpload(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("media.fileTypeNotAllowed"), nil, envelope.InputError) } - // Delete files on any error. - var uuid = uuid.New() - thumbName := thumbPrefix + uuid.String() - defer func() { - if cleanUp { - app.media.Delete(uuid.String()) - app.media.Delete(thumbName) - } - }() - - // Generate and upload thumbnail and store image dimensions in the media meta. - var meta = []byte("{}") - if slices.Contains(image.Exts, srcExt) { - file.Seek(0, 0) - thumbFile, err := image.CreateThumb(image.DefThumbSize, file) - if err != nil { - app.lo.Error("error creating thumb image", "error", err) - 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 { - return sendErrorEnvelope(r, err) - } - - // Store image dimensions in media meta, storing dimensions for image previews in future. - file.Seek(0, 0) - width, height, err := image.GetDimensions(file) - if err != nil { - cleanUp = true - app.lo.Error("error getting image dimensions", "error", err) - return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.media}"), nil, envelope.GeneralError) - } - meta, _ = json.Marshal(map[string]any{ - "width": width, - "height": height, - }) + // Read file content into byte slice + file.Seek(0, 0) + fileContent := make([]byte, srcFileSize) + if _, err := file.Read(fileContent); err != nil { + app.lo.Error("error reading file content", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil, envelope.GeneralError) } - file.Seek(0, 0) - _, err = app.media.Upload(uuid.String(), srcContentType, file) + // Get sender user for ProcessIncomingMessage + sender, err := app.user.Get(senderID, "", "") if err != nil { - cleanUp = true - app.lo.Error("error uploading file", "error", err) - return sendErrorEnvelope(r, err) + app.lo.Error("error fetching sender user", "sender_id", senderID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError) } - // Insert message with empty content and after insert link the media to the message. - message := models.Message{ + // Create message with attachment using existing infrastructure + message := cmodels.Message{ ConversationUUID: conversationUUID, SenderID: senderID, - Type: models.MessageIncoming, - SenderType: models.SenderTypeContact, - Status: models.MessageStatusReceived, + Type: cmodels.MessageIncoming, + SenderType: cmodels.SenderTypeContact, + Status: cmodels.MessageStatusReceived, Content: "", - ContentType: models.ContentTypeText, + ContentType: cmodels.ContentTypeText, Private: false, - } - if err := app.conversation.InsertMessage(&message); err != nil { - cleanUp = true - app.lo.Error("error inserting message", "error", err) - return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil, envelope.GeneralError) - } - - // Insert media linked to the just inserted message. - media, err := app.media.Insert(null.StringFrom(attachment.DispositionAttachment), srcFileName, srcContentType, "" /**content_id**/, null.NewString("messages", true), uuid.String(), null.NewInt(message.ID, true), int(srcFileSize), meta) - if err != nil { - cleanUp = true - app.lo.Error("error inserting metadata into database", "error", err) - return sendErrorEnvelope(r, err) - } - - return r.SendEnvelope(media) -} - -// handleWidgetServeMedia serves media files for the widget -func handleWidgetServeMedia(r *fastglue.Request) error { - var ( - app = r.Context.(*App) - uuid = r.RequestCtx.UserValue("uuid").(string) - signature = string(r.RequestCtx.QueryArgs().Peek("signature")) - expiresStr = string(r.RequestCtx.QueryArgs().Peek("expires")) - ) - - if uuid == "" { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "uuid"), nil, envelope.InputError) - } - - if signature == "" { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Signature missing", nil, envelope.InputError) - } - - if expiresStr == "" { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Expiry missing", nil, envelope.InputError) - } - - expires, err := strconv.ParseInt(expiresStr, 10, 64) - if err != nil { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid expiry", nil, envelope.InputError) + Attachments: attachment.Attachments{ + { + Name: srcFileName, + ContentType: srcContentType, + Size: int(srcFileSize), + Content: fileContent, + Disposition: attachment.DispositionAttachment, + }, + }, } - // Verify signature and expiration. - expiresAt := time.Unix(expires, 0) - if !VerifySignedURL(uuid, signature, expiresAt, getJWTSecret()) { - return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError) + // Process the incoming message with attachment. + if message, err = app.conversation.ProcessIncomingMessage(cmodels.IncomingMessage{ + Channel: livechat.ChannelLiveChat, + Message: message, + Contact: sender, + InboxID: inbox.ID, + }); err != nil { + app.lo.Error("error processing incoming message with attachment", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorInserting", "name", "{globals.terms.message}"), nil, envelope.GeneralError) } - // Get media DB record. - _, err = app.media.Get(0, uuid) + // Fetch the inserted message to get the media information. + insertedMessage, err := app.conversation.GetMessage(message.UUID) if err != nil { - return sendErrorEnvelope(r, err) + app.lo.Error("error fetching inserted message", "message_uuid", message.UUID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.message}"), nil, envelope.GeneralError) } - consts := app.consts.Load().(*constants) - switch consts.UploadProvider { - case "fs": - fasthttp.ServeFile(r.RequestCtx, filepath.Join(ko.String("upload.fs.upload_path"), uuid)) - case "s3": - r.RequestCtx.Redirect(app.media.GetURL(uuid), http.StatusFound) - } - return nil + return r.SendEnvelope(insertedMessage) } // buildConversationResponse builds the response for a conversation including its messages -func buildConversationResponse(app *App, conversation models.Conversation) (conversationResp, error) { +func buildConversationResponse(app *App, conversation cmodels.Conversation) (conversationResp, error) { var resp = conversationResp{} // Fetch last 1000 messages, this should suffice as chats shouldn't have too many messages. @@ -669,22 +740,48 @@ func buildConversationResponse(app *App, conversation models.Conversation) (conv return resp, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.message}"), nil) } + app.conversation.ProcessCSATStatus(messages) + // Convert to chat message format, Generate signed widget URL for all attachments - expires in 1 hour. - chatMessages := make([]models.ChatMessage, len(messages)) + chatMessages := make([]cmodels.ChatMessage, len(messages)) + userCache := make(map[int]umodels.User) for i, msg := range messages { attachments := msg.Attachments for j := range attachments { - expiresAt := time.Now().Add(1 * time.Hour) - signature := GenerateSignedURL(attachments[j].UUID, expiresAt, getJWTSecret()) - attachments[j].URL = fmt.Sprintf(chatWidgetMediaURL, attachments[j].UUID, signature, expiresAt.Unix()) + expiresAt := time.Now().Add(8 * time.Hour) + attachments[j].URL = app.media.GetSignedURL(attachments[j].UUID, expiresAt) + } + + // Check if sender is cached, if not fetch from user store. + var user umodels.User + if _, ok := userCache[msg.SenderID]; !ok { + user, err = app.user.Get(msg.SenderID, "", "") + if err != nil { + app.lo.Error("error fetching message sender user", "sender_id", msg.SenderID, "conversation_uuid", conversation.UUID, "error", err) + } else { + userCache[msg.SenderID] = user + } + } else { + user = userCache[msg.SenderID] } - chatMessages[i] = models.ChatMessage{ - UUID: msg.UUID, - Content: msg.Content, - CreatedAt: msg.CreatedAt, - SenderType: msg.SenderType, - ConversationID: msg.ConversationUUID, - Attachments: attachments, + + chatMessages[i] = cmodels.ChatMessage{ + UUID: msg.UUID, + Status: msg.Status, + CreatedAt: msg.CreatedAt, + Content: msg.Content, + TextContent: msg.TextContent, + ConversationUUID: msg.ConversationUUID, + Meta: msg.Meta, + Author: umodels.ChatUser{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + AvatarURL: user.AvatarURL, + AvailabilityStatus: user.AvailabilityStatus, + Type: user.Type, + }, + Attachments: attachments, } } @@ -698,24 +795,32 @@ func buildConversationResponse(app *App, conversation models.Conversation) (conv } // Convert assignee avatar URL to widget format if set. - // TODO: Instead of this hardcoded URL, make it a central handler. if assignee.AvatarURL.Valid && assignee.AvatarURL.String != "" { avatarPath := assignee.AvatarURL.String if strings.HasPrefix(avatarPath, "/uploads/") { avatarUUID := strings.TrimPrefix(avatarPath, "/uploads/") // Generate signed URL for avatar with 1 hour expiry expiresAt := time.Now().Add(1 * time.Hour) - signature := GenerateSignedURL(avatarUUID, expiresAt, getJWTSecret()) - assignee.AvatarURL = null.StringFrom(fmt.Sprintf(chatWidgetMediaURL, avatarUUID, signature, expiresAt.Unix())) + assignee.AvatarURL = null.StringFrom(app.media.GetSignedURL(avatarUUID, expiresAt)) } } } resp = conversationResp{ - Conversation: models.ChatConversation{ - UUID: conversation.UUID, - Status: conversation.Status.String, - Assignee: assignee, + Conversation: cmodels.ChatConversation{ + CreatedAt: assignee.CreatedAt, + UUID: conversation.UUID, + Status: conversation.Status.String, + UnreadMessageCount: conversation.UnreadMessageCount, + Assignee: umodels.ChatUser{ + ID: assignee.ID, + FirstName: assignee.FirstName, + LastName: assignee.LastName, + AvatarURL: assignee.AvatarURL, + AvailabilityStatus: assignee.AvailabilityStatus, + Type: assignee.Type, + ActiveAt: assignee.LastActiveAt, + }, }, Messages: chatMessages, } @@ -723,33 +828,64 @@ func buildConversationResponse(app *App, conversation models.Conversation) (conv return resp, nil } -// GenerateSignedURL generates a signature for media access with expiration -func GenerateSignedURL(uuid string, expiresAt time.Time, secret []byte) string { - exp := expiresAt.Unix() - payload := fmt.Sprintf("%s:%d", uuid, exp) - sig := hmacSha256(payload, secret) - return sig -} +// buildConversationResponseWithBusinessHours builds conversation response with business hours info +func buildConversationResponseWithBusinessHours(app *App, conversation cmodels.Conversation) (conversationResponseWithBusinessHours, error) { + baseResp, err := buildConversationResponse(app, conversation) + if err != nil { + return conversationResponseWithBusinessHours{}, err + } -// VerifySignedURL verifies a signed URL with expiration -func VerifySignedURL(uuid, signature string, expiresAt time.Time, secret []byte) bool { - // Check if expired - if time.Now().After(expiresAt) { - return false + resp := conversationResponseWithBusinessHours{ + conversationResp: baseResp, } - // Generate expected signature - expectedSignature := GenerateSignedURL(uuid, expiresAt, secret) + // Calculate business hours info if assigned to team or use default + var businessHoursID *int + var timezone string - // Compare signatures using constant time comparison to prevent timing attacks - return hmac.Equal([]byte(signature), []byte(expectedSignature)) -} + // Check if conversation is assigned to a team with business hours + if conversation.AssignedTeamID.Valid { + team, err := app.team.Get(conversation.AssignedTeamID.Int) + if err == nil && team.BusinessHoursID.Valid { + businessHoursID = &team.BusinessHoursID.Int + timezone = team.Timezone + } + } -// hmacSha256 generates HMAC-SHA256 hash -func hmacSha256(data string, secret []byte) string { - h := hmac.New(sha256.New, secret) - h.Write([]byte(data)) - return hex.EncodeToString(h.Sum(nil)) + // Fallback to general settings if no team business hours + if businessHoursID == nil { + out, err := app.setting.GetByPrefix("app") + if err == nil { + var settings map[string]interface{} + if err := json.Unmarshal(out, &settings); err == nil { + if bhIDStr, ok := settings["app.business_hours_id"].(string); ok && bhIDStr != "" { + // Parse the business hours ID + if bhID, err := strconv.Atoi(bhIDStr); err == nil { + businessHoursID = &bhID + } + } + if tz, ok := settings["app.timezone"].(string); ok { + timezone = tz + } + } + } + } + + // Set business hours info in response + if businessHoursID != nil { + resp.BusinessHoursID = businessHoursID + + // Calculate UTC offset for the timezone + if timezone != "" { + if loc, err := time.LoadLocation(timezone); err == nil { + _, offset := time.Now().In(loc).Zone() + offsetMinutes := offset / 60 // Convert seconds to minutes + resp.WorkingHoursUTCOffset = &offsetMinutes + } + } + } + + return resp, nil } // verifyJWT verifies and validates a JWT token with proper signature verification diff --git a/cmd/conversation.go b/cmd/conversation.go index 9e422b8d..4a8ceecb 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -474,11 +474,6 @@ func handleUpdateConversationStatus(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - // Make sure a user is assigned before resolving conversation. - if status == cmodels.StatusResolved && conversation.AssignedUserID.Int == 0 { - return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("conversation.resolveWithoutAssignee"), nil)) - } - // Update conversation status. if err := app.conversation.UpdateConversationStatus(uuid, 0 /**status_id**/, status, snoozedUntil, user); err != nil { return sendErrorEnvelope(r, err) diff --git a/cmd/csat.go b/cmd/csat.go index a89234cc..4f61583d 100644 --- a/cmd/csat.go +++ b/cmd/csat.go @@ -3,9 +3,16 @@ package main import ( "strconv" + "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/valyala/fasthttp" "github.com/zerodha/fastglue" ) +type csatResponse struct { + Rating int `json:"rating"` + Feedback string `json:"feedback"` +} + // handleShowCSAT renders the CSAT page for a given csat. func handleShowCSAT(r *fastglue.Request) error { var ( @@ -42,7 +49,7 @@ func handleShowCSAT(r *fastglue.Request) error { return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{ "Data": map[string]interface{}{ - "Title": "Rate your interaction with us", + "Title": "Rate your interaction with us", "CSAT": map[string]interface{}{ "UUID": csat.UUID, }, @@ -72,7 +79,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error { }) } - if ratingI < 1 || ratingI > 5 { + if ratingI < 0 || ratingI > 5 { return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ "Data": map[string]interface{}{ "ErrorMessage": "Invalid `rating`", @@ -103,3 +110,36 @@ func handleUpdateCSATResponse(r *fastglue.Request) error { }, }) } + +// handleSubmitCSATResponse handles CSAT response submission from the widget API. +func handleSubmitCSATResponse(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + uuid = r.RequestCtx.UserValue("uuid").(string) + req = csatResponse{} + ) + + if err := r.Decode(&req, "json"); err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid JSON", nil, envelope.InputError) + } + + if req.Rating < 0 || req.Rating > 5 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Rating must be between 0 and 5 (0 means no rating)", nil, envelope.InputError) + } + + // At least one of rating or feedback must be provided + if req.Rating == 0 && req.Feedback == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Either rating or feedback must be provided", nil, envelope.InputError) + } + + if uuid == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid UUID", nil, envelope.InputError) + } + + // Update CSAT response + if err := app.csat.UpdateResponse(uuid, req.Rating, req.Feedback); err != nil { + return sendErrorEnvelope(r, err) + } + + return r.SendEnvelope(true) +} diff --git a/cmd/handlers.go b/cmd/handlers.go index 27c7c3fa..f1d2a2bd 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -210,21 +210,26 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { // Actvity logs. g.GET("/api/v1/activity-logs", perm(handleGetActivityLogs, "activity_logs:manage")) + // CSAT. + g.POST("/api/v1/csat/{uuid}/response", handleSubmitCSATResponse) + // WebSocket. g.GET("/ws", auth(func(r *fastglue.Request) error { return handleWS(r, hub) })) - // Live chat widget. + // Live chat widget websocket. g.GET("/widget/ws", handleWidgetWS) - g.GET("/api/v1/widget/chat/settings", handleGetChatSettings) - g.POST("/api/v1/widget/chat/conversations/init", handleChatInit) - g.POST("/api/v1/widget/chat/conversations", handleGetConversations) - g.POST("/api/v1/widget/chat/conversations/{uuid}/update-last-seen", handleChatUpdateLastSeen) - g.POST("/api/v1/widget/chat/conversations/{uuid}", handleChatGetConversation) - g.POST("/api/v1/widget/chat/conversations/{uuid}/message", handleChatSendMessage) - g.POST("/api/v1/widget/media/upload", handleWidgetMediaUpload) - g.GET("/api/v1/widget/media/{uuid}", handleWidgetServeMedia) + + // Widget APIs. + g.GET("/api/v1/widget/chat/settings/launcher", widgetOrigin(handleGetChatLauncherSettings)) + g.GET("/api/v1/widget/chat/settings", widgetOrigin(handleGetChatSettings)) + g.POST("/api/v1/widget/chat/conversations/init", widgetOrigin(handleChatInit)) + g.POST("/api/v1/widget/chat/conversations", widgetOrigin(handleGetConversations)) + g.POST("/api/v1/widget/chat/conversations/{uuid}/update-last-seen", widgetOrigin(handleChatUpdateLastSeen)) + g.POST("/api/v1/widget/chat/conversations/{uuid}", widgetOrigin(handleChatGetConversation)) + g.POST("/api/v1/widget/chat/conversations/{uuid}/message", widgetOrigin(handleChatSendMessage)) + g.POST("/api/v1/widget/media/upload", widgetOrigin(handleWidgetMediaUpload)) // Frontend pages. g.GET("/", notAuthPage(serveIndexPage)) diff --git a/cmd/inboxes.go b/cmd/inboxes.go index 1bac4c7b..2bf4bc04 100644 --- a/cmd/inboxes.go +++ b/cmd/inboxes.go @@ -1,10 +1,12 @@ package main import ( + "encoding/json" "net/mail" "strconv" "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" imodels "github.com/abhinavxd/libredesk/internal/inbox/models" "github.com/valyala/fasthttp" "github.com/zerodha/fastglue" @@ -169,5 +171,17 @@ func validateInbox(app *App, inbox imodels.Inbox) error { if inbox.Channel == "" { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "channel"), nil) } + + // Validate livechat-specific configuration + if inbox.Channel == livechat.ChannelLiveChat { + var config livechat.Config + if err := json.Unmarshal(inbox.Config, &config); err == nil { + // ShowOfficeHoursAfterAssignment cannot be enabled if ShowOfficeHoursInChat is disabled + if config.ShowOfficeHoursAfterAssignment && !config.ShowOfficeHoursInChat { + return envelope.NewError(envelope.InputError, "`show_office_hours_after_assignment` cannot be enabled when `show_office_hours_in_chat` is disabled", nil) + } + } + } + return nil } diff --git a/cmd/init.go b/cmd/init.go index 29301e89..b88aed9e 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -462,10 +462,11 @@ func initMedia(db *sqlx.DB, i18n *i18n.I18n) *media.Manager { } media, err := media.New(media.Opts{ - Store: store, - Lo: lo, - DB: db, - I18n: i18n, + Store: store, + Lo: lo, + DB: db, + I18n: i18n, + Secret: ko.String("upload.secret"), }) if err != nil { log.Fatalf("error initializing media: %v", err) diff --git a/cmd/main.go b/cmd/main.go index 496e2d5b..bc2f4149 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -203,9 +203,13 @@ func main() { conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, settings, csat, automation, template, webhook) autoassigner = initAutoAssigner(team, user, conversation) ) + + wsHub.SetConversationStore(conversation) automation.SetConversationStore(conversation) + // Start inboxes. startInboxes(ctx, inbox, conversation, user) + go automation.Run(ctx, automationWorkers) go autoassigner.Run(ctx, autoAssignInterval) go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval) diff --git a/cmd/media.go b/cmd/media.go index f953d5de..f3764649 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -143,45 +143,51 @@ func handleMediaUpload(r *fastglue.Request) error { } // handleServeMedia serves uploaded media. +// Supports both authenticated agent access and unauthenticated access via signed URLs. func handleServeMedia(r *fastglue.Request) error { var ( - app = r.Context.(*App) - auser = r.RequestCtx.UserValue("user").(amodels.User) - uuid = r.RequestCtx.UserValue("uuid").(string) + app = r.Context.(*App) + uuid = r.RequestCtx.UserValue("uuid").(string) ) - user, err := app.user.GetAgent(auser.ID, "") - if err != nil { - return sendErrorEnvelope(r, err) - } - - // Fetch media from DB. - media, err := app.media.Get(0, strings.TrimPrefix(uuid, thumbPrefix)) - if err != nil { - return sendErrorEnvelope(r, err) - } - - // Check if the user has permission to access the linked model. - allowed, err := app.authz.EnforceMediaAccess(user, media.Model.String) - if err != nil { - return sendErrorEnvelope(r, err) - } + // Check if user is authenticated (agent access) + auser := r.RequestCtx.UserValue("user") + if auser != nil { + // Authenticated. + user, err := app.user.GetAgent(auser.(amodels.User).ID, "") + if err != nil { + return sendErrorEnvelope(r, err) + } - // For messages, check access to the conversation this message is part of. - if media.Model.String == "messages" { - conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int) + // Fetch media from DB. + media, err := app.media.Get(0, strings.TrimPrefix(uuid, thumbPrefix)) if err != nil { return sendErrorEnvelope(r, err) } - allowed, err = app.authz.EnforceConversationAccess(user, conversation) + + // Check if the user has permission to access the linked model. + allowed, err := app.authz.EnforceMediaAccess(user, media.Model.String) if err != nil { return sendErrorEnvelope(r, err) } - } - if !allowed { - return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.UnauthorizedError) + // For messages, check access to the conversation this message is part of. + if media.Model.String == "messages" { + conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int) + if err != nil { + return sendErrorEnvelope(r, err) + } + allowed, err = app.authz.EnforceConversationAccess(user, conversation) + if err != nil { + return sendErrorEnvelope(r, err) + } + } + + if !allowed { + return r.SendErrorEnvelope(http.StatusUnauthorized, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.UnauthorizedError) + } } + // If no authenticated user, the middleware has already verified the request signature serve the file. consts := app.consts.Load().(*constants) switch consts.UploadProvider { case "fs": diff --git a/cmd/messages.go b/cmd/messages.go index 9571bd7d..02fb75bd 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -53,10 +53,11 @@ func handleGetMessages(r *fastglue.Request) error { 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() } + // Process CSAT status for all messages (will only affect CSAT messages) + app.conversation.ProcessCSATStatus(messages) + return r.SendEnvelope(envelope.PageResults{ Total: total, Results: messages, @@ -90,8 +91,10 @@ func handleGetMessage(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - // Redact CSAT survey link - message.CensorCSATContent() + // Process CSAT status for the message (will only affect CSAT messages) + messages := []cmodels.Message{message} + app.conversation.ProcessCSATStatus(messages) + message = messages[0] for j := range message.Attachments { message.Attachments[j].URL = app.media.GetURL(message.Attachments[j].UUID) diff --git a/cmd/middlewares.go b/cmd/middlewares.go index b56ca5c4..4b6d9ae2 100644 --- a/cmd/middlewares.go +++ b/cmd/middlewares.go @@ -97,6 +97,23 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler { return func(r *fastglue.Request) error { var app = r.Context.(*App) + // For media uploads, check if signature is provided in the query parameters, if so, verify it. + path := string(r.RequestCtx.Path()) + if strings.HasPrefix(path, "/uploads/") { + signature := string(r.RequestCtx.QueryArgs().Peek("signature")) + expires := string(r.RequestCtx.QueryArgs().Peek("expires")) + + if signature != "" && expires != "" { + if err := app.media.VerifySignature(r); err != nil { + app.lo.Error("error verifying media signature", "error", + err, "path", string(r.RequestCtx.Path()), "query", string(r.RequestCtx.QueryArgs().QueryString())) + return r.SendErrorEnvelope(http.StatusUnauthorized, "signature verification failed", nil, envelope.PermissionError) + } + return handler(r) + } + // If no signature, continue with normal authentication. + } + // Authenticate user using shared authentication logic user, err := authenticateUser(r, app) if err != nil { diff --git a/cmd/widget_middleware.go b/cmd/widget_middleware.go new file mode 100644 index 00000000..1adf8143 --- /dev/null +++ b/cmd/widget_middleware.go @@ -0,0 +1,106 @@ +package main + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/httputil" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" + "github.com/zerodha/fastglue" +) + +type inboxReq struct { + InboxID int `json:"inbox_id"` +} + +// widgetOrigin middleware validates the Origin header against trusted domains configured in the live chat inbox settings. +func widgetOrigin(next fastglue.FastRequestHandler) fastglue.FastRequestHandler { + return func(r *fastglue.Request) error { + app := r.Context.(*App) + + // Get the Origin header from the request + origin := string(r.RequestCtx.Request.Header.Peek("Origin")) + + // If no origin header is present, allow direct access. + if origin == "" { + return next(r) + } + + // Extract inbox ID from request + var inboxID int + + // Search for inbox_id in query parameters first. + if qInboxID := r.RequestCtx.QueryArgs().GetUintOrZero("inbox_id"); qInboxID > 0 { + inboxID = qInboxID + } else { + // For POST/PUT requests, try to decode the body to get `inbox_id`, every request sends it. + if r.RequestCtx.IsPost() || r.RequestCtx.IsPut() { + inboxReq := inboxReq{} + bodyBytes := r.RequestCtx.Request.Body() + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, &inboxReq); err == nil { + inboxID = inboxReq.InboxID + } + } + } + + // Check multipart form data for `inbox_id` if not found in query or body. + if inboxID == 0 { + if r.RequestCtx.Request.Header.IsPost() { + form, err := r.RequestCtx.MultipartForm() + if err == nil && form != nil && form.Value != nil { + if inboxIDValues, exists := form.Value["inbox_id"]; exists && len(inboxIDValues) > 0 { + if parsedID, parseErr := strconv.Atoi(inboxIDValues[0]); parseErr == nil && parsedID > 0 { + inboxID = parsedID + } + } + } + } + } + } + + // If we can't determine the inbox ID, disallow the request + if inboxID <= 0 { + app.lo.Warn("widget request without inbox_id blocked", "path", string(r.RequestCtx.Path())) + return r.SendErrorEnvelope(http.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.inbox}"), nil, envelope.InputError) + } + + // Get inbox configuration + inbox, err := app.inbox.GetDBRecord(inboxID) + if err != nil { + app.lo.Error("error fetching inbox for origin check", "inbox_id", inboxID, "error", err) + return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.NotFoundError) + } + + if !inbox.Enabled { + return r.SendErrorEnvelope(http.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError) + } + + // Parse the live chat config + var config livechat.Config + if err := json.Unmarshal(inbox.Config, &config); err != nil { + app.lo.Error("error parsing live chat config for origin check", "error", err) + return r.SendErrorEnvelope(http.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError) + } + + // If trusted domains list is empty, allow all origins + if len(config.TrustedDomains) == 0 { + return next(r) + } + + // Check if the origin matches any of the trusted domains + if !httputil.IsOriginTrusted(origin, config.TrustedDomains) { + app.lo.Warn("widget request from untrusted origin blocked", + "origin", origin, + "inbox_id", inboxID, + "trusted_domains", config.TrustedDomains) + return r.SendErrorEnvelope(http.StatusForbidden, "Widget not allowed from this origin: "+origin, nil, envelope.PermissionError) + } + + app.lo.Debug("widget request from trusted origin allowed", "origin", origin, "inbox_id", inboxID) + + return next(r) + } +} diff --git a/cmd/widget_ws.go b/cmd/widget_ws.go index ce917dae..170b79e2 100644 --- a/cmd/widget_ws.go +++ b/cmd/widget_ws.go @@ -24,14 +24,13 @@ const ( // WidgetMessage represents a message sent through the widget WebSocket type WidgetMessage struct { - Type string `json:"type"` - JWT string `json:"jwt,omitempty"` - Data interface{} `json:"data"` + Type string `json:"type"` + JWT string `json:"jwt,omitempty"` + Data any `json:"data"` } -// WidgetJoinData represents data for joining a conversation -type WidgetJoinData struct { - ConversationUUID string `json:"conversation_uuid"` +type WidgetInboxJoinRequest struct { + InboxID int `json:"inbox_id"` } // WidgetMessageData represents a chat message through the widget @@ -91,11 +90,11 @@ func handleWidgetWS(r *fastglue.Request) error { } switch msg.Type { - // Join conversation request. + // Inbox join request. case WidgetMsgTypeJoin: var joinedClient *livechat.Client var joinedLiveChat *livechat.LiveChat - if joinedClient, joinedLiveChat, err = handleWidgetJoin(app, conn, &msg, claims); err != nil { + if joinedClient, joinedLiveChat, err = handleInboxJoin(app, conn, &msg, claims); err != nil { app.lo.Error("error handling widget join", "error", err) sendWidgetError(conn, "Failed to join conversation") continue @@ -124,8 +123,8 @@ func handleWidgetWS(r *fastglue.Request) error { return nil } -// handleWidgetJoin handles a client joining a conversation -func handleWidgetJoin(app *App, conn *websocket.Conn, msg *WidgetMessage, claims Claims) (*livechat.Client, *livechat.LiveChat, error) { +// handleInboxJoin handles a websocket join request for a live chat inbox. +func handleInboxJoin(app *App, conn *websocket.Conn, msg *WidgetMessage, claims Claims) (*livechat.Client, *livechat.LiveChat, error) { userID := claims.UserID joinDataBytes, err := json.Marshal(msg.Data) @@ -133,28 +132,16 @@ func handleWidgetJoin(app *App, conn *websocket.Conn, msg *WidgetMessage, claims return nil, nil, fmt.Errorf("invalid join data: %w", err) } - var joinData WidgetJoinData + var joinData WidgetInboxJoinRequest if err := json.Unmarshal(joinDataBytes, &joinData); err != nil { return nil, nil, fmt.Errorf("invalid join data format: %w", err) } - // Get conversation to find the inbox - conversation, err := app.conversation.GetConversation(0, joinData.ConversationUUID) - if err != nil { - return nil, nil, fmt.Errorf("conversation not found: %w", err) - } - - // Make sure conversation belongs to the user. - if conversation.ContactID != userID { - return nil, nil, fmt.Errorf("conversation does not belong to the user") - } - // Make sure inbox is active. - inbox, err := app.inbox.GetDBRecord(conversation.InboxID) + inbox, err := app.inbox.GetDBRecord(joinData.InboxID) if err != nil { return nil, nil, fmt.Errorf("inbox not found: %w", err) } - if !inbox.Enabled { return nil, nil, fmt.Errorf("inbox is not enabled") } @@ -173,9 +160,9 @@ func handleWidgetJoin(app *App, conn *websocket.Conn, msg *WidgetMessage, claims // Add client to live chat session userIDStr := fmt.Sprintf("%d", userID) - client, err := liveChat.AddClient(userIDStr, joinData.ConversationUUID) + client, err := liveChat.AddClient(userIDStr) if err != nil { - app.lo.Error("error adding client to live chat", "error", err, "user_id", userIDStr, "conversation_uuid", joinData.ConversationUUID) + app.lo.Error("error adding client to live chat", "error", err, "user_id", userIDStr) return nil, nil, err } @@ -193,8 +180,7 @@ func handleWidgetJoin(app *App, conn *websocket.Conn, msg *WidgetMessage, claims joinResp := WidgetMessage{ Type: WidgetMsgTypeJoined, Data: map[string]string{ - "message": "Joined conversation successfully", - "conversation_uuid": joinData.ConversationUUID, + "message": "namaste!", }, } @@ -202,6 +188,8 @@ func handleWidgetJoin(app *App, conn *websocket.Conn, msg *WidgetMessage, claims return nil, nil, err } + app.lo.Debug("widget client joined live chat", "user_id", userIDStr, "inbox_id", joinData.InboxID) + return client, liveChat, nil } @@ -219,8 +207,14 @@ func handleWidgetTyping(app *App, msg *WidgetMessage, claims Claims) error { app.lo.Error("error unmarshalling typing data", "error", err) return fmt.Errorf("invalid typing data format: %w", err) } - // Broadcast this to agents. - app.lo.Debug("Received typing data for user", "user_id", userID, "is_typing", typingData.IsTyping, "conversation_uuid", typingData.ConversationUUID) + + // Broadcast typing status to agents via conversation manager + // Set broadcastToWidgets=false to avoid echoing back to widget clients + if typingData.ConversationUUID != "" { + app.conversation.BroadcastTypingToConversation(typingData.ConversationUUID, typingData.IsTyping, false) + } + + app.lo.Debug("Broadcasted typing data from widget user to agents", "user_id", userID, "is_typing", typingData.IsTyping, "conversation_uuid", typingData.ConversationUUID) return nil } diff --git a/frontend/apps/main/src/components/editor/TextEditor.vue b/frontend/apps/main/src/components/editor/TextEditor.vue index d67356b7..85ebd501 100644 --- a/frontend/apps/main/src/components/editor/TextEditor.vue +++ b/frontend/apps/main/src/components/editor/TextEditor.vue @@ -118,6 +118,8 @@ import Table from '@tiptap/extension-table' import TableRow from '@tiptap/extension-table-row' import TableCell from '@tiptap/extension-table-cell' import TableHeader from '@tiptap/extension-table-header' +import { useTypingIndicator } from '@shared-ui/composables' +import { useConversationStore } from '@main/stores/conversation' const textContent = defineModel('textContent', { default: '' }) const htmlContent = defineModel('htmlContent', { default: '' }) @@ -141,6 +143,10 @@ const emit = defineEmits(['send', 'aiPromptSelected']) const emitPrompt = (key) => emit('aiPromptSelected', key) +// Set up typing indicator +const conversationStore = useConversationStore() +const { startTyping, stopTyping } = useTypingIndicator(conversationStore.sendTyping) + // To preseve the table styling in emails, need to set the table style inline. // Created these custom extensions to set the table style inline. const CustomTable = Table.extend({ @@ -201,6 +207,8 @@ const editor = useEditor({ handleKeyDown: (view, event) => { if (event.ctrlKey && event.key === 'Enter') { emit('send') + // Stop typing when sending + stopTyping() return true } } @@ -211,6 +219,13 @@ const editor = useEditor({ htmlContent.value = editor.getHTML() textContent.value = editor.getText() isInternalUpdate.value = false + + // Trigger typing indicator when user types + startTyping() + }, + onBlur: () => { + // Stop typing when editor loses focus + stopTyping() } }) diff --git a/frontend/apps/main/src/constants/websocket.js b/frontend/apps/main/src/constants/websocket.js index e6374cc1..3d20cd48 100644 --- a/frontend/apps/main/src/constants/websocket.js +++ b/frontend/apps/main/src/constants/websocket.js @@ -2,4 +2,12 @@ export const WS_EVENT = { NEW_MESSAGE: 'new_message', MESSAGE_PROP_UPDATE: 'message_prop_update', CONVERSATION_PROP_UPDATE: 'conversation_prop_update', -} \ No newline at end of file + CONVERSATION_SUBSCRIBE: 'conversation_subscribe', + CONVERSATION_SUBSCRIBED: 'conversation_subscribed', + TYPING: 'typing', +} + +// Message types that should not be queued because they become stale quickly +export const WS_EPHEMERAL_TYPES = [ + WS_EVENT.TYPING, +] \ No newline at end of file diff --git a/frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue b/frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue index 5f83999d..f3271b4e 100644 --- a/frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue +++ b/frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue @@ -1,465 +1,329 @@ + + diff --git a/frontend/apps/widget/src/components/ChatHeader.vue b/frontend/apps/widget/src/components/ChatHeader.vue new file mode 100644 index 00000000..08da583c --- /dev/null +++ b/frontend/apps/widget/src/components/ChatHeader.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/apps/widget/src/components/ChatMessages.vue b/frontend/apps/widget/src/components/ChatMessages.vue new file mode 100644 index 00000000..0152975a --- /dev/null +++ b/frontend/apps/widget/src/components/ChatMessages.vue @@ -0,0 +1,247 @@ + + + diff --git a/frontend/apps/widget/src/components/ChatTitle.vue b/frontend/apps/widget/src/components/ChatTitle.vue new file mode 100644 index 00000000..8f5eebf9 --- /dev/null +++ b/frontend/apps/widget/src/components/ChatTitle.vue @@ -0,0 +1,107 @@ + + + diff --git a/frontend/apps/widget/src/components/CloseWidgetButton.vue b/frontend/apps/widget/src/components/CloseWidgetButton.vue new file mode 100644 index 00000000..0e7d888b --- /dev/null +++ b/frontend/apps/widget/src/components/CloseWidgetButton.vue @@ -0,0 +1,28 @@ + + + diff --git a/frontend/apps/widget/src/components/HomeExternalLinks.vue b/frontend/apps/widget/src/components/HomeExternalLinks.vue new file mode 100644 index 00000000..dba02948 --- /dev/null +++ b/frontend/apps/widget/src/components/HomeExternalLinks.vue @@ -0,0 +1,34 @@ + + + diff --git a/frontend/apps/widget/src/components/HomeHeader.vue b/frontend/apps/widget/src/components/HomeHeader.vue new file mode 100644 index 00000000..4197468c --- /dev/null +++ b/frontend/apps/widget/src/components/HomeHeader.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/apps/widget/src/components/MessageAttachment.vue b/frontend/apps/widget/src/components/MessageAttachment.vue index 711036c7..f915ec5e 100644 --- a/frontend/apps/widget/src/components/MessageAttachment.vue +++ b/frontend/apps/widget/src/components/MessageAttachment.vue @@ -1,5 +1,5 @@ + + diff --git a/frontend/apps/widget/src/components/MessagesList.vue b/frontend/apps/widget/src/components/MessagesList.vue new file mode 100644 index 00000000..573637f2 --- /dev/null +++ b/frontend/apps/widget/src/components/MessagesList.vue @@ -0,0 +1,89 @@ + + + diff --git a/frontend/apps/widget/src/components/RecentConversationCard.vue b/frontend/apps/widget/src/components/RecentConversationCard.vue new file mode 100644 index 00000000..77c5d488 --- /dev/null +++ b/frontend/apps/widget/src/components/RecentConversationCard.vue @@ -0,0 +1,69 @@ + + + diff --git a/frontend/apps/widget/src/components/UnreadCountBadge.vue b/frontend/apps/widget/src/components/UnreadCountBadge.vue new file mode 100644 index 00000000..edc2828e --- /dev/null +++ b/frontend/apps/widget/src/components/UnreadCountBadge.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/apps/widget/src/components/WidgetError.vue b/frontend/apps/widget/src/components/WidgetError.vue index d08085b8..a42259e4 100644 --- a/frontend/apps/widget/src/components/WidgetError.vue +++ b/frontend/apps/widget/src/components/WidgetError.vue @@ -1,5 +1,5 @@ diff --git a/frontend/apps/widget/src/composables/useBusinessHours.js b/frontend/apps/widget/src/composables/useBusinessHours.js new file mode 100644 index 00000000..94b30571 --- /dev/null +++ b/frontend/apps/widget/src/composables/useBusinessHours.js @@ -0,0 +1,228 @@ +import { format, isToday, isTomorrow, addDays, setHours, setMinutes } from 'date-fns' + +/** + * Business hours composable providing generic business hours utilities. + */ +export function useBusinessHours() { + + /** + * Get the business hours by ID from a list + * @param {number} businessHoursId - Business hours ID + * @param {Array} businessHoursList - List of business hours objects + * @returns {Object|null} Business hours object or null + */ + function getBusinessHoursById(businessHoursId, businessHoursList) { + if (!businessHoursId || !businessHoursList) { + return null + } + + return businessHoursList.find(bh => bh.id === businessHoursId) + } + + /** + * Determine which business hours to use based on configuration + * @param {Object} options - Configuration options + * @param {boolean} options.showOfficeHours - Whether to show office hours + * @param {boolean} options.showAfterAssignment - Whether to show team hours after assignment + * @param {number|null} options.assignedBusinessHoursId - Business hours ID from assignment + * @param {number|null} options.defaultBusinessHoursId - Default business hours ID + * @param {Array} options.businessHoursList - List of available business hours + * @returns {Object|null} Business hours object or null + */ + function resolveBusinessHours(options) { + const { + showOfficeHours, + showAfterAssignment, + assignedBusinessHoursId, + defaultBusinessHoursId, + businessHoursList + } = options + + if (!showOfficeHours) { + return null + } + + let businessHoursId = null + + // Check if we should use assigned business hours + if (showAfterAssignment && assignedBusinessHoursId) { + businessHoursId = assignedBusinessHoursId + } else if (defaultBusinessHoursId) { + // Fallback to default business hours + businessHoursId = parseInt(defaultBusinessHoursId) + } + + return getBusinessHoursById(businessHoursId, businessHoursList) + } + + /** + * Check if a given time is within business hours + * @param {Object} businessHours - Business hours object + * @param {Date} date - Date to check + * @param {number} utcOffset - UTC offset in minutes + * @returns {boolean} True if within business hours + */ + function isWithinBusinessHours(businessHours, date, utcOffset = 0) { + if (!businessHours || businessHours.is_always_open) { + return true + } + + // Convert to business timezone + const localDate = new Date(date.getTime() + (utcOffset * 60000)) + + // Check if it's a holiday + if (isHoliday(businessHours, localDate)) { + return false + } + + const dayName = getDayName(localDate.getDay()) + const schedule = businessHours.hours[dayName] + + if (!schedule || !schedule.open || !schedule.close) { + return false + } + + // Check if open and close times are the same (closed day) + if (schedule.open === schedule.close) { + return false + } + + const currentTime = format(localDate, 'HH:mm') + return currentTime >= schedule.open && currentTime <= schedule.close + } + + /** + * Check if a date is a holiday + * @param {Object} businessHours - Business hours object + * @param {Date} date - Date to check + * @returns {boolean} True if it's a holiday + */ + function isHoliday(businessHours, date) { + if (!businessHours.holidays || businessHours.holidays.length === 0) { + return false + } + const dateStr = format(date, 'yyyy-MM-dd') + return businessHours.holidays.some(holiday => holiday.date === dateStr) + } + + /** + * Get the next working time + * @param {Object} businessHours - Business hours object + * @param {Date} fromDate - Date to start from + * @param {number} utcOffset - UTC offset in minutes + * @returns {Date|null} Next working time or null + */ + function getNextWorkingTime(businessHours, fromDate, utcOffset = 0) { + if (!businessHours || businessHours.is_always_open) { + return fromDate + } + + // Check up to 14 days ahead + for (let i = 0; i < 14; i++) { + const checkDate = addDays(fromDate, i) + const localDate = new Date(checkDate.getTime() + (utcOffset * 60000)) + + // Skip holidays + if (isHoliday(businessHours, localDate)) { + continue + } + + const dayName = getDayName(localDate.getDay()) + const schedule = businessHours.hours[dayName] + + if (!schedule || !schedule.open || !schedule.close || schedule.open === schedule.close) { + continue + } + + // Parse opening time + const [openHour, openMinute] = schedule.open.split(':').map(Number) + let nextWorking = setMinutes(setHours(localDate, openHour), openMinute) + + // If it's the same day and current time is before opening time + if (i === 0) { + const currentTime = format(localDate, 'HH:mm') + if (currentTime < schedule.open) { + // Convert back from business timezone to user timezone + return new Date(nextWorking.getTime() - (utcOffset * 60000)) + } + // If it's the same day but past opening time, continue to next day + continue + } + + // For future days, return the opening time + // Convert back from business timezone to user timezone + return new Date(nextWorking.getTime() - (utcOffset * 60000)) + } + + return null + } + + /** + * Get day name from day number + * @param {number} dayNum - Day number (0 = Sunday, 1 = Monday, etc.) + * @returns {string} Day name + */ + function getDayName(dayNum) { + const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] + return days[dayNum] + } + + /** + * Format the next working time for display + * @param {Date} nextWorkingTime - Next working time + * @returns {string} Formatted string + */ + function formatNextWorkingTime(nextWorkingTime) { + if (!nextWorkingTime) { + return '' + } + + if (isToday(nextWorkingTime)) { + return `today at ${format(nextWorkingTime, 'h:mm a')}` + } else if (isTomorrow(nextWorkingTime)) { + return `tomorrow at ${format(nextWorkingTime, 'h:mm a')}` + } else { + return `on ${format(nextWorkingTime, 'EEEE')} at ${format(nextWorkingTime, 'h:mm a')}` + } + } + /** + * Get business hours status message and whether it's within business hours + * @param {Object} businessHours - Business hours object + * @param {number} utcOffset - UTC offset in minutes + * @param {string} withinHoursMessage - Message to show when within hours + * @returns {Object|null} { status: string|null, isWithin: boolean } or null + */ + function getBusinessHoursStatus(businessHours, utcOffset = 0, withinHoursMessage = '') { + if (!businessHours) { + return null + } + + const now = new Date() + const within = isWithinBusinessHours(businessHours, now, utcOffset) + + let status = null + if (within) { + status = withinHoursMessage + } else { + const nextWorkingTime = getNextWorkingTime(businessHours, now, utcOffset) + if (nextWorkingTime) { + status = `We'll be back ${formatNextWorkingTime(nextWorkingTime)}` + } else { + status = 'We are currently offline' + } + } + + return { status, isWithin: within } + } + + return { + getBusinessHoursById, + resolveBusinessHours, + isWithinBusinessHours, + getNextWorkingTime, + formatNextWorkingTime, + getBusinessHoursStatus, + isHoliday, + getDayName + } +} diff --git a/frontend/apps/widget/src/composables/useRelativeTime.js b/frontend/apps/widget/src/composables/useRelativeTime.js new file mode 100644 index 00000000..91a60b66 --- /dev/null +++ b/frontend/apps/widget/src/composables/useRelativeTime.js @@ -0,0 +1,13 @@ +import { ref } from 'vue' +import { useIntervalFn } from '@vueuse/core' +import { getRelativeTime } from '@shared-ui/utils/datetime.js' + +export function useRelativeTime (timestamp) { + const relativeTime = ref(getRelativeTime(timestamp)) + + useIntervalFn(() => { + relativeTime.value = getRelativeTime(timestamp) + }, 60000) + + return relativeTime +} \ No newline at end of file diff --git a/frontend/apps/widget/src/composables/useUnreadCount.js b/frontend/apps/widget/src/composables/useUnreadCount.js new file mode 100644 index 00000000..e1e2eba1 --- /dev/null +++ b/frontend/apps/widget/src/composables/useUnreadCount.js @@ -0,0 +1,40 @@ +import { computed, watch } from 'vue' +import { useChatStore } from '@widget/store/chat.js' + +export function useUnreadCount() { + const chatStore = useChatStore() + + // Calculate total unread messages across all conversations. + const totalUnreadCount = computed(() => { + const conversations = chatStore.getConversations + if (!conversations || conversations.length === 0) return 0 + + return conversations.reduce((total, conversation) => { + return total + (conversation.unread_message_count || 0) + }, 0) + }) + + // Send unread count to parent widget. + const sendUnreadCountToWidget = (count) => { + try { + if (window.parent && window.parent !== window) { + window.parent.postMessage({ + type: 'UPDATE_UNREAD_COUNT', + count: count + }, '*') + } + } catch (error) { + console.error('Failed to send unread count to widget:', error) + } + } + + // Watch for changes in unread count and notify the widget. + watch(totalUnreadCount, (newCount) => { + sendUnreadCountToWidget(newCount) + }, { immediate: true }) + + return { + totalUnreadCount, + sendUnreadCountToWidget + } +} diff --git a/frontend/apps/widget/src/layouts/MainLayout.vue b/frontend/apps/widget/src/layouts/MainLayout.vue index 4ca23df3..f6e1aadd 100644 --- a/frontend/apps/widget/src/layouts/MainLayout.vue +++ b/frontend/apps/widget/src/layouts/MainLayout.vue @@ -10,17 +10,17 @@
- - + + Home - + Messages -
+
Powered by Libredesk diff --git a/frontend/apps/widget/src/layouts/WidgetHeader.vue b/frontend/apps/widget/src/layouts/WidgetHeader.vue new file mode 100644 index 00000000..4cacc7dd --- /dev/null +++ b/frontend/apps/widget/src/layouts/WidgetHeader.vue @@ -0,0 +1,13 @@ + + diff --git a/frontend/apps/widget/src/main.js b/frontend/apps/widget/src/main.js index a951d834..2764b577 100644 --- a/frontend/apps/widget/src/main.js +++ b/frontend/apps/widget/src/main.js @@ -1,10 +1,57 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' +import { createI18n } from 'vue-i18n' import App from './App.vue' +import api from './api/index.js' import '@shared-ui/assets/styles/main.scss' import './assets/widget.css' -const app = createApp(App) -const pinia = createPinia() -app.use(pinia) -app.mount('#app') +async function initWidget () { + try { + // Get `inbox_id` from URL params + const urlParams = new URLSearchParams(window.location.search) + const inboxID = urlParams.get('inbox_id') + + if (!inboxID) { + throw new Error('`inbox_id` is missing in query parameters') + } + + // Fetch widget settings to get language config + const widgetSettingsResponse = await api.getWidgetSettings(inboxID) + const widgetConfig = widgetSettingsResponse.data.data + + // Get language from config or default to 'en' + const lang = widgetConfig.language || 'en' + + // Fetch language messages + const langMessages = await api.getLanguage(lang) + + // Initialize i18n + const i18nConfig = { + legacy: false, + locale: lang, + fallbackLocale: 'en', + messages: { + [lang]: langMessages.data + } + } + + const i18n = createI18n(i18nConfig) + const app = createApp(App) + const pinia = createPinia() + + app.use(pinia) + app.use(i18n) + // Store widget config globally for access in App.vue + app.config.globalProperties.$widgetConfig = widgetConfig + app.mount('#app') + } catch (error) { + console.error('Error initializing widget:', error) + const app = createApp(App) + const pinia = createPinia() + app.use(pinia) + app.mount('#app') + } +} + +initWidget() diff --git a/frontend/apps/widget/src/store/chat.js b/frontend/apps/widget/src/store/chat.js index 12dc8448..f04ec9ea 100644 --- a/frontend/apps/widget/src/store/chat.js +++ b/frontend/apps/widget/src/store/chat.js @@ -1,6 +1,5 @@ import { defineStore } from 'pinia' import { ref, computed, reactive } from 'vue' -import { initWidgetWS } from '../websocket.js' import api from '../api/index.js' import MessageCache from '@main/utils/conversation-message-cache.js' import { useUserStore } from './user.js' @@ -14,75 +13,121 @@ export const useChatStore = defineStore('chat', () => { // Conversation messages cache, evict old conversation messages after 50 conversations. const messageCache = reactive(new MessageCache(50)) const isLoadingConversations = ref(false) + // Reactivity trigger for message cache changes this is easier than making the whole messageCache reactive. + const messageCacheVersion = ref(0) // Getters - const getCurrentConversationMessages = () => { + const getCurrentConversationMessages = computed(() => { + messageCacheVersion.value // Force reactivity tracking const convId = currentConversation.value?.uuid if (!convId) return [] return messageCache.getAllPagesMessages(convId) - } + }) const hasConversations = computed(() => conversations.value?.length > 0) const getConversations = computed(() => { - // Sort by `last_message_at` descending. + // Sort by `last_message.created_at` descending. if (conversations.value) { - return conversations.value.sort((a, b) => new Date(b.last_message_at) - new Date(a.last_message_at)) + return conversations.value.sort((a, b) => new Date(b.last_message.created_at) - new Date(a.last_message.created_at)) } return [] }) - // Actions - const addMessageToConversation = (conversationUUID, message) => { - messageCache.addMessage(conversationUUID, message) - // Update `last_message` for the conversations list. + const updateConversationListLastMessage = (conversationUUID, message, incrementUnread = false) => { + if (!conversations.value || !Array.isArray(conversations.value)) return + + // Find conversation in the list const conv = conversations.value.find(c => c.uuid === conversationUUID) - if (conv) { - conv.last_message = message.text_content - conv.last_message_at = message.created_at + if (!conv) return + + // Update last_message in the conversation + conv.last_message = { + content: message.text_content !== '' ? message.text_content : message.content, + created_at: message.created_at, + status: message.status, + author: { + id: message.author.id, + first_name: message.author.first_name || '', + last_name: message.author.last_name || '', + avatar_url: message.author.avatar_url || '', + availability_status: message.author.availability_status || '', + type: message.author.type || '', + active_at: message.author.active_at || null + } + } + + // Increment unread count if needed + if (incrementUnread) { + conv.unread_message_count = (conv.unread_message_count || 0) + 1 } } - const fetchCurrentConversation = async () => { - const conversationUUID = currentConversation.value?.uuid - if (!conversationUUID) return + const addMessageToConversation = (conversationUUID, message) => { + messageCache.addMessage(conversationUUID, message) + messageCacheVersion.value++ // Trigger reactivity + // Check if we should increment unread count (message from other user) + const shouldIncrementUnread = message.author.id !== userStore.userID + updateConversationListLastMessage(conversationUUID, message, shouldIncrementUnread) + } - // Set unread message count to 0 for the current conversation - const conv = conversations.value.find(c => c.uuid === conversationUUID) - if (conv) { - conv.unread_message_count = 0 + const addPendingMessage = (conversationUUID, messageText, authorType, authorId) => { + // Pending message is a temporary message that will be replaced with actual message later after sending. + const pendingMessage = { + content: messageText, + author: { + type: authorType, + id: authorId, + first_name: userStore.firstName || '', + last_name: userStore.lastName || '', + avatar_url: userStore.avatarUrl || '', + availability_status: '', + active_at: null + }, + attachments: [], + uuid: `pending-${Date.now()}`, + status: 'sending', + created_at: new Date().toISOString() } + messageCache.addMessage(conversationUUID, pendingMessage) + messageCacheVersion.value++ // Trigger reactivity - // If messages are already loaded, do nothing. - if (messageCache.hasConversation(conversationUUID)) { - return - } + // Update conversations list with pending message + updateConversationListLastMessage(conversationUUID, pendingMessage) - // Fetch entire conversation and replace messages and conversation data - try { - const resp = await api.getChatConversation(conversationUUID) - replaceMessages(resp.data.data.messages) - currentConversation.value = resp.data.data.conversation - } catch (error) { - console.error('Error fetching conversation:', error) - } + return pendingMessage.uuid } - const fetchAndReplaceConversationAndMessages = async () => { - const conversationUUID = currentConversation.value?.uuid - if (!conversationUUID) return - // Fetch entire conversation and replace messages + const replaceMessage = (conversationUUID, msgID, actualMessage) => { + messageCache.updateMessage(conversationUUID, msgID, actualMessage) + messageCacheVersion.value++ // Trigger reactivity + updateConversationListLastMessage(conversationUUID, actualMessage) + } + + const removeMessage = (conversationUUID, msgID) => { + messageCache.removeMessage(conversationUUID, msgID) + messageCacheVersion.value++ // Trigger reactivity + } + + const loadConversation = async (conversationUUID, force = false) => { + if (!conversationUUID) return false + + // If the conversation is already loaded, do not fetch again unless forced. + if (currentConversation.value?.uuid === conversationUUID && !force) { + return true + } + try { const resp = await api.getChatConversation(conversationUUID) + setCurrentConversation(resp.data.data.conversation) replaceMessages(resp.data.data.messages) currentConversation.value = resp.data.data.conversation - // Set last message for the conversation - const conv = conversations.value.find(c => c.uuid === conversationUUID) - if (conv) { - conv.last_message = resp.data.data.messages[0]?.content || '' - conv.last_message_at = resp.data.data.messages[0]?.created_at || new Date().toISOString() + if (resp.data.data.messages.length > 0) { + updateConversationListLastMessage(conversationUUID, resp.data.data.messages[0], false) } } catch (error) { console.error('Error fetching conversation:', error) + return false } + return true } const replaceMessages = (newMessages) => { @@ -93,16 +138,22 @@ export const useChatStore = defineStore('chat', () => { messageCache.purgeConversation(convId) messageCache.addMessages(convId, newMessages, 1, 1) } + messageCacheVersion.value++ // Trigger reactivity } const clearMessages = () => { const convId = currentConversation.value?.uuid if (!convId) return - // Clear messages for current conversation by setting empty array. + // Clear messages for current conversation by setting empty values. messageCache.addMessages(convId, [], 1, 1) + messageCacheVersion.value++ // Trigger reactivity } - const setTypingStatus = (status) => { + const setTypingStatus = (conversationUUID, status) => { + if (!conversationUUID) return + if (currentConversation.value?.uuid !== conversationUUID) { + return + } isTyping.value = status } @@ -110,27 +161,22 @@ export const useChatStore = defineStore('chat', () => { if (conversation === null) { conversation = {} } - currentConversation.value = conversation - } - - const openConversation = (conversation) => { - // Set the current conversation - setCurrentConversation(conversation) - - // Init WebSocket connection if not already initialized. - const jwt = userStore.userSessionToken - if (jwt) { - initWidgetWS(jwt) + // Clear messages if conversation is null or empty. + if (!conversation) { + clearMessages() } + currentConversation.value = conversation } const fetchConversations = async () => { + // No session token means no conversations can be fetched simply return empty. if (!userStore.userSessionToken) { conversations.value = [] return } - if (conversations.value !== null) { + // If conversations are already loaded and is an array, do not fetch again. + if (Array.isArray(conversations.value)) { return } @@ -154,7 +200,15 @@ export const useChatStore = defineStore('chat', () => { const updateCurrentConversationLastSeen = async () => { const conversationUUID = currentConversation.value?.uuid if (!conversationUUID) return - api.updateConversationLastSeen(conversationUUID) + + // Reset unread count for current conversation + if (conversations.value && Array.isArray(conversations.value)) { + const conv = conversations.value.find(c => c.uuid === conversationUUID) + if (conv) { + conv.unread_message_count = 0 + } + } + await api.updateConversationLastSeen(conversationUUID) } return { @@ -172,14 +226,16 @@ export const useChatStore = defineStore('chat', () => { // Actions addMessageToConversation, - fetchCurrentConversation, + addPendingMessage, + replaceMessage, + removeMessage, replaceMessages, clearMessages, setTypingStatus, setCurrentConversation, - openConversation, fetchConversations, - fetchAndReplaceConversationAndMessages, - updateCurrentConversationLastSeen + loadConversation, + updateCurrentConversationLastSeen, + updateConversationListLastMessage } }) diff --git a/frontend/apps/widget/src/store/user.js b/frontend/apps/widget/src/store/user.js index 18268a26..841ad780 100644 --- a/frontend/apps/widget/src/store/user.js +++ b/frontend/apps/widget/src/store/user.js @@ -14,13 +14,29 @@ export const useUserStore = defineStore('user', () => { return jwt.is_visitor }) + const userID = computed(() => { + const token = userSessionToken.value + if (!token) return null + const jwt = parseJWT(token) + return jwt.user_id || null + }) + const clearSessionToken = () => { userSessionToken.value = "" } + const setSessionToken = (token) => { + if (typeof token !== 'string') { + throw new Error('Session token must be a string') + } + userSessionToken.value = token + } + return { userSessionToken, isVisitor, - clearSessionToken + userID, + clearSessionToken, + setSessionToken } }) \ No newline at end of file diff --git a/frontend/apps/widget/src/store/widget.js b/frontend/apps/widget/src/store/widget.js index ec595e86..973f5946 100644 --- a/frontend/apps/widget/src/store/widget.js +++ b/frontend/apps/widget/src/store/widget.js @@ -7,6 +7,7 @@ export const useWidgetStore = defineStore('widget', () => { const currentView = ref('home') const config = ref({}) const isInChatView = ref(false) + const isMobileFullScreen = ref(false) // Getters @@ -49,12 +50,17 @@ export const useWidgetStore = defineStore('widget', () => { config.value = { ...newConfig } } + const setMobileFullScreen = (isMobile) => { + isMobileFullScreen.value = isMobile + } + return { // State isOpen, currentView, config, isInChatView, + isMobileFullScreen, // Getters isHomeView, @@ -69,5 +75,6 @@ export const useWidgetStore = defineStore('widget', () => { navigateToMessages, navigateToHome, updateConfig, + setMobileFullScreen, } }) diff --git a/frontend/apps/widget/src/views/ChatView.vue b/frontend/apps/widget/src/views/ChatView.vue index e8152a92..7c28afeb 100644 --- a/frontend/apps/widget/src/views/ChatView.vue +++ b/frontend/apps/widget/src/views/ChatView.vue @@ -1,402 +1,35 @@ diff --git a/frontend/apps/widget/src/views/HomeView.vue b/frontend/apps/widget/src/views/HomeView.vue index 97a2449d..a70c69d8 100644 --- a/frontend/apps/widget/src/views/HomeView.vue +++ b/frontend/apps/widget/src/views/HomeView.vue @@ -1,97 +1,74 @@ diff --git a/frontend/apps/widget/src/views/MessagesView.vue b/frontend/apps/widget/src/views/MessagesView.vue index b5b9a364..9cdf7b17 100644 --- a/frontend/apps/widget/src/views/MessagesView.vue +++ b/frontend/apps/widget/src/views/MessagesView.vue @@ -1,63 +1,14 @@ diff --git a/frontend/apps/widget/src/websocket.js b/frontend/apps/widget/src/websocket.js index a774f0de..af2aba78 100644 --- a/frontend/apps/widget/src/websocket.js +++ b/frontend/apps/widget/src/websocket.js @@ -1,6 +1,6 @@ -import { useChatStore } from './store/chat' - // Widget WebSocket message types (matching backend constants) +import { useChatStore } from './store/chat.js' + export const WS_EVENT = { JOIN: 'join', MESSAGE: 'message', @@ -23,13 +23,14 @@ export class WidgetWebSocketClient { this.manualClose = false this.pingInterval = null this.lastPong = Date.now() - this.chatStore = useChatStore() this.jwt = null - this.isJoined = false + this.inboxId = null } - init (jwt) { + init (jwt, inboxId) { + this.manualClose = false this.jwt = jwt + this.inboxId = inboxId this.connect() this.setupNetworkListeners() } @@ -50,44 +51,48 @@ export class WidgetWebSocketClient { } handleOpen () { - console.log('Widget WebSocket connected') this.reconnectInterval = 1000 + const wasReconnecting = this.reconnectAttempts > 0 this.reconnectAttempts = 0 this.isReconnecting = false this.lastPong = Date.now() this.setupPing() - // Auto-join conversation after connection if a conversation uuid is set. - if (this.chatStore.currentConversation.uuid && this.jwt && !this.isJoined) { - this.joinConversation() + // Auto-join inbox after connection if inbox_id is set. + if (this.inboxId && this.jwt) { + this.joinInbox() + } + + // If this was a reconnection, sync current conversation messages + if (wasReconnecting) { + this.resyncCurrentConversation() } } handleMessage (event) { + const chatStore = useChatStore() try { if (!event.data) return const data = JSON.parse(event.data) const handlers = { [WS_EVENT.JOINED]: () => { - this.isJoined = true + // Joined inbox. }, [WS_EVENT.PONG]: () => { this.lastPong = Date.now() }, [WS_EVENT.NEW_MESSAGE]: () => { - // Add new message to chat store if (data.data) { - this.chatStore.addMessageToConversation(data.data.conversation_uuid, data.data) + chatStore.addMessageToConversation(data.data.conversation_uuid, data.data) } }, [WS_EVENT.ERROR]: () => { console.error('Widget WebSocket error:', data.data) }, [WS_EVENT.TYPING]: () => { - // TODO: check conversation uuid and then set typing as true. - // if (data.data && data.data.is_typing !== undefined) { - // this.chatStore.setTypingStatus(data.data.is_typing) - // } + if (data.data && data.data.is_typing !== undefined) { + chatStore.setTypingStatus(data.data.conversation_uuid, data.data.is_typing) + } } } const handler = handlers[data.type] @@ -108,7 +113,6 @@ export class WidgetWebSocketClient { handleClose () { this.clearPing() - this.isJoined = false if (!this.manualClose) { this.reconnect() } @@ -169,10 +173,9 @@ export class WidgetWebSocketClient { } } - joinConversation () { - const currentConversationUuid = this.chatStore.currentConversation.uuid - if (!currentConversationUuid || !this.jwt) { - console.error('Cannot join conversation: missing conversationUuid or JWT') + joinInbox () { + if (!this.inboxId || !this.jwt) { + console.error('Cannot join inbox: missing inbox_id or JWT') return } @@ -180,29 +183,31 @@ export class WidgetWebSocketClient { type: WS_EVENT.JOIN, jwt: this.jwt, data: { - conversation_uuid: currentConversationUuid + inbox_id: parseInt(this.inboxId, 10) } } this.send(joinMessage) } - sendTyping (isTyping = true) { - if (!this.isJoined) { - console.warn('Cannot send typing indicator: not joined to conversation') - return + // Resync current conversation after reconnection to catch any missed messages. + resyncCurrentConversation () { + const chatStore = useChatStore() + const currentConversationUUID = chatStore.currentConversation?.uuid + if (currentConversationUUID) { + chatStore.loadConversation(currentConversationUUID) } + } - const currentConversationUUID = this.chatStore.currentConversation.uuid + sendTyping (isTyping = true, conversationUUID = null) { const typingMessage = { type: WS_EVENT.TYPING, jwt: this.jwt, data: { - conversation_uuid: currentConversationUUID, + conversation_uuid: conversationUUID, is_typing: isTyping } } - this.send(typingMessage) } @@ -214,19 +219,8 @@ export class WidgetWebSocketClient { } } - // Method to join a new conversation without reinitializing the connection - joinNewConversation () { - if (this.socket?.readyState === WebSocket.OPEN) { - this.isJoined = false - this.joinConversation() - } else { - console.warn('WebSocket not connected, cannot join new conversation') - } - } - close () { this.manualClose = true - this.isJoined = false this.clearPing() if (this.socket) { this.socket.close() @@ -236,26 +230,25 @@ export class WidgetWebSocketClient { let widgetWSClient -export function initWidgetWS (jwt) { +export function initWidgetWS (jwt, inboxId) { if (!widgetWSClient) { widgetWSClient = new WidgetWebSocketClient() - widgetWSClient.init(jwt) + widgetWSClient.init(jwt, inboxId) } else { - // Update JWT and rejoin if connection exists + // Update JWT and inbox_id and rejoin if connection exists widgetWSClient.jwt = jwt + widgetWSClient.inboxId = inboxId if (widgetWSClient.socket?.readyState === WebSocket.OPEN) { - // Reset joined status and join the new conversation - widgetWSClient.isJoined = false - widgetWSClient.joinConversation() + widgetWSClient.joinInbox() } else { // If connection is not open, reconnect - widgetWSClient.init(jwt) + widgetWSClient.init(jwt, inboxId) } } return widgetWSClient } export const sendWidgetMessage = message => widgetWSClient?.send(message) -export const sendWidgetTyping = (isTyping = true) => widgetWSClient?.sendTyping(isTyping) +export const sendWidgetTyping = (isTyping = true, conversationUUID = null) => widgetWSClient?.sendTyping(isTyping, conversationUUID) export const closeWidgetWebSocket = () => widgetWSClient?.close() -export const joinNewConversation = () => widgetWSClient?.joinNewConversation() +export const reOpenWidgetWebSocket = () => widgetWSClient?.reOpen() diff --git a/frontend/shared-ui/components/ScrollToBottomButton/ScrollToBottomButton.vue b/frontend/shared-ui/components/ScrollToBottomButton/ScrollToBottomButton.vue new file mode 100644 index 00000000..c8427832 --- /dev/null +++ b/frontend/shared-ui/components/ScrollToBottomButton/ScrollToBottomButton.vue @@ -0,0 +1,42 @@ + + + diff --git a/frontend/shared-ui/components/ScrollToBottomButton/index.js b/frontend/shared-ui/components/ScrollToBottomButton/index.js new file mode 100644 index 00000000..bab1c7b6 --- /dev/null +++ b/frontend/shared-ui/components/ScrollToBottomButton/index.js @@ -0,0 +1 @@ +export { default } from './ScrollToBottomButton.vue' diff --git a/frontend/shared-ui/components/TypingIndicator/TypingIndicator.vue b/frontend/shared-ui/components/TypingIndicator/TypingIndicator.vue new file mode 100644 index 00000000..9b49915b --- /dev/null +++ b/frontend/shared-ui/components/TypingIndicator/TypingIndicator.vue @@ -0,0 +1,9 @@ + diff --git a/frontend/shared-ui/components/TypingIndicator/index.js b/frontend/shared-ui/components/TypingIndicator/index.js new file mode 100644 index 00000000..7b2c578f --- /dev/null +++ b/frontend/shared-ui/components/TypingIndicator/index.js @@ -0,0 +1 @@ +export { default as TypingIndicator } from './TypingIndicator.vue' diff --git a/frontend/shared-ui/components/index.js b/frontend/shared-ui/components/index.js deleted file mode 100644 index a05b21db..00000000 --- a/frontend/shared-ui/components/index.js +++ /dev/null @@ -1,14 +0,0 @@ -// Button component exports -export { Button, buttonVariants } from './ui/button' - -// Card component exports -export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './ui/card' - -// Input component exports -export { Input } from './ui/input' - -// Add other component exports as needed -// Example: -// export { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog' -// export { Badge, badgeVariants } from './ui/badge' -// export { Avatar, AvatarImage, AvatarFallback } from './ui/avatar' diff --git a/frontend/shared-ui/composables/index.js b/frontend/shared-ui/composables/index.js new file mode 100644 index 00000000..5c9b4961 --- /dev/null +++ b/frontend/shared-ui/composables/index.js @@ -0,0 +1 @@ +export { useTypingIndicator } from './useTypingIndicator.js' diff --git a/frontend/shared-ui/composables/useTypingIndicator.js b/frontend/shared-ui/composables/useTypingIndicator.js new file mode 100644 index 00000000..810a2734 --- /dev/null +++ b/frontend/shared-ui/composables/useTypingIndicator.js @@ -0,0 +1,46 @@ +import { ref, onUnmounted } from 'vue' + +export function useTypingIndicator(sendTypingCallback) { + const typingTimer = ref(null) + const isCurrentlyTyping = ref(false) + + const startTyping = () => { + if (!isCurrentlyTyping.value) { + isCurrentlyTyping.value = true + sendTypingCallback?.(true) + } + + // Clear existing timer + if (typingTimer.value) { + clearTimeout(typingTimer.value) + } + + // Set timer to stop typing after 2 seconds of inactivity + typingTimer.value = setTimeout(() => { + stopTyping() + }, 2000) + } + + const stopTyping = () => { + if (isCurrentlyTyping.value) { + isCurrentlyTyping.value = false + sendTypingCallback?.(false) + } + + if (typingTimer.value) { + clearTimeout(typingTimer.value) + typingTimer.value = null + } + } + + // Clean up on unmount + onUnmounted(() => { + stopTyping() + }) + + return { + startTyping, + stopTyping, + isCurrentlyTyping + } +} diff --git a/frontend/shared-ui/utils/datetime.js b/frontend/shared-ui/utils/datetime.js index 657e83db..7b59893f 100644 --- a/frontend/shared-ui/utils/datetime.js +++ b/frontend/shared-ui/utils/datetime.js @@ -7,9 +7,9 @@ export function getRelativeTime (timestamp, now = new Date()) { const days = differenceInDays(now, timestamp) if (mins === 0) return 'Just now' - if (mins < 60) return `${mins} mins ago` - if (hours < 24) return `${hours} hrs ago` - if (days < 7) return `${days} days ago` + if (mins < 60) return `${mins}m ago` + if (hours < 24) return `${hours}h ago` + if (days < 7) return `${days}d ago` return format(timestamp, 'MMMM d, yyyy h:mm a') } catch (error) { console.error('Error parsing time', error, 'timestamp', timestamp) diff --git a/i18n/en.json b/i18n/en.json index d7acd44b..95e4b020 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -38,6 +38,7 @@ "globals.terms.dashboard": "Dashboard | Dashboards", "globals.terms.tag": "Tag | Tags", "globals.terms.sla": "SLA | SLAs", + "globals.terms.feedback": "Feedback | Feedbacks", "globals.terms.slaPolicy": "SLA Policy | SLA Policies", "globals.terms.csatSurvey": "CSAT Survey | CSAT Surveys", "globals.terms.csatResponse": "CSAT Response | CSAT Responses", @@ -264,8 +265,6 @@ "globals.messages.type": "{name} type", "globals.messages.typeOf": "Type of {name}", "globals.messages.invalidEmailAddress": "Invalid email address", - "globals.messages.invalidColor": "Invalid color format", - "globals.messages.invalidUrl": "Invalid URL format", "globals.messages.selectAtLeastOne": "Please select at least one {name}", "globals.messages.strongPassword": "Password must be between {min} and {max} characters long, should contain at least one uppercase letter, one lowercase letter, one number, and one special character.", "globals.messages.couldNotReload": "Could not reload {name}", @@ -276,6 +275,7 @@ "globals.messages.notFound": "{name} not found", "globals.messages.empty": "{name} empty", "globals.messages.mismatch": "{name} mismatch", + "globals.messages.notAllowed": "{name} not allowed", "globals.messages.errorSendingPasswordResetEmail": "Error sending password reset email", "globals.messages.cannotBeEmpty": "{name} cannot be empty", "globals.messages.pressEnterToSelectAValue": "Press enter to select a value", @@ -470,6 +470,12 @@ "admin.inbox.configureChannel": "Configure channel", "admin.inbox.createEmailInbox": "Create Email Inbox", "admin.inbox.livechatConfig": "Live Chat Configuration", + "admin.inbox.livechat.tabs.general": "General", + "admin.inbox.livechat.tabs.appearance": "Appearance", + "admin.inbox.livechat.tabs.messages": "Messages", + "admin.inbox.livechat.tabs.features": "Features", + "admin.inbox.livechat.tabs.security": "Security", + "admin.inbox.livechat.tabs.users": "Users", "admin.inbox.livechat.logoUrl": "Logo URL", "admin.inbox.livechat.logoUrl.description": "URL of the logo to display in the chat widget", "admin.inbox.livechat.secretKey": "Secret Key", @@ -488,6 +494,8 @@ "admin.inbox.livechat.introductionMessage": "Introduction Message", "admin.inbox.livechat.chatIntroduction": "Chat Introduction", "admin.inbox.livechat.chatIntroduction.description": "Default: Ask us anything, or share your feedback.", + "admin.inbox.livechat.chatReplyExpectationMessage": "Chat reply expectation message", + "admin.inbox.livechat.chatReplyExpectationMessage.description": "Message shown to customers during business hours about expected reply times", "admin.inbox.livechat.officeHours": "Office Hours", "admin.inbox.livechat.showOfficeHoursInChat": "Show Office Hours in Chat", "admin.inbox.livechat.showOfficeHoursInChat.description": "Show when the team will be next available", @@ -646,7 +654,6 @@ "account.removeAvatar": "Remove avatar", "account.cropAvatar": "Crop avatar", "account.avatarRemoved": "Avatar removed", - "conversation.resolveWithoutAssignee": "Cannot resolve the conversation without an assigned user, Please assign a user before attempting to resolve", "conversation.notMemberOfTeam": "You're not a member of this team, Please refresh the page and try again", "conversation.viewPermissionDenied": "You do not have access to this view", "conversation.errorGeneratingMessageID": "Error generating message ID", diff --git a/internal/attachment/attachment.go b/internal/attachment/attachment.go index a8f2c1d5..e73e88b1 100644 --- a/internal/attachment/attachment.go +++ b/internal/attachment/attachment.go @@ -38,6 +38,13 @@ func (a *Attachments) Scan(value interface{}) error { return json.Unmarshal(bytes, a) } +func (a Attachments) MarshalJSON() ([]byte, error) { + if a == nil { + a = make(Attachments, 0) + } + return json.Marshal([]Attachment(a)) +} + // MakeHeader creates a MIME header for email attachments or inline content. func MakeHeader(contentType, contentID, fileName, encoding, disposition string) textproto.MIMEHeader { if encoding == "" { diff --git a/internal/business_hours/queries.sql b/internal/business_hours/queries.sql index bf454951..e7872149 100644 --- a/internal/business_hours/queries.sql +++ b/internal/business_hours/queries.sql @@ -15,7 +15,10 @@ SELECT id, created_at, updated_at, "name", - description + description, + is_always_open, + hours, + holidays FROM business_hours ORDER BY updated_at DESC; diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index f3599057..3c4c0174 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -111,6 +111,7 @@ type userStore interface { type mediaStore interface { GetBlob(name string) ([]byte, error) + GetURL(name string) string Attach(id int, model string, modelID int) error GetByModel(id int, model string) ([]mmodels.Media, error) ContentIDExists(contentID string) (bool, string, error) @@ -129,6 +130,7 @@ type settingsStore interface { type csatStore interface { Create(conversationID int) (csatModels.CSATResponse, error) + Get(uuid string) (csatModels.CSATResponse, error) MakePublicURL(appBaseURL, uuid string) string } @@ -1147,3 +1149,35 @@ func (c *Manager) makeConversationsListQuery(userID int, teamIDs []int, listType "conversation_statuses": conversationStatusAllowedFields, }) } + +// ProcessCSATStatus processes messages and adds CSAT submission status for CSAT messages. +func (m *Manager) ProcessCSATStatus(messages []models.Message) { + for i := range messages { + msg := &messages[i] + if msg.HasCSAT() { + // Extract CSAT UUID from message content + csatUUID := msg.ExtractCSATUUID() + if csatUUID == "" { + // Fallback to basic censoring if UUID extraction fails + msg.CensorCSATContent() + continue + } + + // Get CSAT submission status + csat, err := m.csatStore.Get(csatUUID) + isSubmitted := false + rating := 0 + feedback := "" + if err == nil && csat.ResponseTimestamp.Valid { + isSubmitted = true + rating = csat.Score + if csat.Feedback.Valid { + feedback = csat.Feedback.String + } + } + + // Censor content and add submission status + msg.CensorCSATContentWithStatus(isSubmitted, csatUUID, rating, feedback) + } + } +} diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 1418251b..f1658a68 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -18,6 +18,7 @@ import ( "github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/image" "github.com/abhinavxd/libredesk/internal/inbox" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" mmodels "github.com/abhinavxd/libredesk/internal/media/models" "github.com/abhinavxd/libredesk/internal/sla" "github.com/abhinavxd/libredesk/internal/stringutil" @@ -102,7 +103,7 @@ func (m *Manager) IncomingMessageWorker(ctx context.Context) { if !ok { return } - if err := m.processIncomingMessage(msg); err != nil { + if _, err := m.ProcessIncomingMessage(msg); err != nil { m.lo.Error("error processing incoming msg", "error", err) } } @@ -180,11 +181,12 @@ func (m *Manager) sendOutgoingMessage(message models.Message) { // Send message err = inb.Send(message) - if handleError(err, "error sending message") { + if err != nil && err != livechat.ErrClientNotConnected { + handleError(err, "error sending message") return } - // Update status. + // Update status as sent. m.UpdateMessageStatus(message.UUID, models.MessageStatusSent) // Skip system user replies since we only update timestamps and SLA for human replies. @@ -393,20 +395,22 @@ func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID, contactID return envelope.NewError(envelope.InputError, m.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil) } - // Save to, cc and bcc in meta. - to = stringutil.RemoveEmpty(to) - cc = stringutil.RemoveEmpty(cc) - bcc = stringutil.RemoveEmpty(bcc) - metaMap["to"] = to - if len(cc) > 0 { - metaMap["cc"] = cc - } - if len(bcc) > 0 { - metaMap["bcc"] = bcc - } - var sourceID = "" - if inboxRecord.Channel == "email" { + switch inboxRecord.Channel { + case inbox.ChannelEmail: + // Add `to`, `cc`, and `bcc` recipients to meta map. + to = stringutil.RemoveEmpty(to) + cc = stringutil.RemoveEmpty(cc) + bcc = stringutil.RemoveEmpty(bcc) + if len(to) > 0 { + metaMap["to"] = to + } + if len(cc) > 0 { + metaMap["cc"] = cc + } + if len(bcc) > 0 { + metaMap["bcc"] = bcc + } if len(to) == 0 { return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.empty", "name", "`to`"), nil) } @@ -415,7 +419,7 @@ func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID, contactID m.lo.Error("error generating source message id", "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.T("conversation.errorGeneratingMessageID"), nil) } - } else { + case inbox.ChannelLiveChat: sourceID, err = stringutil.RandomAlphanumeric(35) if err != nil { m.lo.Error("error generating random source id", "error", err) @@ -468,8 +472,8 @@ func (m *Manager) InsertMessage(message *models.Message) error { message.TextContent = stringutil.HTML2Text(message.Content) // Insert Message. - if err := m.q.InsertMessage.QueryRow(message.Type, message.Status, message.ConversationID, message.ConversationUUID, message.Content, message.TextContent, message.SenderID, message.SenderType, - message.Private, message.ContentType, message.SourceID, message.Meta).Scan(&message.ID, &message.UUID, &message.CreatedAt); err != nil { + if err := m.q.InsertMessage.Get(message, message.Type, message.Status, message.ConversationID, message.ConversationUUID, message.Content, message.TextContent, message.SenderID, message.SenderType, + message.Private, message.ContentType, message.SourceID, message.Meta); err != nil { m.lo.Error("error inserting message in db", "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorInserting", "name", "{globals.terms.message}"), nil) } @@ -511,7 +515,10 @@ func (m *Manager) InsertMessage(message *models.Message) error { } } } - fmt.Println("NAME", sender.FullName()) + + // Censor CSAT content before saving last message details. + message.CensorCSATContent() + if slices.Contains([]string{models.MessageIncoming, models.MessageOutgoing}, message.Type) && !message.Private { conversationMeta["last_chat_message"] = map[string]any{ "uuid": message.UUID, @@ -521,8 +528,8 @@ func (m *Manager) InsertMessage(message *models.Message) error { "id": sender.ID, "first_name": sender.FirstName, "last_name": sender.LastName, + "type": sender.Type, }, - "sender_type": message.SenderType, } lastInteractionAt = null.TimeFrom(message.CreatedAt) } @@ -534,8 +541,8 @@ func (m *Manager) InsertMessage(message *models.Message) error { "id": sender.ID, "first_name": sender.FirstName, "last_name": sender.LastName, + "type": sender.Type, }, - "sender_type": message.SenderType, } conversationMetaB, err := json.Marshal(conversationMeta) if err != nil { @@ -669,31 +676,43 @@ func (m *Manager) getMessageActivityContent(activityType, newValue, actorName st return content, nil } -// processIncomingMessage handles the insertion of an incoming message and +// ProcessIncomingMessage handles the insertion of an incoming message and // associated contact. It finds or creates the contact, checks for existing // conversations, and creates a new conversation if necessary. It also // inserts the message, uploads any attachments, and queues the conversation evaluation of automation rules. -func (m *Manager) processIncomingMessage(in models.IncomingMessage) error { - // Find or create contact and set sender ID in message. - if err := m.userStore.CreateContact(&in.Contact); err != nil { - m.lo.Error("error upserting contact", "error", err) - return err - } - in.Message.SenderID = in.Contact.ID +func (m *Manager) ProcessIncomingMessage(in models.IncomingMessage) (models.Message, error) { + var ( + isNewConversation = false + conversationID int + err error + ) - // Conversations exists for this message? - conversationID, err := m.findConversationID([]string{in.Message.SourceID.String}) - if err != nil && err != errConversationNotFound { - return err - } - if conversationID > 0 { - return nil - } + // Do channel specific processing. + switch in.Channel { + case inbox.ChannelEmail: + // Find or create contact and set sender ID in message. + if err := m.userStore.CreateContact(&in.Contact); err != nil { + m.lo.Error("error upserting contact", "error", err) + return models.Message{}, err + } + in.Message.SenderID = in.Contact.ID - // Find or create new conversation. - isNewConversation, err := m.findOrCreateConversation(&in.Message, in.InboxID, in.Contact.ID) - if err != nil { - return err + // Conversations exists for this message? + conversationID, err = m.findConversationID([]string{in.Message.SourceID.String}) + if err != nil && err != errConversationNotFound { + return models.Message{}, err + } + if conversationID > 0 { + return models.Message{}, nil + } + + // Find or create new conversation. + isNewConversation, err = m.findOrCreateConversation(&in.Message, in.InboxID, in.Contact.ID) + if err != nil { + return models.Message{}, err + } + case inbox.ChannelLiveChat: + // For live chat, a conversation is created before the message is processed. So nothing to do here. } // Upload message attachments. @@ -704,56 +723,15 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error { // Insert message. if err = m.InsertMessage(&in.Message); err != nil { - return err - } - - // Evaluate automation rules & send webhook events. - if isNewConversation { - conversation, err := m.GetConversation(in.Message.ConversationID, "") - if err == nil { - m.webhookStore.TriggerEvent(wmodels.EventConversationCreated, conversation) - m.automation.EvaluateNewConversationRules(conversation) - } - return nil + return models.Message{}, err } - // Reopen conversation if it's not Open. - systemUser, err := m.userStore.GetSystemUser() - if err != nil { - m.lo.Error("error fetching system user", "error", err) - } else { - if err := m.ReOpenConversation(in.Message.ConversationUUID, systemUser); err != nil { - m.lo.Error("error reopening conversation", "error", err) - } + // Process post-message hooks (automation rules, webhooks, SLA, etc.). + if err := m.ProcessIncomingMessageHooks(in.Message.ConversationUUID, isNewConversation); err != nil { + m.lo.Error("error processing incoming message hooks", "conversation_uuid", in.Message.ConversationUUID, "error", err) + return models.Message{}, fmt.Errorf("processing incoming message hooks: %w", err) } - - // Set waiting since timestamp, this gets cleared when agent replies to the conversation. - now := time.Now() - m.UpdateConversationWaitingSince(in.Message.ConversationUUID, &now) - - // Create SLA event for next response if a SLA is applied and has next response time set, subsequent agent replies will mark this event as met. - // This cycle continues for next response time SLA metric. - conversation, err := m.GetConversation(in.Message.ConversationID, "") - if err != nil { - m.lo.Error("error fetching conversation", "conversation_id", in.Message.ConversationID, "error", err) - } else { - // Trigger automations on incoming message event. - m.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationMessageIncoming) - - if conversation.SLAPolicyID.Int == 0 { - m.lo.Info("no SLA policy applied to conversation, skipping next response SLA event creation") - return nil - } - if deadline, err := m.slaStore.CreateNextResponseSLAEvent(conversation.ID, conversation.AppliedSLAID.Int, conversation.SLAPolicyID.Int, conversation.AssignedTeamID.Int); err != nil && !errors.Is(err, sla.ErrUnmetSLAEventAlreadyExists) { - m.lo.Error("error creating next response SLA event", "conversation_id", conversation.ID, "error", err) - } else if !deadline.IsZero() { - m.lo.Info("next response SLA event created for conversation", "conversation_id", conversation.ID, "deadline", deadline, "sla_policy_id", conversation.SLAPolicyID.Int) - m.BroadcastConversationUpdate(in.Message.ConversationUUID, "next_response_deadline_at", deadline.Format(time.RFC3339)) - // Clear next response met at timestamp as this event was just created. - m.BroadcastConversationUpdate(in.Message.ConversationUUID, "next_response_met_at", nil) - } - } - return nil + return in.Message, nil } // MessageExists checks if a message with the given messageID exists. @@ -968,9 +946,13 @@ func (m *Manager) attachAttachmentsToMessage(message *models.Message) error { return err } attachment := attachment.Attachment{ - Name: media.Filename, - Content: blob, - Header: attachment.MakeHeader(media.ContentType, media.UUID, media.Filename, "base64", media.Disposition.String), + Name: media.Filename, + UUID: media.UUID, + ContentType: media.ContentType, + Content: blob, + Size: media.Size, + Header: attachment.MakeHeader(media.ContentType, media.UUID, media.Filename, "base64", media.Disposition.String), + URL: m.mediaStore.GetURL(media.UUID), } attachments = append(attachments, attachment) } @@ -1030,3 +1012,56 @@ func (m *Manager) getLatestMessage(conversationID int, typ []string, status []st } return message, nil } + +// ProcessIncomingMessageHooks handles automation rules, webhooks, SLA events, and other post-processing +// for incoming messages. This allows other channels to insert messages first and then call this +// function to trigger the necessary hooks. +func (m *Manager) ProcessIncomingMessageHooks(conversationUUID string, isNewConversation bool) error { + // Handle new conversation events. + if isNewConversation { + conversation, err := m.GetConversation(0, conversationUUID) + if err == nil { + m.webhookStore.TriggerEvent(wmodels.EventConversationCreated, conversation) + m.automation.EvaluateNewConversationRules(conversation) + } + return nil + } + + // Reopen conversation if it's not Open. + systemUser, err := m.userStore.GetSystemUser() + if err != nil { + m.lo.Error("error fetching system user", "error", err) + } else { + if err := m.ReOpenConversation(conversationUUID, systemUser); err != nil { + m.lo.Error("error reopening conversation", "error", err) + } + } + + // Set waiting since timestamp, this gets cleared when agent replies to the conversation. + now := time.Now() + m.UpdateConversationWaitingSince(conversationUUID, &now) + + // Create SLA event for next response if a SLA is applied and has next response time set, subsequent agent replies will mark this event as met. + // This cycle continues for next response time SLA metric. + conversation, err := m.GetConversation(0, conversationUUID) + if err != nil { + m.lo.Error("error fetching conversation", "conversation_uuid", conversationUUID, "error", err) + } else { + // Trigger automations on incoming message event. + m.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationMessageIncoming) + + if conversation.SLAPolicyID.Int == 0 { + m.lo.Info("no SLA policy applied to conversation, skipping next response SLA event creation") + return nil + } + if deadline, err := m.slaStore.CreateNextResponseSLAEvent(conversation.ID, conversation.AppliedSLAID.Int, conversation.SLAPolicyID.Int, conversation.AssignedTeamID.Int); err != nil && !errors.Is(err, sla.ErrUnmetSLAEventAlreadyExists) { + m.lo.Error("error creating next response SLA event", "conversation_id", conversation.ID, "error", err) + } else if !deadline.IsZero() { + m.lo.Info("next response SLA event created for conversation", "conversation_id", conversation.ID, "deadline", deadline, "sla_policy_id", conversation.SLAPolicyID.Int) + m.BroadcastConversationUpdate(conversationUUID, "next_response_deadline_at", deadline.Format(time.RFC3339)) + // Clear next response met at timestamp as this event was just created. + m.BroadcastConversationUpdate(conversationUUID, "next_response_met_at", nil) + } + } + return nil +} diff --git a/internal/conversation/models/models.go b/internal/conversation/models/models.go index 9f5e468e..11b2ea07 100644 --- a/internal/conversation/models/models.go +++ b/internal/conversation/models/models.go @@ -3,6 +3,7 @@ package models import ( "encoding/json" "net/textproto" + "strings" "time" "github.com/abhinavxd/libredesk/internal/attachment" @@ -52,26 +53,31 @@ var ( ContentTypeHTML = "html" ) +type LastChatMessage struct { + Content string `db:"content" json:"content"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + Author umodels.ChatUser `db:"author" json:"author"` +} + type ChatConversation struct { - UUID string `db:"uuid" json:"uuid"` - Status string `db:"status" json:"status"` - LastMessage string `db:"last_message" json:"last_message"` - LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"` - LastMessageSenderFirstName string `db:"last_message_sender_first_name" json:"last_message_sender_first_name"` - LastMessageSenderLastName string `db:"last_message_sender_last_name" json:"last_message_sender_last_name"` - LastMessageSenderAvatarURL string `db:"last_message_sender_avatar_url" json:"last_message_sender_avatar_url"` - UnreadMessageCount int `db:"unread_message_count" json:"unread_message_count"` - Assignee umodels.User `db:"assignee" json:"assignee"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UUID string `db:"uuid" json:"uuid"` + Status string `db:"status" json:"status"` + LastChatMessage LastChatMessage `db:"last_message" json:"last_message"` + UnreadMessageCount int `db:"unread_message_count" json:"unread_message_count"` + Assignee umodels.ChatUser `db:"assignee" json:"assignee"` } type ChatMessage struct { - CreatedAt time.Time `json:"created_at"` - UUID string `json:"uuid"` - Content string `json:"content"` - SenderType string `json:"sender_type"` - SenderName string `json:"sender_name"` - ConversationID string `json:"conversation_id"` - Attachments attachment.Attachments `json:"attachments"` + UUID string `json:"uuid"` + Status string `json:"status"` + ConversationUUID string `json:"conversation_uuid"` + CreatedAt time.Time `json:"created_at"` + Content string `json:"content"` + TextContent string `json:"text_content"` + Author umodels.ChatUser `json:"author"` + Attachments attachment.Attachments `json:"attachments"` + Meta json.RawMessage `json:"meta"` } type Conversation struct { @@ -118,6 +124,13 @@ type Conversation struct { Total int `db:"total" json:"-"` } +type IncomingMessage struct { + Channel string + Message Message + Contact umodels.User + InboxID int +} + type ConversationParticipant struct { ID string `db:"id" json:"id"` FirstName string `db:"first_name" json:"first_name"` @@ -139,39 +152,38 @@ type NewConversationsStats struct { // Message represents a message in a conversation type Message struct { - ID int `db:"id" json:"id,omitempty"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - UUID string `db:"uuid" json:"uuid"` - Type string `db:"type" json:"type"` - Status string `db:"status" json:"status"` - ConversationID int `db:"conversation_id" json:"conversation_id"` - Content string `db:"content" json:"content"` - TextContent string `db:"text_content" json:"text_content"` - ContentType string `db:"content_type" json:"content_type"` - Private bool `db:"private" json:"private"` - SourceID null.String `db:"source_id" json:"-"` - SenderID int `db:"sender_id" json:"sender_id"` - SenderType string `db:"sender_type" json:"sender_type"` - InboxID int `db:"inbox_id" json:"-"` - Meta json.RawMessage `db:"meta" json:"meta"` - Attachments attachment.Attachments `db:"attachments" json:"attachments"` - ConversationUUID string `db:"conversation_uuid" json:"-"` - From string `db:"from" json:"-"` - Subject string `db:"subject" json:"-"` - Channel string `db:"channel" json:"-"` - To pq.StringArray `db:"to" json:"-"` - CC pq.StringArray `db:"cc" json:"-"` - BCC pq.StringArray `db:"bcc" json:"-"` - References []string `json:"-"` - InReplyTo string `json:"-"` - Headers textproto.MIMEHeader `json:"-"` - AltContent string `db:"-" json:"-"` - Media []mmodels.Media `db:"-" json:"-"` - IsCSAT bool `db:"-" json:"-"` - // TODO: Figure if this field is needed or there's a better way. - MessageReceiverID int `db:"-" json:"-"` - Total int `db:"total" json:"-"` + ID int `db:"id" json:"id,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + UUID string `db:"uuid" json:"uuid"` + Type string `db:"type" json:"type"` + Status string `db:"status" json:"status"` + ConversationID int `db:"conversation_id" json:"conversation_id"` + Content string `db:"content" json:"content"` + TextContent string `db:"text_content" json:"text_content"` + ContentType string `db:"content_type" json:"content_type"` + Private bool `db:"private" json:"private"` + SourceID null.String `db:"source_id" json:"-"` + SenderID int `db:"sender_id" json:"sender_id"` + SenderType string `db:"sender_type" json:"sender_type"` + InboxID int `db:"inbox_id" json:"-"` + Meta json.RawMessage `db:"meta" json:"meta"` + Attachments attachment.Attachments `db:"attachments" json:"attachments"` + ConversationUUID string `db:"conversation_uuid" json:"-"` + From string `db:"from" json:"-"` + Subject string `db:"subject" json:"-"` + Channel string `db:"channel" json:"-"` + To pq.StringArray `db:"to" json:"-"` + CC pq.StringArray `db:"cc" json:"-"` + BCC pq.StringArray `db:"bcc" json:"-"` + MessageReceiverID int `db:"message_receiver_id" json:"-"` + References []string `json:"-"` + InReplyTo string `json:"-"` + Headers textproto.MIMEHeader `json:"-"` + AltContent string `db:"-" json:"-"` + Media []mmodels.Media `db:"-" json:"-"` + IsCSAT bool `db:"-" json:"-"` + Total int `db:"total" json:"-"` } // CensorCSATContent redacts the content of a CSAT message to prevent leaking the CSAT survey public link. @@ -181,8 +193,37 @@ func (m *Message) CensorCSATContent() { return } if isCsat, _ := meta["is_csat"].(bool); isCsat { - m.Content = "Please rate your experience with us" + m.Content = "Please rate this conversation" + m.TextContent = m.Content + } +} + +// CensorCSATContentWithStatus redacts the content and adds submission status for CSAT messages. +func (m *Message) CensorCSATContentWithStatus(csatSubmitted bool, csatUUID string, rating int, feedback string) { + var meta map[string]any + if err := json.Unmarshal([]byte(m.Meta), &meta); err != nil { + return + } + if isCsat, _ := meta["is_csat"].(bool); isCsat { + m.Content = "Please rate this conversation" m.TextContent = m.Content + + // Add submission status and UUID to meta + meta["csat_submitted"] = csatSubmitted + meta["csat_uuid"] = csatUUID + + // Add submitted rating and feedback if CSAT was submitted + if csatSubmitted { + if rating > 0 { + meta["submitted_rating"] = rating + } + meta["submitted_feedback"] = feedback + } + + // Update the meta field + if updatedMeta, err := json.Marshal(meta); err == nil { + m.Meta = json.RawMessage(updatedMeta) + } } } @@ -196,11 +237,35 @@ func (m *Message) HasCSAT() bool { return isCsat } -// IncomingMessage links a message with the contact information and inbox id. -type IncomingMessage struct { - Message Message - Contact umodels.User - InboxID int +// ExtractCSATUUID extracts the CSAT UUID from the message content. +func (m *Message) ExtractCSATUUID() string { + if !m.HasCSAT() { + return "" + } + + // Extract UUID from the CSAT URL in the message content + // Pattern: + content := m.Content + // Look for /csat/ followed by UUID pattern + start := strings.Index(content, "/csat/") + if start == -1 { + return "" + } + start += 6 // Skip "/csat/" + + // Find the end of UUID (36 characters) + if len(content) < start+36 { + return "" + } + + uuid := content[start : start+36] + + // Basic validation - UUID should contain hyphens at positions 8, 13, 18, 23 + if len(uuid) == 36 && uuid[8] == '-' && uuid[13] == '-' && uuid[18] == '-' && uuid[23] == '-' { + return uuid + } + + return "" } type Status struct { diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql index d5c0a14b..923c186a 100644 --- a/internal/conversation/queries.sql +++ b/internal/conversation/queries.sql @@ -222,24 +222,35 @@ LIMIT 10; -- name: get-contact-chat-conversations SELECT + c.created_at, c.uuid, - COALESCE(c.meta->'last_chat_message'->>'text_content', '') as last_message, - COALESCE((c.meta->'last_chat_message'->>'created_at')::timestamptz, NULL) as last_message_at, - COALESCE(c.meta->'last_chat_message'->'sender'->>'first_name', '') AS last_message_sender_first_name, - COALESCE(c.meta->'last_chat_message'->'sender'->>'last_name', '') AS last_message_sender_last_name, - COALESCE(c.meta->'last_chat_message'->'sender'->>'avatar_url', '') AS last_message_sender_avatar_url, - LEAST(10, COUNT(unread.id)) AS unread_message_count -FROM conversations c + COALESCE(c.meta->'last_chat_message'->>'text_content', '') as "last_message.content", + COALESCE((c.meta->'last_chat_message'->>'created_at')::timestamptz, NULL) as "last_message.created_at", + COALESCE(c.meta->'last_chat_message'->'sender'->>'id', '') AS "last_message.author.id", + COALESCE(c.meta->'last_chat_message'->'sender'->>'first_name', '') AS "last_message.author.first_name", + COALESCE(c.meta->'last_chat_message'->'sender'->>'last_name', '') AS "last_message.author.last_name", + COALESCE(c.meta->'last_chat_message'->'sender'->>'avatar_url', '') AS "last_message.author.avatar_url", + LEAST(10, COUNT(unread.id)) AS unread_message_count, + COALESCE(au.availability_status::TEXT, '') as "assignee.availability_status", + au.avatar_url as "assignee.avatar_url", + COALESCE(au.first_name, '') as "assignee.first_name", + COALESCE(au.id, 0) as "assignee.id", + COALESCE(au.last_name, '') as "assignee.last_name", + COALESCE(au.type::TEXT, '') as "assignee.type" +FROM conversations c inner join inboxes inb on c.inbox_id = inb.id +LEFT JOIN users au ON c.assigned_user_id = au.id LEFT JOIN conversation_messages unread ON unread.conversation_id = c.id AND unread.created_at > c.contact_last_seen_at AND unread.type IN ('incoming', 'outgoing') AND unread.private = false -WHERE c.contact_id = $1 -GROUP BY c.id, c.uuid, +WHERE c.contact_id = $1 AND inb.channel = 'livechat' AND inb.deleted_at IS NULL AND inb.enabled = true +GROUP BY c.id, c.uuid, c.created_at, c.meta->'last_chat_message'->>'text_content', (c.meta->'last_chat_message'->>'created_at')::timestamptz, + c.meta->'last_chat_message'->'sender'->>'id', c.meta->'last_chat_message'->'sender'->>'first_name', c.meta->'last_chat_message'->'sender'->>'last_name', - c.meta->'last_chat_message'->'sender'->>'avatar_url' + c.meta->'last_chat_message'->'sender'->>'avatar_url', + au.availability_status, au.avatar_url, au.first_name, au.id, au.last_name, au.type ORDER BY c.created_at DESC LIMIT 100; @@ -473,15 +484,19 @@ SELECT m.private, m.status, m.content, + m.text_content, + m.sender_type, m.conversation_id, m.content_type, m.source_id, + m.meta, ARRAY(SELECT jsonb_array_elements_text(m.meta->'cc')) AS cc, ARRAY(SELECT jsonb_array_elements_text(m.meta->'bcc')) AS bcc, ARRAY(SELECT jsonb_array_elements_text(m.meta->'to')) AS to, c.inbox_id, c.uuid as conversation_uuid, - c.subject + c.subject, + c.contact_id as message_receiver_id FROM conversation_messages m INNER JOIN conversations c ON c.id = m.conversation_id WHERE m.status = 'pending' AND m.type = 'outgoing' AND m.private = false diff --git a/internal/conversation/ws.go b/internal/conversation/ws.go index 7a6aaa91..1799a4c8 100644 --- a/internal/conversation/ws.go +++ b/internal/conversation/ws.go @@ -5,6 +5,7 @@ import ( "time" cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" wsmodels "github.com/abhinavxd/libredesk/internal/ws/models" ) @@ -51,6 +52,37 @@ func (m *Manager) BroadcastConversationUpdate(conversationUUID, prop string, val m.broadcastToUsers([]int{}, message) } +// BroadcastTypingToConversation broadcasts typing status to all subscribers of a conversation. +// Set broadcastToWidgets to false when the typing event originates from a widget client to avoid echo. +func (m *Manager) BroadcastTypingToConversation(conversationUUID string, isTyping bool, broadcastToWidgets bool) { + message := wsmodels.Message{ + Type: wsmodels.MessageTypeTyping, + Data: map[string]interface{}{ + "conversation_uuid": conversationUUID, + "is_typing": isTyping, + }, + } + + messageBytes, err := json.Marshal(message) + if err != nil { + m.lo.Error("error marshalling typing WS message", "error", err) + return + } + + // Always broadcast to agent clients (main app WebSocket clients) + m.wsHub.BroadcastTypingToAllConversationClients(conversationUUID, messageBytes) + + // Broadcast to widget clients (customers) only if this typing event comes from agents + if broadcastToWidgets { + m.broadcastTypingToWidgetClients(conversationUUID, isTyping) + } +} + +// BroadcastTypingToWidgetClientsOnly broadcasts typing status only to widget clients. +func (m *Manager) BroadcastTypingToWidgetClientsOnly(conversationUUID string, isTyping bool) { + m.broadcastTypingToWidgetClients(conversationUUID, isTyping) +} + // broadcastToUsers broadcasts a message to a list of users, if the list is empty it broadcasts to all users. func (m *Manager) broadcastToUsers(userIDs []int, message wsmodels.Message) { messageBytes, err := json.Marshal(message) @@ -63,3 +95,25 @@ func (m *Manager) broadcastToUsers(userIDs []int, message wsmodels.Message) { Users: userIDs, }) } + +// broadcastTypingToWidgetClients broadcasts typing status to widget clients (customers) for a conversation. +func (m *Manager) broadcastTypingToWidgetClients(conversationUUID string, isTyping bool) { + // Get the conversation to find its inbox ID + conversation, err := m.GetConversation(0, conversationUUID) + if err != nil { + m.lo.Error("error getting conversation for widget typing broadcast", "error", err, "conversation_uuid", conversationUUID) + return + } + + // Get the inbox + inboxInstance, err := m.inboxStore.Get(conversation.InboxID) + if err != nil { + m.lo.Error("error getting inbox for widget typing broadcast", "error", err, "inbox_id", conversation.InboxID) + return + } + + // Check if it's a livechat inbox and broadcast typing status + if liveChatInbox, ok := inboxInstance.(*livechat.LiveChat); ok { + liveChatInbox.BroadcastTypingToClients(conversationUUID, isTyping) + } +} diff --git a/internal/csat/csat.go b/internal/csat/csat.go index 58b6bef6..46d67b3d 100644 --- a/internal/csat/csat.go +++ b/internal/csat/csat.go @@ -93,7 +93,8 @@ func (m *Manager) UpdateResponse(uuid string, score int, feedback string) error return err } - if csat.Score > 0 || !csat.ResponseTimestamp.IsZero() { + // Check if CSAT has already been submitted (response timestamp exists) + if !csat.ResponseTimestamp.IsZero() { return envelope.NewError(envelope.InputError, m.i18n.T("csat.alreadySubmitted"), nil) } diff --git a/internal/httputil/httputil.go b/internal/httputil/httputil.go new file mode 100644 index 00000000..2450154e --- /dev/null +++ b/internal/httputil/httputil.go @@ -0,0 +1,90 @@ +package httputil + +import ( + "net" + "net/url" + "strings" +) + +// IsOriginTrusted checks if the given origin is trusted based on the trusted domains list +// Expects trustedDomains to be a list of domain strings, which can include wildcards. +// Like "*.example.com" or "example.com". +func IsOriginTrusted(origin string, trustedDomains []string) bool { + if len(trustedDomains) == 0 { + return false + } + + originHost, originPort := parseHostPort(origin) + if originHost == "" { + return false + } + + for _, trusted := range trustedDomains { + trustedHost, trustedPort := parseTrustedDomain(trusted) + if portMatches(originPort, trustedPort) && hostMatches(originHost, trustedHost) { + return true + } + } + + return false +} + +// parseHostPort extracts host and port from origin URL +func parseHostPort(origin string) (host, port string) { + u, err := url.Parse(strings.ToLower(origin)) + if err != nil { + return "", "" + } + + host, port, _ = net.SplitHostPort(u.Host) + if host == "" { + host = u.Host + } + return host, port +} + +// parseTrustedDomain extracts host and port from trusted domain entry +func parseTrustedDomain(domain string) (host, port string) { + domain = strings.ToLower(domain) + + if strings.HasPrefix(domain, "http://") || strings.HasPrefix(domain, "https://") { + u, err := url.Parse(domain) + if err != nil { + return "", "" + } + host, port, _ = net.SplitHostPort(u.Host) + if host == "" { + host = u.Host + } + return host, port + } + + // Handle non-URL patterns (wildcards/domains) + host, port, _ = net.SplitHostPort(domain) + if host == "" { + host = domain + } + return host, port +} + +// portMatches checks if ports are compatible +func portMatches(originPort, trustedPort string) bool { + if trustedPort == "" || trustedPort == originPort { + return true + } + return false +} + +// hostMatches checks if host matches trusted pattern +func hostMatches(origin, trusted string) bool { + if trusted == origin { + return true + } + + if strings.HasPrefix(trusted, "*.") { + base := trusted[2:] + return origin == base || strings.HasSuffix(origin, "."+base) + } + + return false +} \ No newline at end of file diff --git a/internal/inbox/channel/email/imap.go b/internal/inbox/channel/email/imap.go index 0795b232..29c5655e 100644 --- a/internal/inbox/channel/email/imap.go +++ b/internal/inbox/channel/email/imap.go @@ -340,6 +340,7 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client, return fmt.Errorf("marshalling meta: %w", err) } incomingMsg := models.IncomingMessage{ + Channel: ChannelEmail, Message: models.Message{ Channel: e.Channel(), SenderType: models.SenderTypeContact, diff --git a/internal/inbox/channel/livechat/livechat.go b/internal/inbox/channel/livechat/livechat.go index b829aad5..e7f154f0 100644 --- a/internal/inbox/channel/livechat/livechat.go +++ b/internal/inbox/channel/livechat/livechat.go @@ -7,10 +7,10 @@ import ( "fmt" "strconv" "sync" - "time" "github.com/abhinavxd/libredesk/internal/conversation/models" "github.com/abhinavxd/libredesk/internal/inbox" + umodels "github.com/abhinavxd/libredesk/internal/user/models" "github.com/zerodha/logf" ) @@ -26,6 +26,7 @@ const ( // Config holds the live chat inbox configuration. type Config struct { BrandName string `json:"brand_name"` + DarkMode bool `json:"dark_mode"` Language string `json:"language"` Users struct { AllowStartConversation bool `json:"allow_start_conversation"` @@ -67,6 +68,7 @@ type Config struct { IntroductionMessage string `json:"introduction_message"` ShowOfficeHoursInChat bool `json:"show_office_hours_in_chat"` ShowOfficeHoursAfterAssignment bool `json:"show_office_hours_after_assignment"` + ChatReplyExpectationMessage string `json:"chat_reply_expectation_message"` } // Client represents a connected chat client @@ -137,38 +139,49 @@ func (lc *LiveChat) Send(message models.Message) error { } for _, client := range clients { + // Set `content` in all attachments to `null` as attachments are sent with URLs and live chat uses URLs to fetch the content. + for i := range message.Attachments { + if message.Attachments[i].Content != nil { + message.Attachments[i].Content = nil + } + } + messageData := map[string]any{ "type": "new_message", - "data": map[string]any{ - "created_at": message.CreatedAt.Format(time.RFC3339), - "conversation_uuid": message.ConversationUUID, - "uuid": message.UUID, - "content": message.Content, - "text_content": message.TextContent, - "sender_type": message.SenderType, - "sender_name": sender.FullName(), - "status": message.Status, + "data": models.ChatMessage{ + UUID: message.UUID, + ConversationUUID: message.ConversationUUID, + CreatedAt: message.CreatedAt, + Content: message.Content, + TextContent: message.TextContent, + Meta: message.Meta, + Author: umodels.ChatUser{ + ID: message.SenderID, + FirstName: sender.FirstName, + LastName: sender.LastName, + AvatarURL: sender.AvatarURL, + AvailabilityStatus: sender.AvailabilityStatus, + Type: sender.Type, + }, + Attachments: message.Attachments, }, } - // Convert messageData to JSON + // Marshal and send to client's channel. messageJSON, err := json.Marshal(messageData) if err != nil { lc.lo.Error("failed to marshal message data", "error", err) continue } - - // Send the message to the client's channel select { case client.Channel <- messageJSON: lc.lo.Info("message sent to live chat client", "client_id", client.ID, "message_id", message.UUID) default: lc.lo.Warn("client channel full, dropping message", "client_id", client.ID, "message_id", message.UUID) } - continue } } else { - lc.lo.Debug("client not connected for live chat message", "receiver_id", msgReceiverStr, "message_id", message.UUID) + lc.lo.Debug("websocket client not connected for live chat message", "receiver_id", msgReceiverStr, "message_id", message.UUID) return ErrClientNotConnected } } @@ -192,7 +205,7 @@ func (lc *LiveChat) Channel() string { } // AddClient adds a new client to the live chat session. -func (lc *LiveChat) AddClient(userID, conversationUUID string) (*Client, error) { +func (lc *LiveChat) AddClient(userID string) (*Client, error) { lc.clientsMutex.Lock() defer lc.clientsMutex.Unlock() @@ -209,8 +222,6 @@ func (lc *LiveChat) AddClient(userID, conversationUUID string) (*Client, error) // Add the client to the clients map. lc.clients[userID] = append(lc.clients[userID], client) - - lc.lo.Info("client added to live chat", "client_id", userID, "conversation_uuid", conversationUUID) return client, nil } @@ -235,3 +246,36 @@ func (lc *LiveChat) RemoveClient(c *Client) { } } } + +// BroadcastTypingToClients broadcasts typing status to all connected widget clients for a conversation. +func (lc *LiveChat) BroadcastTypingToClients(conversationUUID string, isTyping bool) { + lc.clientsMutex.RLock() + defer lc.clientsMutex.RUnlock() + + // Create typing status message for widget clients + typingMessage := map[string]interface{}{ + "type": "typing", + "data": map[string]interface{}{ + "conversation_uuid": conversationUUID, + "is_typing": isTyping, + }, + } + + messageJSON, err := json.Marshal(typingMessage) + if err != nil { + lc.lo.Error("failed to marshal typing message", "error", err) + return + } + + // Broadcast to all connected clients + for userID, clients := range lc.clients { + for _, client := range clients { + select { + case client.Channel <- messageJSON: + lc.lo.Debug("typing status sent to widget client", "user_id", userID, "client_id", client.ID, "conversation_uuid", conversationUUID, "is_typing", isTyping) + default: + lc.lo.Warn("client channel full, dropping typing message", "user_id", userID, "client_id", client.ID) + } + } + } +} diff --git a/internal/media/media.go b/internal/media/media.go index 26c6318f..1ff4c322 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -18,6 +18,7 @@ import ( "github.com/jmoiron/sqlx" "github.com/knadh/go-i18n" "github.com/volatiletech/null/v9" + "github.com/zerodha/fastglue" "github.com/zerodha/logf" ) @@ -35,19 +36,29 @@ type Store interface { Name() string } +// SignedURLStore defines the interface for stores that support signed URLs. +// This is optional and only implemented by stores that need signed URL functionality (like fs). +type SignedURLStore interface { + Store + GetSignedURL(name string, expiresAt time.Time, secret []byte) string + VerifySignature(name, signature string, expiresAt time.Time, secret []byte) bool +} + type Manager struct { store Store lo *logf.Logger i18n *i18n.I18n queries queries + secret string } // Opts provides options for configuring the Manager. type Opts struct { - Store Store - Lo *logf.Logger - DB *sqlx.DB - I18n *i18n.I18n + Store Store + Lo *logf.Logger + DB *sqlx.DB + I18n *i18n.I18n + Secret string } // New initializes and returns a new Manager instance for handling media operations. @@ -61,6 +72,7 @@ func New(opt Opts) (*Manager, error) { lo: opt.Lo, i18n: opt.I18n, queries: q, + secret: opt.Secret, }, nil } @@ -217,3 +229,49 @@ func (m *Manager) deleteUnlinkedMessageMedia() error { } return nil } + +// GetSignedURL returns a signed URL for accessing a media file with expiration. +// This delegates to the store if it supports signed URLs (like fs), otherwise returns the normal URL. +func (m *Manager) GetSignedURL(name string, expiresAt time.Time) string { + // Check if the store supports signed URLs + if signedStore, ok := m.store.(SignedURLStore); ok { + return signedStore.GetSignedURL(name, expiresAt, []byte(m.secret)) + } + // Fallback to regular URL for stores that handle signing internally (like S3) + return m.store.GetURL(name) +} + +// VerifySignature verifies a signed URL signature using the request parameters. +// This is used by middleware to verify widget media access. +func (m *Manager) VerifySignature(r *fastglue.Request) error { + uuid := r.RequestCtx.UserValue("uuid") + if uuid == nil { + return fmt.Errorf("missing uuid parameter") + } + + signature := string(r.RequestCtx.QueryArgs().Peek("signature")) + expiresStr := string(r.RequestCtx.QueryArgs().Peek("expires")) + + if signature == "" || expiresStr == "" { + return fmt.Errorf("missing signature or expires parameter") + } + + // Parse expiration time + var expires int64 + if _, err := fmt.Sscanf(expiresStr, "%d", &expires); err != nil { + return fmt.Errorf("invalid expires parameter: %v", err) + } + + expiresAt := time.Unix(expires, 0) + + // Check if store supports signature verification + if signedStore, ok := m.store.(SignedURLStore); ok { + if !signedStore.VerifySignature(uuid.(string), signature, expiresAt, []byte(m.secret)) { + return fmt.Errorf("signature verification failed") + } + return nil + } + + // For stores that don't support signing (like S3), always allow + return nil +} diff --git a/internal/media/models/models.go b/internal/media/models/models.go index f4a05734..f72b185d 100644 --- a/internal/media/models/models.go +++ b/internal/media/models/models.go @@ -1,6 +1,7 @@ package models import ( + "encoding/json" "time" "github.com/volatiletech/null/v9" @@ -15,17 +16,18 @@ const ( // Media represents an uploaded object. type Media struct { - ID int `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UUID string `db:"uuid" json:"uuid"` - Filename string `db:"filename" json:"filename"` - ContentType string `db:"content_type" json:"content_type"` - Model null.String `db:"model_type" json:"-"` - ModelID null.Int `db:"model_id" json:"-"` - Size int `db:"size" json:"size"` - Store string `db:"store" json:"store"` - Disposition null.String `db:"disposition" json:"disposition"` - URL string `json:"url"` - ContentID string `json:"-"` - Content []byte `json:"-"` + ID int `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UUID string `db:"uuid" json:"uuid"` + Filename string `db:"filename" json:"filename"` + ContentType string `db:"content_type" json:"content_type"` + Model null.String `db:"model_type" json:"-"` + ModelID null.Int `db:"model_id" json:"-"` + Size int `db:"size" json:"size"` + Store string `db:"store" json:"store"` + Disposition null.String `db:"disposition" json:"disposition"` + Meta json.RawMessage `db:"meta" json:"meta"` + URL string `json:"url"` + ContentID string `json:"-"` + Content []byte `json:"-"` } diff --git a/internal/migrations/v0.8.0.go b/internal/migrations/v0.8.0.go index 46f2a606..60f3edee 100644 --- a/internal/migrations/v0.8.0.go +++ b/internal/migrations/v0.8.0.go @@ -8,13 +8,27 @@ import ( // V0_8_0 updates the database schema to v0.8.0. func V0_8_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { - // Add 'livechat' to the channels enum - _, err := db.Exec(` - ALTER TYPE channels ADD VALUE IF NOT EXISTS 'livechat'; + // Add 'livechat' to the channels enum if not already present + var exists bool + err := db.Get(&exists, ` + SELECT EXISTS ( + SELECT 1 + FROM pg_enum + WHERE enumlabel = 'livechat' + AND enumtypid = ( + SELECT oid FROM pg_type WHERE typname = 'channels' + ) + ) `) if err != nil { return err } + if !exists { + _, err = db.Exec(`ALTER TYPE channels ADD VALUE 'livechat'`) + if err != nil { + return err + } + } // Drop the foreign key constraint and column from conversations table first _, err = db.Exec(` @@ -64,5 +78,34 @@ func V0_8_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { return err } + tx, err := db.Beginx() + if err != nil { + return err + } + defer tx.Rollback() + stmts := []string{ + /* ── drop index for e‑mail uniqueness and add seperate indexes for type of user ── */ + `DROP INDEX IF EXISTS index_unique_users_on_email_and_type_when_deleted_at_is_null`, + + /* ── add separate indexes for type of user, this excludes `visitor` type as there can be multiple visitors with the same email ── */ + `CREATE UNIQUE INDEX IF NOT EXISTS + index_unique_users_on_email_when_type_is_contact + ON users(email) + WHERE type = 'contact' AND deleted_at IS NULL`, + + `CREATE UNIQUE INDEX IF NOT EXISTS + index_unique_users_on_email_when_type_is_agent + ON users(email) + WHERE type = 'agent' AND deleted_at IS NULL`, + } + + for _, q := range stmts { + if _, err = tx.Exec(q); err != nil { + return err + } + } + if err := tx.Commit(); err != nil { + return err + } return nil } diff --git a/internal/user/models/models.go b/internal/user/models/models.go index 42ef9064..e7289d58 100644 --- a/internal/user/models/models.go +++ b/internal/user/models/models.go @@ -44,7 +44,7 @@ type User struct { PhoneNumber null.String `db:"phone_number" json:"phone_number"` AvatarURL null.String `db:"avatar_url" json:"avatar_url"` Enabled bool `db:"enabled" json:"enabled"` - Password string `db:"password" json:"-"` + Password null.String `db:"password" json:"-"` LastActiveAt null.Time `db:"last_active_at" json:"last_active_at"` LastLoginAt null.Time `db:"last_login_at" json:"last_login_at"` Roles pq.StringArray `db:"roles" json:"roles"` @@ -63,6 +63,17 @@ type User struct { Total int `json:"total,omitempty"` } +// ChatUser is a user with limited fields for live chat. +type ChatUser struct { + ID int `db:"id" json:"id"` + FirstName string `db:"first_name" json:"first_name"` + LastName string `db:"last_name" json:"last_name"` + AvatarURL null.String `db:"avatar_url" json:"avatar_url"` + AvailabilityStatus string `db:"availability_status" json:"availability_status"` + Type string `db:"type" json:"type"` + ActiveAt null.Time `db:"active_at" json:"active_at"` +} + type Note struct { ID int `db:"id" json:"id"` CreatedAt time.Time `db:"created_at" json:"created_at"` diff --git a/internal/user/queries.sql b/internal/user/queries.sql index 08f04b8e..571ecf89 100644 --- a/internal/user/queries.sql +++ b/internal/user/queries.sql @@ -63,7 +63,10 @@ FROM users u LEFT JOIN user_roles ur ON ur.user_id = u.id LEFT JOIN roles r ON r.id = ur.role_id LEFT JOIN LATERAL unnest(r.permissions) AS p ON true -WHERE (u.id = $1 OR u.email = $2) AND u.type = $3 AND u.deleted_at IS NULL +WHERE u.deleted_at IS NULL + AND ($1 = 0 OR u.id = $1) + AND ($2 = '' OR u.email = $2) + AND ($3 = '' OR u.type::text = $3) GROUP BY u.id; -- name: set-user-password @@ -152,16 +155,14 @@ RETURNING user_id; -- name: insert-contact INSERT INTO users (email, type, first_name, last_name, "password", avatar_url) VALUES ($1, 'contact', $2, $3, $4, $5) -ON CONFLICT (email, type) WHERE deleted_at IS NULL +ON CONFLICT (email) WHERE type = 'contact' AND deleted_at IS NULL DO UPDATE SET updated_at = now() RETURNING id; -- name: insert-visitor -INSERT INTO users (email, type, first_name, last_name, "password", avatar_url) -VALUES ($1, 'visitor', $2, $3, $4, $5) -ON CONFLICT (email, type) WHERE deleted_at IS NULL -DO UPDATE SET updated_at = now() -RETURNING id; +INSERT INTO users (email, type, first_name, last_name) +VALUES ($1, 'visitor', $2, $3) +RETURNING *; -- name: update-last-login-at UPDATE users diff --git a/internal/user/user.go b/internal/user/user.go index 83ce8511..23b9c0a0 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -115,7 +115,7 @@ func (u *Manager) VerifyPassword(email string, password []byte) (models.User, er u.lo.Error("error fetching user from db", "error", err) return user, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil) } - if err := u.verifyPassword(password, user.Password); err != nil { + if err := u.verifyPassword(password, user.Password.String); err != nil { return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil) } return user, nil @@ -151,6 +151,10 @@ func (u *Manager) GetAllUsers(page, pageSize int, userType, order, orderBy strin // Get retrieves an user by ID or email. func (u *Manager) Get(id int, email, type_ string) (models.User, error) { + if id == 0 && email == "" { + return models.User{}, envelope.NewError(envelope.InputError, u.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.user}"), nil) + } + var user models.User if err := u.q.GetUser.Get(&user, id, email, type_); err != nil { if errors.Is(err, sql.ErrNoRows) { diff --git a/internal/user/visitor.go b/internal/user/visitor.go index afda91e4..3f9948b2 100644 --- a/internal/user/visitor.go +++ b/internal/user/visitor.go @@ -11,12 +11,6 @@ import ( // CreateVisitor creates a new visitor user. func (u *Manager) CreateVisitor(user *models.User) error { - password, err := u.generatePassword() - if err != nil { - u.lo.Error("generating password", "error", err) - return fmt.Errorf("generating password: %w", err) - } - // Normalize email address. user.Email = null.NewString(strings.ToLower(user.Email.String), user.Email.Valid) @@ -25,7 +19,7 @@ func (u *Manager) CreateVisitor(user *models.User) error { user.FirstName = h.Haikunate() } - if err := u.q.InsertVisitor.QueryRow(user.Email, user.FirstName, user.LastName, password, user.AvatarURL).Scan(&user.ID); err != nil { + if err := u.q.InsertVisitor.Get(user, user.Email, user.FirstName, user.LastName); err != nil { u.lo.Error("error inserting contact", "error", err) return fmt.Errorf("insert contact: %w", err) } diff --git a/internal/ws/client.go b/internal/ws/client.go index 0720826f..ee7e6b3b 100644 --- a/internal/ws/client.go +++ b/internal/ws/client.go @@ -100,7 +100,84 @@ func (c *Client) processIncomingMessage(data []byte) { c.SendMessage([]byte("pong"), websocket.TextMessage) return } - c.SendError("unknown incoming message type") + + // Try to parse as JSON message + var msg models.Message + if err := json.Unmarshal(data, &msg); err != nil { + c.SendError("invalid message format") + return + } + + switch msg.Type { + case models.MessageTypeConversationSubscribe: + c.handleConversationSubscribe(msg.Data) + case models.MessageTypeTyping: + c.handleTyping(msg.Data) + default: + c.SendError("unknown message type") + } +} + +// handleConversationSubscribe handles conversation subscription requests. +func (c *Client) handleConversationSubscribe(data interface{}) { + // Convert the data to JSON and then unmarshal to ConversationSubscribe + dataBytes, err := json.Marshal(data) + if err != nil { + c.SendError("invalid subscription data") + return + } + + var subscribeMsg models.ConversationSubscribe + if err := json.Unmarshal(dataBytes, &subscribeMsg); err != nil { + c.SendError("invalid subscription format") + return + } + + if subscribeMsg.ConversationUUID == "" { + c.SendError("conversation_uuid is required") + return + } + + // Subscribe to the conversation using the Hub + c.Hub.SubscribeToConversation(c, subscribeMsg.ConversationUUID) + + // Send confirmation back to client + response := models.Message{ + Type: models.MessageTypeConversationSubscribed, + Data: map[string]string{ + "conversation_uuid": subscribeMsg.ConversationUUID, + }, + } + + responseBytes, _ := json.Marshal(response) + c.SendMessage(responseBytes, websocket.TextMessage) +} + +// handleTyping handles typing indicator messages. +func (c *Client) handleTyping(data interface{}) { + // Convert the data to JSON and then unmarshal to TypingMessage + dataBytes, err := json.Marshal(data) + if err != nil { + c.SendError("invalid typing data") + return + } + + var typingMsg models.TypingMessage + if err := json.Unmarshal(dataBytes, &typingMsg); err != nil { + c.SendError("invalid typing format") + return + } + + if typingMsg.ConversationUUID == "" { + c.SendError("conversation_uuid is required for typing") + return + } + + // Set the user ID from the client + typingMsg.UserID = c.ID + + // Broadcast typing status to all subscribers of this conversation (except sender) + c.Hub.BroadcastTypingToConversation(typingMsg.ConversationUUID, typingMsg, c) } // close closes the client connection. diff --git a/internal/ws/models/models.go b/internal/ws/models/models.go index 8b38b481..ec65af07 100644 --- a/internal/ws/models/models.go +++ b/internal/ws/models/models.go @@ -7,6 +7,9 @@ const ( MessageTypeNewMessage = "new_message" MessageTypeNewConversation = "new_conversation" MessageTypeError = "error" + MessageTypeConversationSubscribe = "conversation_subscribe" + MessageTypeConversationSubscribed = "conversation_subscribed" + MessageTypeTyping = "typing" ) // WSMessage represents a WS message. @@ -26,3 +29,15 @@ type BroadcastMessage struct { Data []byte `json:"data"` Users []int `json:"users"` } + +// ConversationSubscribe represents a conversation subscription message. +type ConversationSubscribe struct { + ConversationUUID string `json:"conversation_uuid"` +} + +// TypingMessage represents a typing indicator message. +type TypingMessage struct { + ConversationUUID string `json:"conversation_uuid"` + IsTyping bool `json:"is_typing"` + UserID int `json:"user_id"` +} diff --git a/internal/ws/ws.go b/internal/ws/ws.go index 36d8bcd4..f1a3d6fc 100644 --- a/internal/ws/ws.go +++ b/internal/ws/ws.go @@ -2,6 +2,7 @@ package ws import ( + "encoding/json" "sync" "github.com/abhinavxd/libredesk/internal/ws/models" @@ -14,22 +15,40 @@ type Hub struct { clients map[int][]*Client clientsMutex sync.RWMutex - userStore userStore + // Conversation UUID to clients map for faster conversation broadcasting + conversationClients map[string][]*Client + conversationClientsMutex sync.RWMutex + + userStore userStore + conversationStore conversationStore } type userStore interface { UpdateLastActive(userID int) error } +type conversationStore interface { + BroadcastTypingToWidgetClientsOnly(conversationUUID string, isTyping bool) +} + // NewHub creates a new websocket hub. func NewHub(userStore userStore) *Hub { return &Hub{ - clients: make(map[int][]*Client, 10000), - clientsMutex: sync.RWMutex{}, - userStore: userStore, + clients: make(map[int][]*Client, 10000), + clientsMutex: sync.RWMutex{}, + conversationClients: make(map[string][]*Client), + conversationClientsMutex: sync.RWMutex{}, + userStore: userStore, + // To be set later via conversationStore. + conversationStore: nil, } } +// SetConversationStore sets the conversation store for cross-broadcasting. +func (h *Hub) SetConversationStore(manager conversationStore) { + h.conversationStore = manager +} + // AddClient adds a new client to the hub. func (h *Hub) AddClient(client *Client) { h.clientsMutex.Lock() @@ -41,6 +60,12 @@ func (h *Hub) AddClient(client *Client) { func (h *Hub) RemoveClient(client *Client) { h.clientsMutex.Lock() defer h.clientsMutex.Unlock() + + // Remove from all conversation subscriptions + h.conversationClientsMutex.Lock() + h.removeClientFromAllConversations(client) + h.conversationClientsMutex.Unlock() + if clients, ok := h.clients[client.ID]; ok { for i, c := range clients { if c == client { @@ -74,3 +99,99 @@ func (h *Hub) BroadcastMessage(msg models.BroadcastMessage) { } } } + +// SubscribeToConversation subscribes a client to a conversation. +func (h *Hub) SubscribeToConversation(client *Client, conversationUUID string) { + h.conversationClientsMutex.Lock() + defer h.conversationClientsMutex.Unlock() + + // Unsubscribe from previous conversation if any + h.removeClientFromAllConversations(client) + + // Subscribe to new conversation + h.conversationClients[conversationUUID] = append(h.conversationClients[conversationUUID], client) +} + +// UnsubscribeFromConversation unsubscribes a client from a conversation. +func (h *Hub) UnsubscribeFromConversation(client *Client, conversationUUID string) { + h.conversationClientsMutex.Lock() + defer h.conversationClientsMutex.Unlock() + h.unsubscribeFromConversationUnsafe(client, conversationUUID) +} + +// unsubscribeFromConversationUnsafe removes a client from conversation subscription without locking. +// Must be called with conversationClientsMutex held. +func (h *Hub) unsubscribeFromConversationUnsafe(client *Client, conversationUUID string) { + if clients, ok := h.conversationClients[conversationUUID]; ok { + for i, c := range clients { + if c == client { + h.conversationClients[conversationUUID] = append(clients[:i], clients[i+1:]...) + if len(h.conversationClients[conversationUUID]) == 0 { + delete(h.conversationClients, conversationUUID) + } + break + } + } + } +} + +// removeClientFromAllConversations removes a client from all conversation subscriptions. +// Must be called with conversationClientsMutex held. +func (h *Hub) removeClientFromAllConversations(client *Client) { + for conversationUUID, clients := range h.conversationClients { + for i, c := range clients { + if c == client { + h.conversationClients[conversationUUID] = append(clients[:i], clients[i+1:]...) + if len(h.conversationClients[conversationUUID]) == 0 { + delete(h.conversationClients, conversationUUID) + } + break + } + } + } +} + +// BroadcastToConversation broadcasts a message to all clients subscribed to a specific conversation. +func (h *Hub) BroadcastToConversation(conversationUUID string, data []byte) { + h.conversationClientsMutex.RLock() + defer h.conversationClientsMutex.RUnlock() + + for _, client := range h.conversationClients[conversationUUID] { + client.SendMessage(data, websocket.TextMessage) + } +} + +// BroadcastTypingToConversation broadcasts typing status to all clients subscribed to a conversation except the sender. +func (h *Hub) BroadcastTypingToConversation(conversationUUID string, typingMsg models.TypingMessage, sender *Client) { + h.conversationClientsMutex.RLock() + defer h.conversationClientsMutex.RUnlock() + + message := models.Message{ + Type: models.MessageTypeTyping, + Data: typingMsg, + } + + messageBytes, _ := json.Marshal(message) + + for _, client := range h.conversationClients[conversationUUID] { + // Don't send typing indicator back to the sender. + if client != sender { + client.SendMessage(messageBytes, websocket.TextMessage) + } + } + + // Also broadcast to widget clients since this is an agent typing. + if h.conversationStore != nil { + h.conversationStore.BroadcastTypingToWidgetClientsOnly(conversationUUID, typingMsg.IsTyping) + } +} + +// BroadcastTypingToAllConversationClients broadcasts typing status to all clients subscribed to a conversation. +func (h *Hub) BroadcastTypingToAllConversationClients(conversationUUID string, data []byte) { + h.conversationClientsMutex.RLock() + defer h.conversationClientsMutex.RUnlock() + + for _, client := range h.conversationClients[conversationUUID] { + client.SendMessage(data, websocket.TextMessage) + } +} diff --git a/schema.sql b/schema.sql index bfa6b7a9..0c821f4e 100644 --- a/schema.sql +++ b/schema.sql @@ -151,8 +151,12 @@ CREATE TABLE users ( CONSTRAINT constraint_users_on_first_name CHECK (LENGTH(first_name) <= 140), CONSTRAINT constraint_users_on_last_name CHECK (LENGTH(last_name) <= 140) ); -CREATE UNIQUE INDEX index_unique_users_on_email_and_type_when_deleted_at_is_null ON users (email, type) -WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX index_unique_users_on_email_when_type_is_contact + ON users(email) + WHERE type = 'contact' AND deleted_at IS NULL; +CREATE UNIQUE INDEX index_unique_users_on_email_when_type_is_agent + ON users(email) + WHERE type = 'agent' AND deleted_at IS NULL; CREATE INDEX index_tgrm_users_on_email ON users USING GIN (email gin_trgm_ops); CREATE INDEX index_users_on_api_key ON users(api_key); diff --git a/static/widget.js b/static/widget.js index b6524743..76eea84f 100644 --- a/static/widget.js +++ b/static/widget.js @@ -6,11 +6,11 @@ 'use strict'; // Prevent multiple initializations - if (window.LibreDeskWidget) { + if (window.LibredeskWidget) { return; } - class LibreDeskWidget { + class LibredeskWidget { constructor(config = {}) { // Validate required config if (!config.baseUrl) { @@ -23,9 +23,12 @@ this.config = config; this.iframe = null; this.toggleButton = null; + this.widgetButtonWrapper = null; + this.unreadBadge = null; this.isChatVisible = false; this.widgetSettings = null; - + this.unreadCount = 0; + this.isMobile = window.innerWidth <= 600; this.init(); } @@ -33,18 +36,25 @@ try { await this.fetchWidgetSettings(); this.createElements(); + this.setLauncherPosition(); + this.iframe.addEventListener('load', () => { + setTimeout(() => { + this.sendMobileState(); + }, 2000); + }); + this.setupMobileDetection(); this.setupEventListeners(); } catch (error) { - console.error('Failed to initialize LibreDesk Widget:', error); + console.error('Failed to initialize Libredesk Widget:', error); } } async fetchWidgetSettings () { try { - const response = await fetch(`${this.config.baseUrl}/api/v1/widget/chat/settings?inbox_id=${this.config.inboxID}`); + const response = await fetch(`${this.config.baseUrl}/api/v1/widget/chat/settings/launcher?inbox_id=${this.config.inboxID}`); if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + throw new Error(`Error fetching widget settings. Status: ${response.status}`); } const result = await response.json(); @@ -94,6 +104,39 @@ this.toggleButton.appendChild(icon); } + // Create unread badge + this.unreadBadge = document.createElement('div'); + this.unreadBadge.style.cssText = ` + position: absolute; + top: -5px; + right: -5px; + background-color: #ef4444; + color: white; + border-radius: 50%; + width: 20px; + height: 20px; + display: none; + justify-content: center; + align-items: center; + font-size: 12px; + font-weight: bold; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + border: 2px solid white; + box-sizing: border-box; + z-index: 10000; + `; + + const widgetButtonWrapper = document.createElement('div'); + widgetButtonWrapper.style.cssText = ` + position: fixed; + z-index: 9999; + `; + + widgetButtonWrapper.appendChild(this.toggleButton); + widgetButtonWrapper.appendChild(this.unreadBadge); + this.toggleButton.style.position = 'relative'; + this.widgetButtonWrapper = widgetButtonWrapper; + // Create iframe this.iframe = document.createElement('iframe'); this.iframe.src = `${this.config.baseUrl}/widget/?inbox_id=${this.config.inboxID}`; @@ -109,9 +152,19 @@ display: none; `; - document.body.appendChild(this.toggleButton); + document.body.appendChild(this.widgetButtonWrapper); document.body.appendChild(this.iframe); - this.setLauncherPosition(); + } + + sendMobileState () { + this.isMobile = window.innerWidth <= 600; + // Send message to iframe to update mobile state there. + if (this.iframe && this.iframe.contentWindow) { + this.iframe.contentWindow.postMessage({ + type: 'SET_MOBILE_STATE', + isMobile: this.isMobile + }, '*'); + } } setLauncherPosition () { @@ -120,9 +173,9 @@ const position = launcher.position; const side = position === 'right' ? 'right' : 'left'; - // Position toggle button - this.toggleButton.style.bottom = `${spacing.bottom}px`; - this.toggleButton.style[side] = `${spacing.side}px`; + // Position button wrapper (which contains the toggle button and badge) + this.widgetButtonWrapper.style.bottom = `${spacing.bottom}px`; + this.widgetButtonWrapper.style[side] = `${spacing.side}px`; // Position iframe this.iframe.style.bottom = `${spacing.bottom + 80}px`; @@ -131,6 +184,33 @@ setupEventListeners () { this.toggleButton.addEventListener('click', () => this.toggle()); + + // Listen for messages from the iframe (Vue widget app) + window.addEventListener('message', (event) => { + // Verify the message is from our iframe. + if (event.source === this.iframe.contentWindow) { + if (event.data.type === 'CLOSE_WIDGET') { + this.hideChat(); + } else if (event.data.type === 'UPDATE_UNREAD_COUNT') { + this.updateUnreadCount(event.data.count); + } + } + }); + } + + setupMobileDetection () { + window.addEventListener('resize', () => { + this.sendMobileState(); + if (this.isChatVisible) { + this.showChat(); + } + }); + window.addEventListener('orientationchange', () => { + this.sendMobileState(); + if (this.isChatVisible) { + this.showChat(); + } + }); } toggle () { @@ -143,9 +223,36 @@ showChat () { if (this.iframe) { - this.iframe.style.display = 'block'; + this.isMobile = window.innerWidth <= 600; + if (this.isMobile) { + this.iframe.style.display = 'block'; + this.iframe.style.position = 'fixed'; + this.iframe.style.top = '0'; + this.iframe.style.left = '0'; + this.iframe.style.width = '100vw'; + this.iframe.style.height = '100vh'; + this.iframe.style.borderRadius = '0'; + this.iframe.style.boxShadow = 'none'; + this.iframe.style.bottom = ''; + this.iframe.style.right = ''; + this.iframe.style.left = ''; + this.iframe.style.top = '0'; + this.widgetButtonWrapper.style.display = 'none'; + } else { + this.iframe.style.display = 'block'; + this.iframe.style.position = 'fixed'; + this.iframe.style.width = '400px'; + this.iframe.style.height = '700px'; + this.iframe.style.borderRadius = '10px'; + this.iframe.style.boxShadow = '0 4px 20px rgba(0,0,0,0.25)'; + this.iframe.style.top = ''; + this.iframe.style.left = ''; + this.setLauncherPosition(); + this.widgetButtonWrapper.style.display = ''; + } this.isChatVisible = true; this.toggleButton.style.transform = 'scale(0.9)'; + this.unreadBadge.style.display = 'none'; } } @@ -154,13 +261,27 @@ this.iframe.style.display = 'none'; this.isChatVisible = false; this.toggleButton.style.transform = 'scale(1)'; + this.widgetButtonWrapper.style.display = ''; + } + } + + updateUnreadCount (count) { + this.unreadCount = count; + + if (count > 0 && !this.isChatVisible) { + this.unreadBadge.textContent = count > 99 ? '99+' : count.toString(); + this.unreadBadge.style.display = 'flex'; + } else { + this.unreadBadge.style.display = 'none'; } } destroy () { - if (this.toggleButton) { - document.body.removeChild(this.toggleButton); + if (this.widgetButtonWrapper) { + document.body.removeChild(this.widgetButtonWrapper); + this.widgetButtonWrapper = null; this.toggleButton = null; + this.unreadBadge = null; } if (this.iframe) { document.body.removeChild(this.iframe); @@ -171,19 +292,19 @@ } // Global widget instance - window.LibreDeskWidget = LibreDeskWidget; + window.LibredeskWidget = LibredeskWidget; // Auto-initialize if configuration is provided - if (window.libreDeskConfig) { - window.libreDeskWidget = new LibreDeskWidget(window.libreDeskConfig); + if (window.LibredeskConfig) { + window.libreDeskWidget = new LibredeskWidget(window.LibredeskConfig); } window.initLibreDeskWidget = function (config = {}) { if (window.libreDeskWidget) { - console.warn('LibreDesk Widget is already initialized'); + console.warn('Libredesk Widget is already initialized'); return window.libreDeskWidget; } - window.libreDeskWidget = new LibreDeskWidget(config); + window.libreDeskWidget = new LibredeskWidget(config); return window.libreDeskWidget; }; From 74732bfe9120e4cee737d32a04c26163cf956303 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Thu, 17 Jul 2025 01:49:22 +0530 Subject: [PATCH 05/55] feat: Add expand/collapse functionality to chat view --- frontend/apps/widget/src/App.vue | 2 + .../apps/widget/src/components/ChatHeader.vue | 22 ++++++- frontend/apps/widget/src/store/widget.js | 61 +++++++++++++++++++ static/widget.js | 58 +++++++++++++++++- 4 files changed, 138 insertions(+), 5 deletions(-) diff --git a/frontend/apps/widget/src/App.vue b/frontend/apps/widget/src/App.vue index 2d81a6f7..a7425a7c 100644 --- a/frontend/apps/widget/src/App.vue +++ b/frontend/apps/widget/src/App.vue @@ -40,6 +40,8 @@ const setupParentMessageListeners = () => { window.addEventListener('message', (event) => { if (event.data.type === 'SET_MOBILE_STATE') { widgetStore.setMobileFullScreen(event.data.isMobile) + } else if (event.data.type === 'WIDGET_EXPANDED') { + widgetStore.setExpanded(event.data.isExpanded) } }) } diff --git a/frontend/apps/widget/src/components/ChatHeader.vue b/frontend/apps/widget/src/components/ChatHeader.vue index 08da583c..ad3b47b0 100644 --- a/frontend/apps/widget/src/components/ChatHeader.vue +++ b/frontend/apps/widget/src/components/ChatHeader.vue @@ -6,16 +6,32 @@
- - +
+ + + + +
diff --git a/frontend/apps/widget/src/store/widget.js b/frontend/apps/widget/src/store/widget.js index 973f5946..ce7622be 100644 --- a/frontend/apps/widget/src/store/widget.js +++ b/frontend/apps/widget/src/store/widget.js @@ -8,6 +8,8 @@ export const useWidgetStore = defineStore('widget', () => { const config = ref({}) const isInChatView = ref(false) const isMobileFullScreen = ref(false) + const isExpanded = ref(false) + const wasExpandedBeforeLeaving = ref(false) // Getters @@ -26,24 +28,51 @@ export const useWidgetStore = defineStore('widget', () => { } const closeWidget = () => { + // Clear expanded state memory when widget is closed + wasExpandedBeforeLeaving.value = false + isOpen.value = false currentView.value = 'home' isInChatView.value = false + // Auto-collapse when closing widget + if (isExpanded.value) { + collapseWidget() + } } const navigateToChat = () => { currentView.value = 'messages' isInChatView.value = true + // Restore expanded state if it was expanded before leaving + if (wasExpandedBeforeLeaving.value && !isMobileFullScreen.value) { + setTimeout(() => { + expandWidget() + }, 100) + } } const navigateToMessages = () => { + // Remember expanded state before leaving chat view + wasExpandedBeforeLeaving.value = isExpanded.value + currentView.value = 'messages' isInChatView.value = false + // Auto-collapse when leaving chat view + if (isExpanded.value) { + collapseWidget() + } } const navigateToHome = () => { + // Remember expanded state before leaving chat view + wasExpandedBeforeLeaving.value = isExpanded.value + currentView.value = 'home' isInChatView.value = false + // Auto-collapse when leaving chat view + if (isExpanded.value) { + collapseWidget() + } } const updateConfig = (newConfig) => { @@ -54,6 +83,32 @@ export const useWidgetStore = defineStore('widget', () => { isMobileFullScreen.value = isMobile } + const toggleExpand = () => { + if (isExpanded.value) { + collapseWidget() + } else { + expandWidget() + } + } + + const expandWidget = () => { + if (!isMobileFullScreen.value) { + isExpanded.value = true + window.parent.postMessage({ type: 'EXPAND_WIDGET' }, '*') + } + } + + const collapseWidget = () => { + if (!isMobileFullScreen.value) { + isExpanded.value = false + window.parent.postMessage({ type: 'COLLAPSE_WIDGET' }, '*') + } + } + + const setExpanded = (expanded) => { + isExpanded.value = expanded + } + return { // State isOpen, @@ -61,6 +116,8 @@ export const useWidgetStore = defineStore('widget', () => { config, isInChatView, isMobileFullScreen, + isExpanded, + wasExpandedBeforeLeaving, // Getters isHomeView, @@ -76,5 +133,9 @@ export const useWidgetStore = defineStore('widget', () => { navigateToHome, updateConfig, setMobileFullScreen, + toggleExpand, + expandWidget, + collapseWidget, + setExpanded, } }) diff --git a/static/widget.js b/static/widget.js index 76eea84f..00d32680 100644 --- a/static/widget.js +++ b/static/widget.js @@ -29,6 +29,7 @@ this.widgetSettings = null; this.unreadCount = 0; this.isMobile = window.innerWidth <= 600; + this.isExpanded = false; this.init(); } @@ -193,6 +194,10 @@ this.hideChat(); } else if (event.data.type === 'UPDATE_UNREAD_COUNT') { this.updateUnreadCount(event.data.count); + } else if (event.data.type === 'EXPAND_WIDGET') { + this.expandWidget(); + } else if (event.data.type === 'COLLAPSE_WIDGET') { + this.collapseWidget(); } } }); @@ -242,13 +247,21 @@ this.iframe.style.display = 'block'; this.iframe.style.position = 'fixed'; this.iframe.style.width = '400px'; - this.iframe.style.height = '700px'; this.iframe.style.borderRadius = '10px'; this.iframe.style.boxShadow = '0 4px 20px rgba(0,0,0,0.25)'; this.iframe.style.top = ''; this.iframe.style.left = ''; - this.setLauncherPosition(); this.widgetButtonWrapper.style.display = ''; + + // Apply expanded or normal height based on current state + if (this.isExpanded) { + this.iframe.style.height = '100vh'; + this.iframe.style.bottom = '0'; + this.iframe.style.top = '0'; + } else { + this.iframe.style.height = '700px'; + this.setLauncherPosition(); + } } this.isChatVisible = true; this.toggleButton.style.transform = 'scale(0.9)'; @@ -260,6 +273,7 @@ if (this.iframe) { this.iframe.style.display = 'none'; this.isChatVisible = false; + this.isExpanded = false; this.toggleButton.style.transform = 'scale(1)'; this.widgetButtonWrapper.style.display = ''; } @@ -276,6 +290,46 @@ } } + expandWidget () { + if (this.iframe && this.isChatVisible && !this.isMobile) { + this.isExpanded = true; + + // Expand to a larger size (wider and taller) + this.iframe.style.width = '600px'; + this.iframe.style.height = '80vh'; + this.iframe.style.maxHeight = '800px'; + + // Set launcher position to avoid covering it + this.setLauncherPosition(); + + // Send expanded state to iframe + this.iframe.contentWindow.postMessage({ + type: 'WIDGET_EXPANDED', + isExpanded: true + }, '*'); + } + } + + collapseWidget () { + if (this.iframe && this.isChatVisible && !this.isMobile) { + this.isExpanded = false; + + // Reset to original size + this.iframe.style.width = '400px'; + this.iframe.style.height = '700px'; + this.iframe.style.maxHeight = 'none'; + + // Set launcher position to avoid covering it + this.setLauncherPosition(); + + // Send collapsed state to iframe + this.iframe.contentWindow.postMessage({ + type: 'WIDGET_EXPANDED', + isExpanded: false + }, '*'); + } + } + destroy () { if (this.widgetButtonWrapper) { document.body.removeChild(this.widgetButtonWrapper); From 3c3709557eae685554a6612328096b65e7e09685 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Thu, 17 Jul 2025 02:29:05 +0530 Subject: [PATCH 06/55] feat: Add loading indicators to chat components and improve spinner UI --- .../src/components/CSATMessageBubble.vue | 3 +- .../widget/src/components/ChatMessages.vue | 7 ++ .../widget/src/components/MessageInput.vue | 8 +- .../widget/src/components/MessagesList.vue | 5 +- frontend/apps/widget/src/store/chat.js | 21 ++-- .../components/ui/spinner/Spinner.vue | 96 ++++++++++++++----- 6 files changed, 107 insertions(+), 33 deletions(-) diff --git a/frontend/apps/widget/src/components/CSATMessageBubble.vue b/frontend/apps/widget/src/components/CSATMessageBubble.vue index 6eb1ce91..3f0b0f00 100644 --- a/frontend/apps/widget/src/components/CSATMessageBubble.vue +++ b/frontend/apps/widget/src/components/CSATMessageBubble.vue @@ -33,8 +33,9 @@ diff --git a/frontend/apps/widget/src/components/ChatMessages.vue b/frontend/apps/widget/src/components/ChatMessages.vue index 0152975a..858572cb 100644 --- a/frontend/apps/widget/src/components/ChatMessages.vue +++ b/frontend/apps/widget/src/components/ChatMessages.vue @@ -1,5 +1,10 @@