mirror of
				https://github.com/abhinavxd/libredesk.git
				synced 2025-10-31 20:13:36 +00:00 
			
		
		
		
	Compare commits
	
		
			34 Commits
		
	
	
		
			v0.7.0-alp
			...
			refactor-a
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 98492a1869 | ||
|  | 18b50b11c8 | ||
|  | 5a1628f710 | ||
|  | 12ebe32ba3 | ||
|  | fce2587a9d | ||
|  | 7d92ac9cce | ||
|  | 3ce3c5e0ee | ||
|  | 35ad00ec51 | ||
|  | 9ec96be959 | ||
|  | 6ca36d611f | ||
|  | 5a87d24d72 | ||
|  | 7d4e7e68c3 | ||
|  | 5b941fd993 | ||
|  | 63e348e512 | ||
|  | 10a845dc81 | ||
|  | 0228989202 | ||
|  | 3f7d151d33 | ||
|  | a516773b14 | ||
|  | f6d3bd543f | ||
|  | c1c14f7f54 | ||
|  | 634fc66e9f | ||
|  | 0dec822c1c | ||
|  | 958f5e38c0 | ||
|  | 550a3fa801 | ||
|  | 6bbfbe8cf6 | ||
|  | f9ed326d72 | ||
|  | e0dc0285a4 | ||
|  | b971619ea6 | ||
|  | 69accaebef | ||
|  | 27de73536e | ||
|  | df108a3363 | ||
|  | 266c3dab72 | ||
|  | bf2c1fff6f | ||
|  | 2930af0c4f | 
							
								
								
									
										16
									
								
								.github/ISSUE_TEMPLATE/confirmed-bug.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								.github/ISSUE_TEMPLATE/confirmed-bug.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| --- | ||||
| name: Confirmed Bug Report | ||||
| about: Report a confirmed bug in Libredesk | ||||
| title: "[Bug] <brief summary>" | ||||
| labels: bug | ||||
| assignees: "" | ||||
| --- | ||||
|  | ||||
| **Version:** | ||||
| - libredesk: [eg: v0.7.0] | ||||
|  | ||||
| **Description of the bug and steps to reproduce:** | ||||
| A clear and concise description of what the bug is. | ||||
|  | ||||
| **Logs / Screenshots:** | ||||
| Attach any relevant logs or screenshots to help diagnose the issue. | ||||
							
								
								
									
										16
									
								
								.github/ISSUE_TEMPLATE/possible-bug.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								.github/ISSUE_TEMPLATE/possible-bug.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| --- | ||||
| name: Possible Bug Report | ||||
| about: Something in Libredesk might be broken but needs confirmation | ||||
| title: "[Possible Bug] <brief summary>" | ||||
| labels: bug, needs-investigation | ||||
| assignees: "" | ||||
| --- | ||||
|  | ||||
| **Version:** | ||||
|  - libredesk: [eg: v0.7.0] | ||||
|   | ||||
| **Description of the bug and steps to reproduce:** | ||||
| A clear and concise description of what the bug is. | ||||
|  | ||||
| **Logs / Screenshots:** | ||||
| Attach any relevant logs or screenshots to help diagnose the issue. | ||||
							
								
								
									
										63
									
								
								cmd/config.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								cmd/config.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
|  | ||||
| 	"github.com/abhinavxd/libredesk/internal/envelope" | ||||
| 	"github.com/zerodha/fastglue" | ||||
| ) | ||||
|  | ||||
| // handleGetConfig returns the public configuration needed for app initialization, this includes minimal app settings and enabled SSO providers (without secrets). | ||||
| func handleGetConfig(r *fastglue.Request) error { | ||||
| 	var app = r.Context.(*App) | ||||
|  | ||||
| 	// Get app settings | ||||
| 	settingsJSON, err := app.setting.GetByPrefix("app") | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	// Unmarshal settings | ||||
| 	var settings map[string]any | ||||
| 	if err := json.Unmarshal(settingsJSON, &settings); err != nil { | ||||
| 		app.lo.Error("error unmarshalling settings", "err", err) | ||||
| 		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", app.i18n.T("globals.terms.setting")), nil)) | ||||
| 	} | ||||
|  | ||||
| 	// Filter to only include public fields needed for initial app load | ||||
| 	publicSettings := map[string]any{ | ||||
| 		"app.lang":        settings["app.lang"], | ||||
| 		"app.favicon_url": settings["app.favicon_url"], | ||||
| 		"app.logo_url":    settings["app.logo_url"], | ||||
| 		"app.site_name":   settings["app.site_name"], | ||||
| 	} | ||||
|  | ||||
| 	// Get all OIDC providers | ||||
| 	oidcProviders, err := app.oidc.GetAll() | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	// Filter for enabled providers and remove client_secret | ||||
| 	enabledProviders := make([]map[string]any, 0) | ||||
| 	for _, provider := range oidcProviders { | ||||
| 		if provider.Enabled { | ||||
| 			providerMap := map[string]any{ | ||||
| 				"id":           provider.ID, | ||||
| 				"name":         provider.Name, | ||||
| 				"provider":     provider.Provider, | ||||
| 				"provider_url": provider.ProviderURL, | ||||
| 				"client_id":    provider.ClientID, | ||||
| 				"logo_url":     provider.ProviderLogoURL, | ||||
| 				"enabled":      provider.Enabled, | ||||
| 				"redirect_uri": provider.RedirectURI, | ||||
| 			} | ||||
| 			enabledProviders = append(enabledProviders, providerMap) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Add SSO providers to the response | ||||
| 	publicSettings["app.sso_providers"] = enabledProviders | ||||
|  | ||||
| 	return r.SendEnvelope(publicSettings) | ||||
| } | ||||
| @@ -164,11 +164,17 @@ func handleUpdateContact(r *fastglue.Request) error { | ||||
| 	// Upload avatar? | ||||
| 	files, ok := form.File["files"] | ||||
| 	if ok && len(files) > 0 { | ||||
| 		if err := uploadUserAvatar(r, &contact, files); err != nil { | ||||
| 		if err := uploadUserAvatar(r, contact, files); err != nil { | ||||
| 			return sendErrorEnvelope(r, err) | ||||
| 		} | ||||
| 	} | ||||
| 	return r.SendEnvelope(true) | ||||
|  | ||||
| 	// Refetch contact and return it | ||||
| 	contact, err = app.user.GetContact(id, "") | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	return r.SendEnvelope(contact) | ||||
| } | ||||
|  | ||||
| // handleGetContactNotes returns all notes for a contact. | ||||
| @@ -195,18 +201,21 @@ func handleCreateContactNote(r *fastglue.Request) error { | ||||
| 		auser        = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 		req          = createContactNoteReq{} | ||||
| 	) | ||||
|  | ||||
| 	if err := r.Decode(&req, "json"); err != nil { | ||||
| 		return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil)) | ||||
| 	} | ||||
|  | ||||
| 	if len(req.Note) == 0 { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "note"), nil, envelope.InputError) | ||||
| 	} | ||||
| 	if err := app.user.CreateNote(contactID, auser.ID, req.Note); err != nil { | ||||
| 	n, err := app.user.CreateNote(contactID, auser.ID, req.Note) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	return r.SendEnvelope(true) | ||||
| 	n, err = app.user.GetNote(n.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	return r.SendEnvelope(n) | ||||
| } | ||||
|  | ||||
| // handleDeleteContactNote deletes a note for a contact. | ||||
| @@ -240,6 +249,8 @@ func handleDeleteContactNote(r *fastglue.Request) error { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	app.lo.Info("deleting contact note", "note_id", noteID, "contact_id", contactID, "actor_id", auser.ID) | ||||
|  | ||||
| 	if err := app.user.DeleteNote(noteID, contactID); err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -251,6 +262,7 @@ func handleBlockContact(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app          = r.Context.(*App) | ||||
| 		contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) | ||||
| 		auser        = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 		req          = blockContactReq{} | ||||
| 	) | ||||
|  | ||||
| @@ -262,8 +274,15 @@ func handleBlockContact(r *fastglue.Request) error { | ||||
| 		return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil)) | ||||
| 	} | ||||
|  | ||||
| 	app.lo.Info("setting contact block status", "contact_id", contactID, "enabled", req.Enabled, "actor_id", auser.ID) | ||||
|  | ||||
| 	if err := app.user.ToggleEnabled(contactID, models.UserTypeContact, req.Enabled); err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	return r.SendEnvelope(true) | ||||
|  | ||||
| 	contact, err := app.user.GetContact(contactID, "") | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	return r.SendEnvelope(contact) | ||||
| } | ||||
|   | ||||
| @@ -49,6 +49,7 @@ type createConversationRequest struct { | ||||
| 	Subject         string `json:"subject"` | ||||
| 	Content         string `json:"content"` | ||||
| 	Attachments     []int  `json:"attachments"` | ||||
| 	Initiator       string `json:"initiator"` // "contact" | "agent" | ||||
| } | ||||
|  | ||||
| // handleGetAllConversations retrieves all conversations. | ||||
| @@ -273,8 +274,8 @@ func handleGetConversation(r *fastglue.Request) error { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	prev, _ := app.conversation.GetContactConversations(conv.ContactID) | ||||
| 	conv.PreviousConversations = filterCurrentConv(prev, conv.UUID) | ||||
| 	prev, _ := app.conversation.GetContactPreviousConversations(conv.ContactID, 10) | ||||
| 	conv.PreviousConversations = filterCurrentPreviousConv(prev, conv.UUID) | ||||
| 	return r.SendEnvelope(conv) | ||||
| } | ||||
|  | ||||
| @@ -649,14 +650,14 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error { | ||||
| 	return r.SendEnvelope(true) | ||||
| } | ||||
|  | ||||
| // filterCurrentConv removes the current conversation from the list of conversations. | ||||
| func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conversation { | ||||
| // filterCurrentPreviousConv removes the current conversation from the list of previous conversations. | ||||
| func filterCurrentPreviousConv(convs []cmodels.PreviousConversation, uuid string) []cmodels.PreviousConversation { | ||||
| 	for i, c := range convs { | ||||
| 		if c.UUID == uuid { | ||||
| 			return append(convs[:i], convs[i+1:]...) | ||||
| 		} | ||||
| 	} | ||||
| 	return []cmodels.Conversation{} | ||||
| 	return []cmodels.PreviousConversation{} | ||||
| } | ||||
|  | ||||
| // handleCreateConversation creates a new conversation and sends a message to it. | ||||
| @@ -672,39 +673,17 @@ func handleCreateConversation(r *fastglue.Request) error { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	// Validate the request | ||||
| 	if err := validateCreateConversationRequest(req, app); err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	to := []string{req.Email} | ||||
|  | ||||
| 	// Validate required fields | ||||
| 	if req.InboxID <= 0 { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil, envelope.InputError) | ||||
| 	} | ||||
| 	if req.Content == "" { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil, envelope.InputError) | ||||
| 	} | ||||
| 	if req.Email == "" { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil, envelope.InputError) | ||||
| 	} | ||||
| 	if req.FirstName == "" { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil, envelope.InputError) | ||||
| 	} | ||||
| 	if !stringutil.ValidEmail(req.Email) { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	user, err := app.user.GetAgent(auser.ID, "") | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	// Check if inbox exists and is enabled. | ||||
| 	inbox, err := app.inbox.GetDBRecord(req.InboxID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	if !inbox.Enabled { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "inbox"), nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	// Find or create contact. | ||||
| 	contact := umodels.User{ | ||||
| 		Email:           null.StringFrom(req.Email), | ||||
| @@ -717,7 +696,7 @@ func handleCreateConversation(r *fastglue.Request) error { | ||||
| 		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil)) | ||||
| 	} | ||||
|  | ||||
| 	// Create conversation | ||||
| 	// Create conversation first. | ||||
| 	conversationID, conversationUUID, err := app.conversation.CreateConversation( | ||||
| 		contact.ID, | ||||
| 		contact.ContactChannelID, | ||||
| @@ -725,14 +704,14 @@ func handleCreateConversation(r *fastglue.Request) error { | ||||
| 		"",         /** last_message **/ | ||||
| 		time.Now(), /** last_message_at **/ | ||||
| 		req.Subject, | ||||
| 		true, /** append reference number to subject **/ | ||||
| 		true, /** append reference number to subject? **/ | ||||
| 	) | ||||
| 	if err != nil { | ||||
| 		app.lo.Error("error creating conversation", "error", err) | ||||
| 		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil)) | ||||
| 	} | ||||
|  | ||||
| 	// Prepare attachments. | ||||
| 	// Get media for the attachment ids. | ||||
| 	var media = make([]medModels.Media, 0, len(req.Attachments)) | ||||
| 	for _, id := range req.Attachments { | ||||
| 		m, err := app.media.Get(id, "") | ||||
| @@ -743,13 +722,29 @@ func handleCreateConversation(r *fastglue.Request) error { | ||||
| 		media = append(media, m) | ||||
| 	} | ||||
|  | ||||
| 	// Send reply to the created conversation. | ||||
| 	if _, err := app.conversation.SendReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil { | ||||
| 		// Delete the conversation if reply fails. | ||||
| 		if err := app.conversation.DeleteConversation(conversationUUID); err != nil { | ||||
| 			app.lo.Error("error deleting conversation", "error", err) | ||||
| 	// Send initial message based on the initiator of conversation. | ||||
| 	switch req.Initiator { | ||||
| 	case umodels.UserTypeAgent: | ||||
| 		// Queue reply. | ||||
| 		if _, err := app.conversation.QueueReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil { | ||||
| 			// Delete the conversation if msg queue fails. | ||||
| 			if err := app.conversation.DeleteConversation(conversationUUID); err != nil { | ||||
| 				app.lo.Error("error deleting conversation", "error", err) | ||||
| 			} | ||||
| 			return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil)) | ||||
| 		} | ||||
| 		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil)) | ||||
| 	case umodels.UserTypeContact: | ||||
| 		// Create message on behalf of contact. | ||||
| 		if _, err := app.conversation.CreateContactMessage(media, contact.ID, conversationUUID, req.Content, cmodels.ContentTypeHTML); err != nil { | ||||
| 			// Delete the conversation if message creation fails. | ||||
| 			if err := app.conversation.DeleteConversation(conversationUUID); err != nil { | ||||
| 				app.lo.Error("error deleting conversation", "error", err) | ||||
| 			} | ||||
| 			return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil)) | ||||
| 		} | ||||
| 	default: | ||||
| 		// Guard anyway. | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`initiator`"), nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	// Assign the conversation to the agent or team. | ||||
| @@ -768,3 +763,36 @@ func handleCreateConversation(r *fastglue.Request) error { | ||||
|  | ||||
| 	return r.SendEnvelope(conversation) | ||||
| } | ||||
|  | ||||
| // validateCreateConversationRequest validates the create conversation request fields. | ||||
| func validateCreateConversationRequest(req createConversationRequest, app *App) error { | ||||
| 	if req.InboxID <= 0 { | ||||
| 		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil) | ||||
| 	} | ||||
| 	if req.Content == "" { | ||||
| 		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil) | ||||
| 	} | ||||
| 	if req.Email == "" { | ||||
| 		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil) | ||||
| 	} | ||||
| 	if req.FirstName == "" { | ||||
| 		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil) | ||||
| 	} | ||||
| 	if !stringutil.ValidEmail(req.Email) { | ||||
| 		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil) | ||||
| 	} | ||||
| 	if req.Initiator != umodels.UserTypeContact && req.Initiator != umodels.UserTypeAgent { | ||||
| 		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`initiator`"), nil) | ||||
| 	} | ||||
|  | ||||
| 	// Check if inbox exists and is enabled. | ||||
| 	inbox, err := app.inbox.GetDBRecord(req.InboxID) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if !inbox.Enabled { | ||||
| 		return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.disabled", "name", "inbox"), nil) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|   | ||||
							
								
								
									
										20
									
								
								cmd/csat.go
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								cmd/csat.go
									
									
									
									
									
								
							| @@ -17,7 +17,7 @@ func handleShowCSAT(r *fastglue.Request) error { | ||||
| 	if err != nil { | ||||
| 		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ | ||||
| 			"Data": map[string]interface{}{ | ||||
| 				"ErrorMessage": "Page not found", | ||||
| 				"ErrorMessage": app.i18n.T("globals.messages.pageNotFound"), | ||||
| 			}, | ||||
| 		}) | ||||
| 	} | ||||
| @@ -25,8 +25,8 @@ func handleShowCSAT(r *fastglue.Request) error { | ||||
| 	if csat.ResponseTimestamp.Valid { | ||||
| 		return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{ | ||||
| 			"Data": map[string]interface{}{ | ||||
| 				"Title":   "Thank you!", | ||||
| 				"Message": "We appreciate you taking the time to submit your feedback.", | ||||
| 				"Title":   app.i18n.T("globals.messages.thankYou"), | ||||
| 				"Message": app.i18n.T("csat.thankYouMessage"), | ||||
| 			}, | ||||
| 		}) | ||||
| 	} | ||||
| @@ -35,14 +35,14 @@ func handleShowCSAT(r *fastglue.Request) error { | ||||
| 	if err != nil { | ||||
| 		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ | ||||
| 			"Data": map[string]interface{}{ | ||||
| 				"ErrorMessage": "Page not found", | ||||
| 				"ErrorMessage": app.i18n.T("globals.messages.pageNotFound"), | ||||
| 			}, | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| 	return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{ | ||||
| 		"Data": map[string]interface{}{ | ||||
| 			"Title":    "Rate your interaction with us", | ||||
| 			"Title": app.i18n.T("csat.pageTitle"), | ||||
| 			"CSAT": map[string]interface{}{ | ||||
| 				"UUID": csat.UUID, | ||||
| 			}, | ||||
| @@ -67,7 +67,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error { | ||||
| 	if err != nil { | ||||
| 		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ | ||||
| 			"Data": map[string]interface{}{ | ||||
| 				"ErrorMessage": "Invalid `rating`", | ||||
| 				"ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"), | ||||
| 			}, | ||||
| 		}) | ||||
| 	} | ||||
| @@ -75,7 +75,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error { | ||||
| 	if ratingI < 1 || ratingI > 5 { | ||||
| 		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ | ||||
| 			"Data": map[string]interface{}{ | ||||
| 				"ErrorMessage": "Invalid `rating`", | ||||
| 				"ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"), | ||||
| 			}, | ||||
| 		}) | ||||
| 	} | ||||
| @@ -83,7 +83,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error { | ||||
| 	if uuid == "" { | ||||
| 		return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ | ||||
| 			"Data": map[string]interface{}{ | ||||
| 				"ErrorMessage": "Invalid `uuid`", | ||||
| 				"ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"), | ||||
| 			}, | ||||
| 		}) | ||||
| 	} | ||||
| @@ -98,8 +98,8 @@ func handleUpdateCSATResponse(r *fastglue.Request) error { | ||||
|  | ||||
| 	return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{ | ||||
| 		"Data": map[string]interface{}{ | ||||
| 			"Title":   "Thank you!", | ||||
| 			"Message": "We appreciate you taking the time to submit your feedback.", | ||||
| 			"Title":   app.i18n.T("globals.messages.thankYou"), | ||||
| 			"Message": app.i18n.T("csat.thankYouMessage"), | ||||
| 		}, | ||||
| 	}) | ||||
| } | ||||
|   | ||||
| @@ -23,18 +23,20 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { | ||||
| 	// i18n. | ||||
| 	g.GET("/api/v1/lang/{lang}", handleGetI18nLang) | ||||
|  | ||||
| 	// Public config for app initialization. | ||||
| 	g.GET("/api/v1/config", handleGetConfig) | ||||
|  | ||||
| 	// Media. | ||||
| 	g.GET("/uploads/{uuid}", auth(handleServeMedia)) | ||||
| 	g.POST("/api/v1/media", auth(handleMediaUpload)) | ||||
|  | ||||
| 	// Settings. | ||||
| 	g.GET("/api/v1/settings/general", handleGetGeneralSettings) | ||||
| 	g.GET("/api/v1/settings/general", auth(handleGetGeneralSettings)) | ||||
| 	g.PUT("/api/v1/settings/general", perm(handleUpdateGeneralSettings, "general_settings:manage")) | ||||
| 	g.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "notification_settings:manage")) | ||||
| 	g.PUT("/api/v1/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "notification_settings:manage")) | ||||
|  | ||||
| 	// OpenID connect single sign-on. | ||||
| 	g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC) | ||||
| 	g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage")) | ||||
| 	g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage")) | ||||
| 	g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage")) | ||||
|   | ||||
| @@ -17,6 +17,12 @@ func handleGetInboxes(r *fastglue.Request) error { | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	for i := range inboxes { | ||||
| 		if err := inboxes[i].ClearPasswords(); err != nil { | ||||
| 			app.lo.Error("error clearing inbox passwords from response", "error", err) | ||||
| 			return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil) | ||||
| 		} | ||||
| 	} | ||||
| 	return r.SendEnvelope(inboxes) | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										21
									
								
								cmd/init.go
									
									
									
									
									
								
							
							
						
						
									
										21
									
								
								cmd/init.go
									
									
									
									
									
								
							| @@ -250,11 +250,12 @@ func initTag(db *sqlx.DB, i18n *i18n.I18n) *tag.Manager { | ||||
| } | ||||
|  | ||||
| // initViews inits view manager. | ||||
| func initView(db *sqlx.DB) *view.Manager { | ||||
| func initView(db *sqlx.DB, i18n *i18n.I18n) *view.Manager { | ||||
| 	var lo = initLogger("view_manager") | ||||
| 	m, err := view.New(view.Opts{ | ||||
| 		DB: db, | ||||
| 		Lo: lo, | ||||
| 		DB:   db, | ||||
| 		Lo:   lo, | ||||
| 		I18n: i18n, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		log.Fatalf("error initializing view manager: %v", err) | ||||
| @@ -327,7 +328,7 @@ func initWS(user *user.Manager) *ws.Hub { | ||||
| func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *i18n.I18n) *tmpl.Manager { | ||||
| 	var ( | ||||
| 		lo      = initLogger("template") | ||||
| 		funcMap = getTmplFuncs(consts) | ||||
| 		funcMap = getTmplFuncs(consts, i18n) | ||||
| 	) | ||||
| 	tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html") | ||||
| 	if err != nil { | ||||
| @@ -345,7 +346,7 @@ func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n * | ||||
| } | ||||
|  | ||||
| // getTmplFuncs returns the template functions. | ||||
| func getTmplFuncs(consts *constants) template.FuncMap { | ||||
| func getTmplFuncs(consts *constants, i18n *i18n.I18n) template.FuncMap { | ||||
| 	return template.FuncMap{ | ||||
| 		"RootURL": func() string { | ||||
| 			return consts.AppBaseURL | ||||
| @@ -365,6 +366,9 @@ func getTmplFuncs(consts *constants) template.FuncMap { | ||||
| 		"SiteName": func() string { | ||||
| 			return consts.SiteName | ||||
| 		}, | ||||
| 		"L": func() interface{} { | ||||
| 			return i18n | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @@ -381,7 +385,10 @@ func reloadSettings(app *App) error { | ||||
| 		app.lo.Error("error unmarshalling settings from DB", "error", err) | ||||
| 		return err | ||||
| 	} | ||||
| 	if err := ko.Load(confmap.Provider(out, "."), nil); err != nil { | ||||
| 	app.Lock() | ||||
| 	err = ko.Load(confmap.Provider(out, "."), nil) | ||||
| 	app.Unlock() | ||||
| 	if err != nil { | ||||
| 		app.lo.Error("error loading settings into koanf", "error", err) | ||||
| 		return err | ||||
| 	} | ||||
| @@ -393,7 +400,7 @@ func reloadSettings(app *App) error { | ||||
| // reloadTemplates reloads the templates from the filesystem. | ||||
| func reloadTemplates(app *App) error { | ||||
| 	app.lo.Info("reloading templates") | ||||
| 	funcMap := getTmplFuncs(app.consts.Load().(*constants)) | ||||
| 	funcMap := getTmplFuncs(app.consts.Load().(*constants), app.i18n) | ||||
| 	tpls, err := stuffbin.ParseTemplatesGlob(funcMap, app.fs, "/static/email-templates/*.html") | ||||
| 	if err != nil { | ||||
| 		app.lo.Error("error parsing email templates", "error", err) | ||||
|   | ||||
| @@ -3,7 +3,6 @@ package main | ||||
| import ( | ||||
| 	amodels "github.com/abhinavxd/libredesk/internal/auth/models" | ||||
| 	"github.com/abhinavxd/libredesk/internal/envelope" | ||||
| 	umodels "github.com/abhinavxd/libredesk/internal/user/models" | ||||
| 	realip "github.com/ferluci/fast-realip" | ||||
| 	"github.com/valyala/fasthttp" | ||||
| 	"github.com/zerodha/fastglue" | ||||
| @@ -42,12 +41,6 @@ func handleLogin(r *fastglue.Request) error { | ||||
| 		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.accountDisabled"), nil)) | ||||
| 	} | ||||
|  | ||||
| 	// Set user availability status to online. | ||||
| 	if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	user.AvailabilityStatus = umodels.Online | ||||
|  | ||||
| 	if err := app.auth.SaveSession(amodels.User{ | ||||
| 		ID:        user.ID, | ||||
| 		Email:     user.Email.String, | ||||
|   | ||||
| @@ -97,6 +97,8 @@ type App struct { | ||||
|  | ||||
| 	// Global state that stores data on an available app update. | ||||
| 	update *AppUpdate | ||||
| 	// Flag to indicate if app restart is required for settings to take effect. | ||||
| 	restartRequired bool | ||||
| 	sync.Mutex | ||||
| } | ||||
|  | ||||
| @@ -239,7 +241,7 @@ func main() { | ||||
| 		activityLog:     initActivityLog(db, i18n), | ||||
| 		customAttribute: initCustomAttribute(db, i18n), | ||||
| 		authz:           initAuthz(i18n), | ||||
| 		view:            initView(db), | ||||
| 		view:            initView(db, i18n), | ||||
| 		report:          initReport(db, i18n), | ||||
| 		csat:            initCSAT(db, i18n), | ||||
| 		search:          initSearch(db, i18n), | ||||
|   | ||||
| @@ -99,7 +99,7 @@ func handleGetMessage(r *fastglue.Request) error { | ||||
| 	return r.SendEnvelope(message) | ||||
| } | ||||
|  | ||||
| // handleRetryMessage changes message status so it can be retried for sending. | ||||
| // handleRetryMessage changes message status to `pending`, so it's enqueued for sending. | ||||
| func handleRetryMessage(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app   = r.Context.(*App) | ||||
| @@ -168,7 +168,7 @@ func handleSendMessage(r *fastglue.Request) error { | ||||
| 		} | ||||
| 		return r.SendEnvelope(message) | ||||
| 	} | ||||
| 	message, err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/) | ||||
| 	message, err := app.conversation.QueueReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|   | ||||
							
								
								
									
										18
									
								
								cmd/oidc.go
									
									
									
									
									
								
							
							
						
						
									
										18
									
								
								cmd/oidc.go
									
									
									
									
									
								
							| @@ -11,16 +11,6 @@ import ( | ||||
| 	"github.com/zerodha/fastglue" | ||||
| ) | ||||
|  | ||||
| // handleGetAllEnabledOIDC returns all enabled OIDC records | ||||
| func handleGetAllEnabledOIDC(r *fastglue.Request) error { | ||||
| 	app := r.Context.(*App) | ||||
| 	out, err := app.oidc.GetAllEnabled() | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| 	return r.SendEnvelope(out) | ||||
| } | ||||
|  | ||||
| // handleGetAllOIDC returns all OIDC records | ||||
| func handleGetAllOIDC(r *fastglue.Request) error { | ||||
| 	app := r.Context.(*App) | ||||
| @@ -74,10 +64,10 @@ func handleCreateOIDC(r *fastglue.Request) error { | ||||
| 	if err := reloadAuth(app); err != nil { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError) | ||||
| 	} | ||||
| 	 | ||||
|  | ||||
| 	// Clear client secret before returning | ||||
| 	createdOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10) | ||||
| 	 | ||||
|  | ||||
| 	return r.SendEnvelope(createdOIDC) | ||||
| } | ||||
|  | ||||
| @@ -110,10 +100,10 @@ func handleUpdateOIDC(r *fastglue.Request) error { | ||||
| 	if err := reloadAuth(app); err != nil { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError) | ||||
| 	} | ||||
| 	 | ||||
|  | ||||
| 	// Clear client secret before returning | ||||
| 	updatedOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10) | ||||
| 	 | ||||
|  | ||||
| 	return r.SendEnvelope(updatedOIDC) | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -31,6 +31,8 @@ func handleGetGeneralSettings(r *fastglue.Request) error { | ||||
| 	settings["app.update"] = app.update | ||||
| 	// Set app version. | ||||
| 	settings["app.version"] = versionString | ||||
| 	// Set restart required flag. | ||||
| 	settings["app.restart_required"] = app.restartRequired | ||||
| 	return r.SendEnvelope(settings) | ||||
| } | ||||
|  | ||||
| @@ -45,6 +47,11 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	// Get current language before update. | ||||
| 	app.Lock() | ||||
| 	oldLang := ko.String("app.lang") | ||||
| 	app.Unlock() | ||||
|  | ||||
| 	// Remove any trailing slash `/` from the root url. | ||||
| 	req.RootURL = strings.TrimRight(req.RootURL, "/") | ||||
|  | ||||
| @@ -55,6 +62,17 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error { | ||||
| 	if err := reloadSettings(app); err != nil { | ||||
| 		return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil) | ||||
| 	} | ||||
|  | ||||
| 	// Check if language changed and reload i18n if needed. | ||||
| 	app.Lock() | ||||
| 	newLang := ko.String("app.lang") | ||||
| 	if oldLang != newLang { | ||||
| 		app.lo.Info("language changed, reloading i18n", "old_lang", oldLang, "new_lang", newLang) | ||||
| 		app.i18n = initI18n(app.fs) | ||||
| 		app.lo.Info("reloaded i18n", "old_lang", oldLang, "new_lang", newLang) | ||||
| 	} | ||||
| 	app.Unlock() | ||||
|  | ||||
| 	if err := reloadTemplates(app); err != nil { | ||||
| 		return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil) | ||||
| 	} | ||||
| @@ -109,6 +127,7 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.invalidFromAddress"), nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	// If empty then retain previous password. | ||||
| 	if req.Password == "" { | ||||
| 		req.Password = cur.Password | ||||
| 	} | ||||
| @@ -117,6 +136,10 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	// No reload implemented, so user has to restart the app. | ||||
| 	// Email notification settings require app restart to take effect. | ||||
| 	app.Lock() | ||||
| 	app.restartRequired = true | ||||
| 	app.Unlock() | ||||
|  | ||||
| 	return r.SendEnvelope(true) | ||||
| } | ||||
|   | ||||
| @@ -83,7 +83,7 @@ func handleUpdateTeam(r *fastglue.Request) error { | ||||
| 		return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil)) | ||||
| 	} | ||||
|  | ||||
| 	updatedTeam, err := app.team.Update(id, req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations); | ||||
| 	updatedTeam, err := app.team.Update(id, req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|   | ||||
							
								
								
									
										220
									
								
								cmd/users.go
									
									
									
									
									
								
							
							
						
						
									
										220
									
								
								cmd/users.go
									
									
									
									
									
								
							| @@ -26,34 +26,38 @@ const ( | ||||
| 	maxAvatarSizeMB = 2 | ||||
| ) | ||||
|  | ||||
| // Request structs for user-related endpoints | ||||
|  | ||||
| // UpdateAvailabilityRequest represents the request to update user availability | ||||
| type UpdateAvailabilityRequest struct { | ||||
| type updateAvailabilityRequest struct { | ||||
| 	Status string `json:"status"` | ||||
| } | ||||
|  | ||||
| // ResetPasswordRequest represents the password reset request | ||||
| type ResetPasswordRequest struct { | ||||
| type resetPasswordRequest struct { | ||||
| 	Email string `json:"email"` | ||||
| } | ||||
|  | ||||
| // SetPasswordRequest represents the set password request | ||||
| type SetPasswordRequest struct { | ||||
| type setPasswordRequest struct { | ||||
| 	Token    string `json:"token"` | ||||
| 	Password string `json:"password"` | ||||
| } | ||||
|  | ||||
| // AvailabilityRequest represents the request to update agent availability | ||||
| type AvailabilityRequest struct { | ||||
| type availabilityRequest struct { | ||||
| 	Status string `json:"status"` | ||||
| } | ||||
|  | ||||
| type agentReq struct { | ||||
| 	FirstName          string   `json:"first_name"` | ||||
| 	LastName           string   `json:"last_name"` | ||||
| 	Email              string   `json:"email"` | ||||
| 	SendWelcomeEmail   bool     `json:"send_welcome_email"` | ||||
| 	Teams              []string `json:"teams"` | ||||
| 	Roles              []string `json:"roles"` | ||||
| 	Enabled            bool     `json:"enabled"` | ||||
| 	AvailabilityStatus string   `json:"availability_status"` | ||||
| 	NewPassword        string   `json:"new_password,omitempty"` | ||||
| } | ||||
|  | ||||
| // handleGetAgents returns all agents. | ||||
| func handleGetAgents(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app = r.Context.(*App) | ||||
| 	) | ||||
| 	var app = r.Context.(*App) | ||||
| 	agents, err := app.user.GetAgents() | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| @@ -73,9 +77,7 @@ func handleGetAgentsCompact(r *fastglue.Request) error { | ||||
|  | ||||
| // handleGetAgent returns an agent. | ||||
| func handleGetAgent(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app = r.Context.(*App) | ||||
| 	) | ||||
| 	var app = r.Context.(*App) | ||||
| 	id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) | ||||
| 	if err != nil || id <= 0 { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) | ||||
| @@ -93,7 +95,7 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error { | ||||
| 		app      = r.Context.(*App) | ||||
| 		auser    = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 		ip       = realip.FromRequest(r.RequestCtx) | ||||
| 		availReq AvailabilityRequest | ||||
| 		availReq availabilityRequest | ||||
| 	) | ||||
|  | ||||
| 	// Decode JSON request | ||||
| @@ -101,6 +103,7 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	// Fetch entire agent | ||||
| 	agent, err := app.user.GetAgent(auser.ID, "") | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| @@ -108,10 +111,10 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error { | ||||
|  | ||||
| 	// Same status? | ||||
| 	if agent.AvailabilityStatus == availReq.Status { | ||||
| 		return r.SendEnvelope(true) | ||||
| 		return r.SendEnvelope(agent) | ||||
| 	} | ||||
|  | ||||
| 	// Update availability status. | ||||
| 	// Update availability status | ||||
| 	if err := app.user.UpdateAvailability(auser.ID, availReq.Status); err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -123,21 +126,22 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return r.SendEnvelope(true) | ||||
| 	// Fetch updated agent and return | ||||
| 	agent, err = app.user.GetAgent(auser.ID, "") | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	return r.SendEnvelope(agent) | ||||
| } | ||||
|  | ||||
| // handleGetCurrentAgentTeams returns the teams of an agent. | ||||
| // handleGetCurrentAgentTeams returns the teams of current agent. | ||||
| func handleGetCurrentAgentTeams(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app   = r.Context.(*App) | ||||
| 		auser = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 	) | ||||
| 	agent, err := app.user.GetAgent(auser.ID, "") | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	teams, err := app.team.GetUserTeams(agent.ID) | ||||
| 	teams, err := app.team.GetUserTeams(auser.ID) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -150,11 +154,6 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error { | ||||
| 		app   = r.Context.(*App) | ||||
| 		auser = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 	) | ||||
| 	agent, err := app.user.GetAgent(auser.ID, "") | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	form, err := r.RequestCtx.MultipartForm() | ||||
| 	if err != nil { | ||||
| 		app.lo.Error("error parsing form data", "error", err) | ||||
| @@ -165,54 +164,53 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error { | ||||
|  | ||||
| 	// Upload avatar? | ||||
| 	if ok && len(files) > 0 { | ||||
| 		if err := uploadUserAvatar(r, &agent, files); err != nil { | ||||
| 		agent, err := app.user.GetAgent(auser.ID, "") | ||||
| 		if err != nil { | ||||
| 			return sendErrorEnvelope(r, err) | ||||
| 		} | ||||
| 		if err := uploadUserAvatar(r, agent, files); err != nil { | ||||
| 			return sendErrorEnvelope(r, err) | ||||
| 		} | ||||
| 	} | ||||
| 	return r.SendEnvelope(true) | ||||
|  | ||||
| 	// Fetch updated agent and return. | ||||
| 	agent, err := app.user.GetAgent(auser.ID, "") | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	return r.SendEnvelope(agent) | ||||
| } | ||||
|  | ||||
| // handleCreateAgent creates a new agent. | ||||
| func handleCreateAgent(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app  = r.Context.(*App) | ||||
| 		user = models.User{} | ||||
| 		app = r.Context.(*App) | ||||
| 		req = agentReq{} | ||||
| 	) | ||||
| 	if err := r.Decode(&user, "json"); err != nil { | ||||
|  | ||||
| 	if err := r.Decode(&req, "json"); err != nil { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	if user.Email.String == "" { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError) | ||||
| 	} | ||||
| 	user.Email = null.StringFrom(strings.TrimSpace(strings.ToLower(user.Email.String))) | ||||
|  | ||||
| 	if !stringutil.ValidEmail(user.Email.String) { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError) | ||||
| 	// Validate agent request | ||||
| 	if err := validateAgentRequest(r, &req); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if user.Roles == nil { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	if user.FirstName == "" { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	if err := app.user.CreateAgent(&user); err != nil { | ||||
| 	agent, err := app.user.CreateAgent(req.FirstName, req.LastName, req.Email, req.Roles) | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	// Upsert user teams. | ||||
| 	if len(user.Teams) > 0 { | ||||
| 		if err := app.team.UpsertUserTeams(user.ID, user.Teams.Names()); err != nil { | ||||
| 			return sendErrorEnvelope(r, err) | ||||
| 		} | ||||
| 	if len(req.Teams) > 0 { | ||||
| 		app.team.UpsertUserTeams(agent.ID, req.Teams) | ||||
| 	} | ||||
|  | ||||
| 	if user.SendWelcomeEmail { | ||||
| 	if req.SendWelcomeEmail { | ||||
| 		// Generate reset token. | ||||
| 		resetToken, err := app.user.SetResetPasswordToken(user.ID) | ||||
| 		resetToken, err := app.user.SetResetPasswordToken(agent.ID) | ||||
| 		if err != nil { | ||||
| 			return sendErrorEnvelope(r, err) | ||||
| 		} | ||||
| @@ -220,31 +218,36 @@ func handleCreateAgent(r *fastglue.Request) error { | ||||
| 		// Render template and send email. | ||||
| 		content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{ | ||||
| 			"ResetToken": resetToken, | ||||
| 			"Email":      user.Email.String, | ||||
| 			"Email":      req.Email, | ||||
| 		}) | ||||
| 		if err != nil { | ||||
| 			app.lo.Error("error rendering template", "error", err) | ||||
| 			return r.SendEnvelope(true) | ||||
| 		} | ||||
|  | ||||
| 		if err := app.notifier.Send(notifier.Message{ | ||||
| 			RecipientEmails: []string{user.Email.String}, | ||||
| 			Subject:         "Welcome to Libredesk", | ||||
| 			RecipientEmails: []string{req.Email}, | ||||
| 			Subject:         app.i18n.T("globals.messages.welcomeToLibredesk"), | ||||
| 			Content:         content, | ||||
| 			Provider:        notifier.ProviderEmail, | ||||
| 		}); err != nil { | ||||
| 			app.lo.Error("error sending notification message", "error", err) | ||||
| 			return r.SendEnvelope(true) | ||||
| 		} | ||||
| 	} | ||||
| 	return r.SendEnvelope(true) | ||||
|  | ||||
| 	// Refetch agent as other details might've changed. | ||||
| 	agent, err = app.user.GetAgent(agent.ID, "") | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	return r.SendEnvelope(agent) | ||||
| } | ||||
|  | ||||
| // handleUpdateAgent updates an agent. | ||||
| func handleUpdateAgent(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app   = r.Context.(*App) | ||||
| 		user  = models.User{} | ||||
| 		req   = agentReq{} | ||||
| 		auser = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 		ip    = realip.FromRequest(r.RequestCtx) | ||||
| 		id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) | ||||
| @@ -253,25 +256,13 @@ func handleUpdateAgent(r *fastglue.Request) error { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	if err := r.Decode(&user, "json"); err != nil { | ||||
| 	if err := r.Decode(&req, "json"); err != nil { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	if user.Email.String == "" { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError) | ||||
| 	} | ||||
| 	user.Email = null.StringFrom(strings.TrimSpace(strings.ToLower(user.Email.String))) | ||||
|  | ||||
| 	if !stringutil.ValidEmail(user.Email.String) { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	if user.Roles == nil { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	if user.FirstName == "" { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError) | ||||
| 	// Validate agent request | ||||
| 	if err := validateAgentRequest(r, &req); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	agent, err := app.user.GetAgent(id, "") | ||||
| @@ -280,8 +271,8 @@ func handleUpdateAgent(r *fastglue.Request) error { | ||||
| 	} | ||||
| 	oldAvailabilityStatus := agent.AvailabilityStatus | ||||
|  | ||||
| 	// Update agent. | ||||
| 	if err = app.user.UpdateAgent(id, user); err != nil { | ||||
| 	// Update agent with individual fields | ||||
| 	if err = app.user.UpdateAgent(id, req.FirstName, req.LastName, req.Email, req.Roles, req.Enabled, req.AvailabilityStatus, req.NewPassword); err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| @@ -289,18 +280,24 @@ func handleUpdateAgent(r *fastglue.Request) error { | ||||
| 	defer app.authz.InvalidateUserCache(id) | ||||
|  | ||||
| 	// Create activity log if user availability status changed. | ||||
| 	if oldAvailabilityStatus != user.AvailabilityStatus { | ||||
| 		if err := app.activityLog.UserAvailability(auser.ID, auser.Email, user.AvailabilityStatus, ip, user.Email.String, id); err != nil { | ||||
| 	if oldAvailabilityStatus != req.AvailabilityStatus { | ||||
| 		if err := app.activityLog.UserAvailability(auser.ID, auser.Email, req.AvailabilityStatus, ip, req.Email, id); err != nil { | ||||
| 			app.lo.Error("error creating activity log", "error", err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Upsert agent teams. | ||||
| 	if err := app.team.UpsertUserTeams(id, user.Teams.Names()); err != nil { | ||||
| 	if err := app.team.UpsertUserTeams(id, req.Teams); err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	return r.SendEnvelope(true) | ||||
| 	// Refetch agent and return. | ||||
| 	agent, err = app.user.GetAgent(id, "") | ||||
| 	if err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
|  | ||||
| 	return r.SendEnvelope(agent) | ||||
| } | ||||
|  | ||||
| // handleDeleteAgent soft deletes an agent. | ||||
| @@ -381,7 +378,7 @@ func handleResetPassword(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app       = r.Context.(*App) | ||||
| 		auser, ok = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 		resetReq  ResetPasswordRequest | ||||
| 		resetReq  resetPasswordRequest | ||||
| 	) | ||||
| 	if ok && auser.ID > 0 { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError) | ||||
| @@ -399,7 +396,7 @@ func handleResetPassword(r *fastglue.Request) error { | ||||
| 	agent, err := app.user.GetAgent(0, resetReq.Email) | ||||
| 	if err != nil { | ||||
| 		// Send 200 even if user not found, to prevent email enumeration. | ||||
| 		return r.SendEnvelope("Reset password email sent successfully.") | ||||
| 		return r.SendEnvelope(true) | ||||
| 	} | ||||
|  | ||||
| 	token, err := app.user.SetResetPasswordToken(agent.ID) | ||||
| @@ -434,7 +431,7 @@ func handleSetPassword(r *fastglue.Request) error { | ||||
| 	var ( | ||||
| 		app       = r.Context.(*App) | ||||
| 		agent, ok = r.RequestCtx.UserValue("user").(amodels.User) | ||||
| 		req       = SetPasswordRequest{} | ||||
| 		req       setPasswordRequest | ||||
| 	) | ||||
|  | ||||
| 	if ok && agent.ID > 0 { | ||||
| @@ -457,13 +454,13 @@ func handleSetPassword(r *fastglue.Request) error { | ||||
| } | ||||
|  | ||||
| // uploadUserAvatar uploads the user avatar. | ||||
| func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart.FileHeader) error { | ||||
| func uploadUserAvatar(r *fastglue.Request, user models.User, files []*multipart.FileHeader) error { | ||||
| 	var app = r.Context.(*App) | ||||
|  | ||||
| 	fileHeader := files[0] | ||||
| 	file, err := fileHeader.Open() | ||||
| 	if err != nil { | ||||
| 		app.lo.Error("error opening uploaded file", "error", err) | ||||
| 		app.lo.Error("error opening uploaded file", "user_id", user.ID, "error", err) | ||||
| 		return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil) | ||||
| 	} | ||||
| 	defer file.Close() | ||||
| @@ -480,7 +477,7 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart | ||||
|  | ||||
| 	// Check file size | ||||
| 	if bytesToMegabytes(srcFileSize) > maxAvatarSizeMB { | ||||
| 		app.lo.Error("error uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB) | ||||
| 		app.lo.Error("error uploaded file size is larger than max allowed", "user_id", user.ID, "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB) | ||||
| 		return envelope.NewError( | ||||
| 			envelope.InputError, | ||||
| 			app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", maxAvatarSizeMB)), | ||||
| @@ -497,23 +494,25 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart | ||||
| 	meta := []byte("{}") | ||||
| 	media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta) | ||||
| 	if err != nil { | ||||
| 		app.lo.Error("error uploading file", "error", err) | ||||
| 		app.lo.Error("error uploading file", "user_id", user.ID, "error", err) | ||||
| 		return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil) | ||||
| 	} | ||||
|  | ||||
| 	// Delete current avatar. | ||||
| 	if user.AvatarURL.Valid { | ||||
| 		fileName := filepath.Base(user.AvatarURL.String) | ||||
| 		app.media.Delete(fileName) | ||||
| 		if err := app.media.Delete(fileName); err != nil { | ||||
| 			app.lo.Error("error deleting user avatar", "user_id", user.ID, "error", err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Save file path. | ||||
| 	path, err := stringutil.GetPathFromURL(media.URL) | ||||
| 	if err != nil { | ||||
| 		app.lo.Debug("error getting path from URL", "url", media.URL, "error", err) | ||||
| 		app.lo.Debug("error getting path from URL", "user_id", user.ID, "url", media.URL, "error", err) | ||||
| 		return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil) | ||||
| 	} | ||||
| 	fmt.Println("path", path) | ||||
|  | ||||
| 	if err := app.user.UpdateAvatar(user.ID, path); err != nil { | ||||
| 		return sendErrorEnvelope(r, err) | ||||
| 	} | ||||
| @@ -577,3 +576,28 @@ func handleRevokeAPIKey(r *fastglue.Request) error { | ||||
|  | ||||
| 	return r.SendEnvelope(true) | ||||
| } | ||||
|  | ||||
| // validateAgentRequest validates common agent request fields and normalizes the email | ||||
| func validateAgentRequest(r *fastglue.Request, req *agentReq) error { | ||||
| 	var app = r.Context.(*App) | ||||
|  | ||||
| 	// Normalize email | ||||
| 	req.Email = strings.TrimSpace(strings.ToLower(req.Email)) | ||||
| 	if req.Email == "" { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	if !stringutil.ValidEmail(req.Email) { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	if req.Roles == nil { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	if req.FirstName == "" { | ||||
| 		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|   | ||||
							
								
								
									
										30
									
								
								docs/docs/api-getting-started.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								docs/docs/api-getting-started.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| # API getting started | ||||
|  | ||||
| You can access the Libredesk API to interact with your instance programmatically. | ||||
|  | ||||
| ## Generating API keys | ||||
|  | ||||
| 1. **Edit agent**: Go to Admin → Teammate → Agent → Edit | ||||
| 2. **Generate new API key**: An API Key and API Secret will be generated for the agent | ||||
| 3. **Save the credentials**: Keep both the API Key and API Secret secure | ||||
| 4. **Key management**: You can revoke / regenerate API keys at any time from the same page | ||||
|  | ||||
| ## Using the API | ||||
|  | ||||
| LibreDesk supports two authentication schemes: | ||||
|  | ||||
| ### Basic authentication | ||||
| ```bash | ||||
| curl -X GET "https://your-libredesk-instance.com/api/endpoint" \ | ||||
|   -H "Authorization: Basic <base64_encoded_key:secret>" | ||||
| ``` | ||||
|  | ||||
| ### Token authentication | ||||
| ```bash | ||||
| curl -X GET "https://your-libredesk-instance.com/api/endpoint" \ | ||||
|   -H "Authorization: token your_api_key:your_api_secret" | ||||
| ``` | ||||
|  | ||||
| ## API Documentation | ||||
|  | ||||
| Complete API documentation with available endpoints and examples coming soon. | ||||
| @@ -32,6 +32,7 @@ nav: | ||||
|       - Email Templates: templating.md | ||||
|       - SSO Setup: sso.md | ||||
|       - Webhooks: webhooks.md | ||||
|       - API Getting Started: api-getting-started.md | ||||
|   - Contributions: | ||||
|       - Developer Setup: developer-setup.md | ||||
|       - Translate Libredesk: translations.md | ||||
|   | ||||
| @@ -2,23 +2,33 @@ | ||||
|  | ||||
| describe('Login Component', () => { | ||||
|     beforeEach(() => { | ||||
|         // Visit the login page | ||||
|         cy.visit('/') | ||||
|  | ||||
|         // Mock the API response for OIDC providers | ||||
|         cy.intercept('GET', '**/api/v1/oidc/enabled', { | ||||
|         cy.intercept('GET', '**/api/v1/config', { | ||||
|             statusCode: 200, | ||||
|             body: { | ||||
|                 data: [ | ||||
|                     { | ||||
|                         id: 1, | ||||
|                         name: 'Google', | ||||
|                         logo_url: 'https://example.com/google-logo.png', | ||||
|                         disabled: false | ||||
|                     } | ||||
|                 ] | ||||
|                 data: { | ||||
|                     "app.favicon_url": "http://localhost:9000/favicon.ico", | ||||
|                     "app.lang": "en", | ||||
|                     "app.logo_url": "http://localhost:9000/logo.png", | ||||
|                     "app.site_name": "Libredesk", | ||||
|                     "app.sso_providers": [ | ||||
|                         { | ||||
|                             "client_id": "xx", | ||||
|                             "enabled": true, | ||||
|                             "id": 1, | ||||
|                             "logo_url": "/images/google-logo.png", | ||||
|                             "name": "Google", | ||||
|                             "provider": "Google", | ||||
|                             "provider_url": "https://accounts.google.com", | ||||
|                             "redirect_uri": "http://localhost:9000/api/v1/oidc/1/finish" | ||||
|                         } | ||||
|                     ] | ||||
|                 } | ||||
|             } | ||||
|         }).as('getOIDCProviders') | ||||
|  | ||||
|         // Visit the login page | ||||
|         cy.visit('/') | ||||
|     }) | ||||
|  | ||||
|     it('should display login form', () => { | ||||
|   | ||||
| @@ -88,8 +88,8 @@ | ||||
|         @create-conversation="() => (openCreateConversationDialog = true)" | ||||
|       > | ||||
|         <div class="flex flex-col h-screen"> | ||||
|           <!-- Show app update only in admin routes --> | ||||
|           <AppUpdate v-if="route.path.startsWith('/admin')" /> | ||||
|           <!-- Show admin banner only in admin routes --> | ||||
|           <AdminBanner v-if="route.path.startsWith('/admin')" /> | ||||
|  | ||||
|           <!-- Common header for all pages --> | ||||
|           <PageHeader /> | ||||
| @@ -128,7 +128,7 @@ import { useCustomAttributeStore } from '@/stores/customAttributes' | ||||
| import { useIdleDetection } from '@/composables/useIdleDetection' | ||||
| import PageHeader from './components/layout/PageHeader.vue' | ||||
| import ViewForm from '@/features/view/ViewForm.vue' | ||||
| import AppUpdate from '@/components/update/AppUpdate.vue' | ||||
| import AdminBanner from '@/components/banner/AdminBanner.vue' | ||||
| import api from '@/api' | ||||
| import { toast as sooner } from 'vue-sonner' | ||||
| import Sidebar from '@/components/sidebar/Sidebar.vue' | ||||
|   | ||||
| @@ -122,7 +122,7 @@ const createOIDC = (data) => | ||||
|       'Content-Type': 'application/json' | ||||
|     } | ||||
|   }) | ||||
| const getAllEnabledOIDC = () => http.get('/api/v1/oidc/enabled') | ||||
| const getConfig = () => http.get('/api/v1/config') | ||||
| const getAllOIDC = () => http.get('/api/v1/oidc') | ||||
| const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`) | ||||
| const updateOIDC = (id, data) => | ||||
| @@ -514,7 +514,7 @@ export default { | ||||
|   updateSettings, | ||||
|   createOIDC, | ||||
|   getAllOIDC, | ||||
|   getAllEnabledOIDC, | ||||
|   getConfig, | ||||
|   getOIDC, | ||||
|   updateOIDC, | ||||
|   deleteOIDC, | ||||
|   | ||||
							
								
								
									
										63
									
								
								frontend/src/components/banner/AdminBanner.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								frontend/src/components/banner/AdminBanner.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| <template> | ||||
|   <div class="border-b"> | ||||
|     <!-- Update notification --> | ||||
|     <div | ||||
|       v-if="appSettingsStore.settings['app.update']?.update?.is_new" | ||||
|       class="px-4 py-2.5 border-b border-border/50 last:border-b-0" | ||||
|     > | ||||
|       <div class="flex items-center gap-3"> | ||||
|         <div class="flex-shrink-0"> | ||||
|           <Download class="w-5 h-5 text-primary" /> | ||||
|         </div> | ||||
|         <div class="min-w-0 flex-1"> | ||||
|           <div class="flex items-center gap-2 text-sm text-foreground"> | ||||
|             <span>{{ $t('update.newUpdateAvailable') }}</span> | ||||
|             <a | ||||
|               :href="appSettingsStore.settings['app.update'].update.url" | ||||
|               target="_blank" | ||||
|               rel="nofollow noreferrer" | ||||
|               class="font-semibold text-primary hover:text-primary/80 underline transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1" | ||||
|             > | ||||
|               {{ appSettingsStore.settings['app.update'].update.release_version }} | ||||
|             </a> | ||||
|             <span class="text-muted-foreground">•</span> | ||||
|             <span class="text-muted-foreground"> | ||||
|               {{ appSettingsStore.settings['app.update'].update.release_date }} | ||||
|             </span> | ||||
|           </div> | ||||
|  | ||||
|           <!-- Update description --> | ||||
|           <div | ||||
|             v-if="appSettingsStore.settings['app.update'].update.description" | ||||
|             class="mt-2 text-xs text-muted-foreground" | ||||
|           > | ||||
|             {{ appSettingsStore.settings['app.update'].update.description }} | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <!-- Restart required notification --> | ||||
|     <div | ||||
|       v-if="appSettingsStore.settings['app.restart_required']" | ||||
|       class="px-4 py-2.5 border-b border-border/50 last:border-b-0" | ||||
|     > | ||||
|       <div class="flex items-center gap-3"> | ||||
|         <div class="flex-shrink-0"> | ||||
|           <Info class="w-5 h-5 text-primary" /> | ||||
|         </div> | ||||
|         <div class="min-w-0 flex-1"> | ||||
|           <div class="text-sm text-foreground"> | ||||
|             {{ $t('admin.banner.restartMessage') }} | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { Download, Info } from 'lucide-vue-next' | ||||
| import { useAppSettingsStore } from '@/stores/appSettings' | ||||
| const appSettingsStore = useAppSettingsStore() | ||||
| </script> | ||||
| @@ -4,9 +4,9 @@ | ||||
|     @click="handleClick"> | ||||
|     <div class="flex items-center mb-2"> | ||||
|       <component :is="icon" size="24" class="mr-2 text-primary" /> | ||||
|       <h3 class="text-lg font-medium text-gray-800">{{ title }}</h3> | ||||
|       <h3 class="text-lg font-medium">{{ title }}</h3> | ||||
|     </div> | ||||
|     <p class="text-sm text-gray-600">{{ subTitle }}</p> | ||||
|     <p class="text-sm text-gray-600 dark:text-gray-400">{{ subTitle }}</p> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
|   | ||||
| @@ -1,25 +0,0 @@ | ||||
| <template> | ||||
|   <div | ||||
|     v-if="appSettingsStore.settings['app.update']?.update?.is_new" | ||||
|     class="p-2 mb-2 border-b bg-secondary text-secondary-foreground" | ||||
|   > | ||||
|     {{ $t('update.newUpdateAvailable') }}: | ||||
|     {{ appSettingsStore.settings['app.update'].update.release_version }} ({{ | ||||
|       appSettingsStore.settings['app.update'].update.release_date | ||||
|     }}) | ||||
|     <a | ||||
|       :href="appSettingsStore.settings['app.update'].update.url" | ||||
|       target="_blank" | ||||
|       nofollow | ||||
|       noreferrer | ||||
|       class="underline ml-2" | ||||
|     > | ||||
|       {{ $t('globals.messages.viewDetails') }} | ||||
|     </a> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { useAppSettingsStore } from '@/stores/appSettings' | ||||
| const appSettingsStore = useAppSettingsStore() | ||||
| </script> | ||||
| @@ -1 +1,3 @@ | ||||
| export const Roles = ["Admin", "Agent"] | ||||
| export const Roles = ["Admin", "Agent"] | ||||
| export const UserTypeAgent = "agent" | ||||
| export const UserTypeContact = "contact" | ||||
| @@ -418,7 +418,6 @@ const onSubmit = form.handleSubmit((values) => { | ||||
|   if (values.availability_status === 'active_group') { | ||||
|     values.availability_status = 'online' | ||||
|   } | ||||
|   values.teams = values.teams.map((team) => ({ name: team })) | ||||
|   props.submitForm(values) | ||||
| }) | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,106 @@ | ||||
| <template> | ||||
|   <div class="h-screen w-full flex items-center justify-center min-w-[400px]"> | ||||
|     <p>{{ $t('conversation.placeholder') }}</p> | ||||
|   <div class="placeholder-container"> | ||||
|     <Spinner v-if="isLoading" /> | ||||
|     <template v-else> | ||||
|       <div v-if="showGettingStarted" class="getting-started-wrapper"> | ||||
|         <div class="text-center"> | ||||
|           <h2 class="text-2xl font-semibold text-foreground mb-6"> | ||||
|             {{ $t('setup.completeYourSetup') }} | ||||
|           </h2> | ||||
|  | ||||
|           <div class="space-y-4 mb-6"> | ||||
|             <div class="checklist-item" :class="{ completed: hasInboxes }"> | ||||
|               <CheckCircle v-if="hasInboxes" class="check-icon completed" /> | ||||
|               <Circle v-else class="w-5 h-5 text-muted-foreground" /> | ||||
|               <span class="flex-1 text-left ml-3 text-foreground"> | ||||
|                 {{ $t('setup.createFirstInbox') }} | ||||
|               </span> | ||||
|               <Button | ||||
|                 v-if="!hasInboxes" | ||||
|                 variant="ghost" | ||||
|                 size="sm" | ||||
|                 @click="router.push({ name: 'inbox-list' })" | ||||
|                 class="ml-auto" | ||||
|               > | ||||
|                 {{ $t('globals.messages.setUp') }} | ||||
|               </Button> | ||||
|             </div> | ||||
|  | ||||
|             <div class="checklist-item" :class="{ completed: hasAgents, disabled: !hasInboxes }"> | ||||
|               <CheckCircle v-if="hasAgents" class="check-icon completed" /> | ||||
|               <Circle v-else class="w-5 h-5 text-muted-foreground" /> | ||||
|               <span class="flex-1 text-left ml-3 text-foreground"> | ||||
|                 {{ $t('setup.inviteTeammates') }} | ||||
|               </span> | ||||
|               <Button | ||||
|                 v-if="!hasAgents && hasInboxes" | ||||
|                 variant="ghost" | ||||
|                 size="sm" | ||||
|                 @click="router.push({ name: 'agent-list' })" | ||||
|                 class="ml-auto" | ||||
|               > | ||||
|                 {{ $t('globals.messages.invite') }} | ||||
|               </Button> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div v-else> | ||||
|         <p class="placeholder-text">{{ $t('conversation.placeholder') }}</p> | ||||
|       </div> | ||||
|     </template> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { ref, computed, onMounted } from 'vue' | ||||
| import { useRouter } from 'vue-router' | ||||
| import { CheckCircle, Circle } from 'lucide-vue-next' | ||||
| import { Button } from '@/components/ui/button' | ||||
| import { Spinner } from '@/components/ui/spinner' | ||||
| import { useInboxStore } from '@/stores/inbox' | ||||
| import { useUsersStore } from '@/stores/users' | ||||
|  | ||||
| const router = useRouter() | ||||
| const inboxStore = useInboxStore() | ||||
| const usersStore = useUsersStore() | ||||
| const isLoading = ref(true) | ||||
|  | ||||
| onMounted(async () => { | ||||
|   try { | ||||
|     await Promise.all([inboxStore.fetchInboxes(), usersStore.fetchUsers()]) | ||||
|   } finally { | ||||
|     isLoading.value = false | ||||
|   } | ||||
| }) | ||||
|  | ||||
| const hasInboxes = computed(() => inboxStore.inboxes.length > 0) | ||||
| const hasAgents = computed(() => usersStore.users.length > 0) | ||||
| const showGettingStarted = computed(() => !hasInboxes.value || !hasAgents.value) | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .placeholder-container { | ||||
|   @apply h-screen w-full flex items-center justify-center min-w-[400px] relative; | ||||
| } | ||||
|  | ||||
| .getting-started-wrapper { | ||||
|   @apply w-full max-w-md mx-auto px-4; | ||||
| } | ||||
|  | ||||
| .checklist-item { | ||||
|   @apply flex items-center justify-between py-3 px-4 rounded-lg border border-border; | ||||
| } | ||||
|  | ||||
| .checklist-item.completed { | ||||
|   @apply bg-muted/50; | ||||
| } | ||||
|  | ||||
| .checklist-item.disabled { | ||||
|   @apply opacity-50; | ||||
| } | ||||
|  | ||||
| .check-icon.completed { | ||||
|   @apply w-5 h-5 text-primary; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -10,7 +10,7 @@ | ||||
|               }) | ||||
|             }} | ||||
|           </DialogTitle> | ||||
|           <DialogDescription/> | ||||
|           <DialogDescription /> | ||||
|         </DialogHeader> | ||||
|         <form @submit="createConversation" class="flex flex-col flex-1 overflow-hidden"> | ||||
|           <!-- Form Fields Section --> | ||||
| @@ -263,6 +263,7 @@ import { useFileUpload } from '@/composables/useFileUpload' | ||||
| import Editor from '@/components/editor/TextEditor.vue' | ||||
| import { useMacroStore } from '@/stores/macro' | ||||
| import SelectComboBox from '@/components/combobox/SelectCombobox.vue' | ||||
| import { UserTypeAgent } from '@/constants/user' | ||||
| import api from '@/api' | ||||
|  | ||||
| const dialogOpen = defineModel({ | ||||
| @@ -393,12 +394,14 @@ const selectContact = (contact) => { | ||||
| const createConversation = form.handleSubmit(async (values) => { | ||||
|   loading.value = true | ||||
|   try { | ||||
|     // convert ids to numbers if they are not already | ||||
|     // Convert ids to numbers if they are not already | ||||
|     values.inbox_id = Number(values.inbox_id) | ||||
|     values.team_id = values.team_id ? Number(values.team_id) : null | ||||
|     values.agent_id = values.agent_id ? Number(values.agent_id) : null | ||||
|     // array of attachment ids. | ||||
|     // Array of attachment ids. | ||||
|     values.attachments = mediaFiles.value.map((file) => file.id) | ||||
|     // Initiator of this conversation is always agent | ||||
|     values.initiator = UserTypeAgent | ||||
|     const conversation = await api.createConversation(values) | ||||
|     const conversationUUID = conversation.data.data.uuid | ||||
|  | ||||
|   | ||||
| @@ -1,105 +1,139 @@ | ||||
| <template> | ||||
|   <div class="max-w-5xl mx-auto p-6 min-h-screen"> | ||||
|     <div class="space-y-8"> | ||||
|       <div | ||||
|         v-for="(items, type) in results" | ||||
|         :key="type" | ||||
|         class="bg-card rounded shadow overflow-hidden" | ||||
|       > | ||||
|         <!-- Header for each section --> | ||||
|         <h2 | ||||
|           class="bg-primary dark:bg-primary text-lg font-bold text-white dark:text-primary-foreground py-2 px-6 capitalize" | ||||
|         > | ||||
|           {{ type }} | ||||
|         </h2> | ||||
|     <Tabs :default-value="defaultTab" v-model="activeTab"> | ||||
|       <TabsList class="grid w-full mb-6" :class="tabsGridClass"> | ||||
|         <TabsTrigger v-for="(items, type) in results" :key="type" :value="type" class="capitalize"> | ||||
|           {{ type }} ({{ items.length }}) | ||||
|         </TabsTrigger> | ||||
|       </TabsList> | ||||
|  | ||||
|         <!-- No results message --> | ||||
|         <div v-if="items.length === 0" class="p-6 text-gray-500 dark:text-muted-foreground"> | ||||
|           {{ | ||||
|             $t('globals.messages.noResults', { | ||||
|               name: type | ||||
|             }) | ||||
|           }} | ||||
|         </div> | ||||
|       <TabsContent v-for="(items, type) in results" :key="type" :value="type" class="mt-0"> | ||||
|         <div class="bg-background rounded border overflow-hidden"> | ||||
|           <!-- No results message --> | ||||
|           <div v-if="items.length === 0" class="p-8 text-center text-muted-foreground"> | ||||
|             <div class="text-lg font-medium mb-2"> | ||||
|               {{ | ||||
|                 $t('globals.messages.noResults', { | ||||
|                   name: type | ||||
|                 }) | ||||
|               }} | ||||
|             </div> | ||||
|             <div class="text-sm">{{ $t('search.adjustSearchTerms') }}</div> | ||||
|           </div> | ||||
|  | ||||
|         <!-- Results list --> | ||||
|         <div class="divide-y divide-gray-200 dark:divide-border"> | ||||
|           <div | ||||
|             v-for="item in items" | ||||
|             :key="item.id || item.uuid" | ||||
|             class="p-6 hover:bg-gray-100 dark:hover:bg-accent transition duration-300 ease-in-out group" | ||||
|           > | ||||
|             <router-link | ||||
|               :to="{ | ||||
|                 name: 'inbox-conversation', | ||||
|                 params: { | ||||
|                   uuid: type === 'conversations' ? item.uuid : item.conversation_uuid, | ||||
|                   type: 'assigned' | ||||
|                 } | ||||
|               }" | ||||
|               class="block" | ||||
|           <!-- Results list --> | ||||
|           <div v-else class="divide-y divide-border"> | ||||
|             <div | ||||
|               v-for="item in items" | ||||
|               :key="item.id || item.uuid" | ||||
|               class="p-6 hover:bg-accent/50 transition duration-200 ease-in-out group" | ||||
|             > | ||||
|               <div class="flex justify-between items-start"> | ||||
|                 <div class="flex-grow"> | ||||
|                   <!-- Reference number --> | ||||
|                   <div | ||||
|                     class="text-sm font-semibold mb-2 group-hover:text-primary dark:group-hover:text-primary transition duration-300" | ||||
|                   > | ||||
|                     #{{ | ||||
|                       type === 'conversations' | ||||
|                         ? item.reference_number | ||||
|                         : item.conversation_reference_number | ||||
|                     }} | ||||
|               <router-link | ||||
|                 :to="{ | ||||
|                   name: 'inbox-conversation', | ||||
|                   params: { | ||||
|                     uuid: type === 'conversations' ? item.uuid : item.conversation_uuid, | ||||
|                     type: 'assigned' | ||||
|                   } | ||||
|                 }" | ||||
|                 class="block" | ||||
|               > | ||||
|                 <div class="flex justify-between items-start"> | ||||
|                   <div class="flex-grow"> | ||||
|                     <!-- Reference number --> | ||||
|                     <div | ||||
|                       class="text-sm font-semibold mb-2 text-muted-foreground group-hover:text-primary transition duration-200" | ||||
|                     > | ||||
|                       #{{ | ||||
|                         type === 'conversations' | ||||
|                           ? item.reference_number | ||||
|                           : item.conversation_reference_number | ||||
|                       }} | ||||
|                     </div> | ||||
|  | ||||
|                     <!-- Content --> | ||||
|                     <div | ||||
|                       class="text-foreground font-medium mb-2 text-lg group-hover:text-primary transition duration-200" | ||||
|                     > | ||||
|                       {{ | ||||
|                         truncateText( | ||||
|                           type === 'conversations' ? item.subject : item.text_content, | ||||
|                           100 | ||||
|                         ) | ||||
|                       }} | ||||
|                     </div> | ||||
|  | ||||
|                     <!-- Timestamp --> | ||||
|                     <div class="text-sm text-muted-foreground flex items-center"> | ||||
|                       <ClockIcon class="h-4 w-4 mr-1" /> | ||||
|                       {{ | ||||
|                         formatDate( | ||||
|                           type === 'conversations' ? item.created_at : item.conversation_created_at | ||||
|                         ) | ||||
|                       }} | ||||
|                     </div> | ||||
|                   </div> | ||||
|  | ||||
|                   <!-- Content --> | ||||
|                   <!-- Right arrow icon --> | ||||
|                   <div | ||||
|                     class="text-gray-900 dark:text-card-foreground font-medium mb-2 text-lg group-hover:text-gray-950 dark:group-hover:text-foreground transition duration-300" | ||||
|                     class="bg-secondary rounded-full p-2 group-hover:bg-primary transition duration-200" | ||||
|                   > | ||||
|                     {{ | ||||
|                       truncateText(type === 'conversations' ? item.subject : item.text_content, 100) | ||||
|                     }} | ||||
|                   </div> | ||||
|  | ||||
|                   <!-- Timestamp --> | ||||
|                   <div class="text-sm text-gray-500 dark:text-muted-foreground flex items-center"> | ||||
|                     <ClockIcon class="h-4 w-4 mr-1" /> | ||||
|                     {{ | ||||
|                       formatDate( | ||||
|                         type === 'conversations' ? item.created_at : item.conversation_created_at | ||||
|                       ) | ||||
|                     }} | ||||
|                     <ChevronRightIcon | ||||
|                       class="h-5 w-5 text-secondary-foreground group-hover:text-primary-foreground" | ||||
|                       aria-hidden="true" | ||||
|                     /> | ||||
|                   </div> | ||||
|                 </div> | ||||
|  | ||||
|                 <!-- Right arrow icon --> | ||||
|                 <div | ||||
|                   class="bg-gray-200 dark:bg-secondary rounded-full p-2 group-hover:bg-primary dark:group-hover:bg-primary transition duration-300" | ||||
|                 > | ||||
|                   <ChevronRightIcon | ||||
|                     class="h-5 w-5 text-gray-700 dark:text-secondary-foreground group-hover:text-white dark:group-hover:text-primary-foreground" | ||||
|                     aria-hidden="true" | ||||
|                   /> | ||||
|                 </div> | ||||
|               </div> | ||||
|             </router-link> | ||||
|               </router-link> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|       </TabsContent> | ||||
|     </Tabs> | ||||
|   </div> | ||||
| </template> | ||||
| <script setup> | ||||
| import { computed, ref, watch } from 'vue' | ||||
| import { ChevronRightIcon, ClockIcon } from 'lucide-vue-next' | ||||
| import { format, parseISO } from 'date-fns' | ||||
| import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' | ||||
|  | ||||
| defineProps({ | ||||
| const props = defineProps({ | ||||
|   results: { | ||||
|     type: Object, | ||||
|     required: true | ||||
|   } | ||||
| }) | ||||
|  | ||||
| // Get the first available tab as default | ||||
| const defaultTab = computed(() => { | ||||
|   const types = Object.keys(props.results) | ||||
|   return types.length > 0 ? types[0] : '' | ||||
| }) | ||||
|  | ||||
| const activeTab = ref('') | ||||
|  | ||||
| // Watch for changes in results and set the first tab as active | ||||
| watch( | ||||
|   () => props.results, | ||||
|   (newResults) => { | ||||
|     const types = Object.keys(newResults) | ||||
|     if (types.length > 0 && !activeTab.value) { | ||||
|       activeTab.value = types[0] | ||||
|     } | ||||
|   }, | ||||
|   { immediate: true } | ||||
| ) | ||||
|  | ||||
| // Dynamic grid class based on number of tabs | ||||
| const tabsGridClass = computed(() => { | ||||
|   const tabCount = Object.keys(props.results).length | ||||
|   if (tabCount <= 2) return 'grid-cols-2' | ||||
|   if (tabCount <= 3) return 'grid-cols-3' | ||||
|   if (tabCount <= 4) return 'grid-cols-4' | ||||
|   return 'grid-cols-5' | ||||
| }) | ||||
|  | ||||
| const formatDate = (dateString) => { | ||||
|   const date = parseISO(dateString) | ||||
|   return format(date, 'MMM d, yyyy HH:mm') | ||||
|   | ||||
| @@ -18,14 +18,14 @@ const setFavicon = (url) => { | ||||
| } | ||||
|  | ||||
| async function initApp () { | ||||
|   const settings = (await api.getSettings('general')).data.data | ||||
|   const config = (await api.getConfig()).data.data | ||||
|   const emitter = mitt() | ||||
|   const lang = settings['app.lang'] || 'en' | ||||
|   const lang = config['app.lang'] || 'en' | ||||
|   const langMessages = await api.getLanguage(lang) | ||||
|  | ||||
|   // Set favicon. | ||||
|   if (settings['app.favicon_url']) | ||||
|     setFavicon(settings['app.favicon_url']) | ||||
|   if (config['app.favicon_url']) | ||||
|     setFavicon(config['app.favicon_url']) | ||||
|  | ||||
|   // Initialize i18n. | ||||
|   const i18nConfig = { | ||||
| @@ -42,9 +42,17 @@ async function initApp () { | ||||
|   const pinia = createPinia() | ||||
|   app.use(pinia) | ||||
|  | ||||
|   // Store app settings in Pinia | ||||
|   // Fetch and store app settings in store (after pinia is initialized) | ||||
|   const settingsStore = useAppSettingsStore() | ||||
|   settingsStore.setSettings(settings) | ||||
|  | ||||
|   // Store the public config in the store | ||||
|   settingsStore.setPublicConfig(config) | ||||
|  | ||||
|   try { | ||||
|     await settingsStore.fetchSettings('general') | ||||
|   } catch (error) { | ||||
|     // Pass | ||||
|   } | ||||
|  | ||||
|   // Add emitter to global properties. | ||||
|   app.config.globalProperties.emitter = emitter | ||||
|   | ||||
| @@ -1,12 +1,35 @@ | ||||
| import { defineStore } from 'pinia' | ||||
| import api from '@/api' | ||||
|  | ||||
| export const useAppSettingsStore = defineStore('settings', { | ||||
|     state: () => ({ | ||||
|         settings: {} | ||||
|         settings: {}, | ||||
|         public_config: {} | ||||
|     }), | ||||
|     actions: { | ||||
|         async fetchSettings (key = 'general') { | ||||
|             try { | ||||
|                 const response = await api.getSettings(key) | ||||
|                 this.settings = response?.data?.data || {} | ||||
|                 return this.settings | ||||
|             } catch (error) { | ||||
|                 // Pass | ||||
|             } | ||||
|         }, | ||||
|         async fetchPublicConfig () { | ||||
|             try { | ||||
|                 const response = await api.getConfig() | ||||
|                 this.public_config = response?.data?.data || {} | ||||
|                 return this.public_config | ||||
|             } catch (error) { | ||||
|                 // Pass | ||||
|             } | ||||
|         }, | ||||
|         setSettings (newSettings) { | ||||
|             this.settings = newSettings | ||||
|         }, | ||||
|         setPublicConfig (newPublicConfig) { | ||||
|             this.public_config = newPublicConfig | ||||
|         } | ||||
|     } | ||||
| }) | ||||
|   | ||||
| @@ -12,14 +12,13 @@ export const useInboxStore = defineStore('inbox', () => { | ||||
|     label: inb.name, | ||||
|     value: String(inb.id) | ||||
|   }))) | ||||
|   const fetchInboxes = async () => { | ||||
|     if (inboxes.value.length) return | ||||
|   const fetchInboxes = async (force = false) => { | ||||
|     if (!force && inboxes.value.length) return | ||||
|     try { | ||||
|       const response = await api.getInboxes() | ||||
|       inboxes.value = response?.data?.data || [] | ||||
|     } catch (error) { | ||||
|       emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { | ||||
|         title: 'Error', | ||||
|         variant: 'destructive', | ||||
|         description: handleHTTPError(error).message | ||||
|       }) | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import { useEmitter } from '@/composables/useEmitter' | ||||
| import { EMITTER_EVENTS } from '@/constants/emitterEvents' | ||||
| import api from '@/api' | ||||
|  | ||||
| // TODO: rename this store to agents | ||||
| export const useUsersStore = defineStore('users', () => { | ||||
|     const users = ref([]) | ||||
|     const emitter = useEmitter() | ||||
| @@ -13,8 +14,8 @@ export const useUsersStore = defineStore('users', () => { | ||||
|         value: String(user.id), | ||||
|         avatar_url: user.avatar_url, | ||||
|     }))) | ||||
|     const fetchUsers = async () => { | ||||
|         if (users.value.length) return | ||||
|     const fetchUsers = async (force = false) => { | ||||
|         if (!force && users.value.length) return | ||||
|         try { | ||||
|             const response = await api.getUsersCompact() | ||||
|             users.value = response?.data?.data || [] | ||||
|   | ||||
| @@ -17,7 +17,7 @@ | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { onMounted, ref } from 'vue' | ||||
| import { onMounted, onUnmounted, ref } from 'vue' | ||||
| import { createColumns } from '@/features/admin/agents/dataTableColumns.js' | ||||
| import { Button } from '@/components/ui/button' | ||||
| import DataTable from '@/components/datatable/DataTable.vue' | ||||
| @@ -25,10 +25,11 @@ import { handleHTTPError } from '@/utils/http' | ||||
| import { Spinner } from '@/components/ui/spinner' | ||||
| import { useEmitter } from '@/composables/useEmitter' | ||||
| import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' | ||||
| import api from '@/api' | ||||
| import { useUsersStore } from '@/stores/users' | ||||
| import { useI18n } from 'vue-i18n' | ||||
|  | ||||
| const isLoading = ref(false) | ||||
| const usersStore = useUsersStore() | ||||
| const { t } = useI18n() | ||||
| const data = ref([]) | ||||
| const emitter = useEmitter() | ||||
| @@ -40,11 +41,15 @@ onMounted(async () => { | ||||
|   }) | ||||
| }) | ||||
|  | ||||
| onUnmounted(() => { | ||||
|   emitter.off(EMITTER_EVENTS.REFRESH_LIST) | ||||
| }) | ||||
|  | ||||
| const getData = async () => { | ||||
|   try { | ||||
|     isLoading.value = true | ||||
|     const response = await api.getUsers() | ||||
|     data.value = response.data.data | ||||
|     await usersStore.fetchUsers(true) | ||||
|     data.value = usersStore.users | ||||
|   } catch (error) { | ||||
|     emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { | ||||
|       variant: 'destructive', | ||||
|   | ||||
| @@ -20,15 +20,17 @@ import { ref, onMounted } from 'vue' | ||||
| import { Spinner } from '@/components/ui/spinner' | ||||
| import GeneralSettingForm from '@/features/admin/general/GeneralSettingForm.vue' | ||||
| import AdminPageWithHelp from '@/layouts/admin/AdminPageWithHelp.vue' | ||||
| import { useAppSettingsStore } from '@/stores/appSettings' | ||||
| import api from '@/api' | ||||
|  | ||||
| const initialValues = ref({}) | ||||
| const isLoading = ref(false) | ||||
| const settingsStore = useAppSettingsStore() | ||||
|  | ||||
| onMounted(async () => { | ||||
|   isLoading.value = true | ||||
|   const response = await api.getSettings('general') | ||||
|   const data = response.data.data | ||||
|   await settingsStore.fetchSettings('general') | ||||
|   const data = settingsStore.settings | ||||
|   isLoading.value = false | ||||
|   initialValues.value = Object.keys(data).reduce((acc, key) => { | ||||
|     // Remove 'app.' prefix | ||||
|   | ||||
| @@ -32,11 +32,13 @@ import { useRouter } from 'vue-router' | ||||
| import { useI18n } from 'vue-i18n' | ||||
| import { format } from 'date-fns' | ||||
| import { Spinner } from '@/components/ui/spinner' | ||||
| import { useInboxStore } from '@/stores/inbox' | ||||
| import api from '@/api' | ||||
|  | ||||
| const { t } = useI18n() | ||||
| const router = useRouter() | ||||
| const emitter = useEmitter() | ||||
| const inboxStore = useInboxStore() | ||||
| const isLoading = ref(false) | ||||
| const data = ref([]) | ||||
|  | ||||
| @@ -47,8 +49,8 @@ onMounted(async () => { | ||||
| const getInboxes = async () => { | ||||
|   try { | ||||
|     isLoading.value = true | ||||
|     const response = await api.getInboxes() | ||||
|     data.value = response.data.data | ||||
|     await inboxStore.fetchInboxes(true) | ||||
|     data.value = inboxStore.inboxes | ||||
|   } catch (error) { | ||||
|     emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { | ||||
|       variant: 'destructive', | ||||
|   | ||||
| @@ -9,7 +9,7 @@ | ||||
|       <CardContent class="p-6 space-y-6"> | ||||
|         <div class="space-y-2 text-center"> | ||||
|           <CardTitle class="text-3xl font-bold text-foreground"> | ||||
|             {{ appSettingsStore.settings?.['app.site_name'] || 'Libredesk' }} | ||||
|             {{ appSettingsStore.public_config?.['app.site_name'] || 'LIBREDESK' }} | ||||
|           </CardTitle> | ||||
|           <p class="text-muted-foreground">{{ t('auth.signIn') }}</p> | ||||
|         </div> | ||||
| @@ -25,9 +25,8 @@ | ||||
|           > | ||||
|             <img | ||||
|               :src="oidcProvider.logo_url" | ||||
|               :alt="oidcProvider.name" | ||||
|               width="20" | ||||
|               class="mr-2" | ||||
|               alt="" | ||||
|               v-if="oidcProvider.logo_url" | ||||
|             /> | ||||
|             {{ oidcProvider.name }} | ||||
| @@ -89,7 +88,9 @@ | ||||
|             type="submit" | ||||
|           > | ||||
|             <span v-if="isLoading" class="flex items-center justify-center"> | ||||
|               <div class="w-5 h-5 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin mr-3"></div> | ||||
|               <div | ||||
|                 class="w-5 h-5 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin mr-3" | ||||
|               ></div> | ||||
|               {{ t('auth.loggingIn') }} | ||||
|             </span> | ||||
|             <span v-else>{{ t('auth.signInButton') }}</span> | ||||
| @@ -159,8 +160,10 @@ onMounted(async () => { | ||||
|  | ||||
| const fetchOIDCProviders = async () => { | ||||
|   try { | ||||
|     const resp = await api.getAllEnabledOIDC() | ||||
|     oidcProviders.value = resp.data.data | ||||
|     const config = appSettingsStore.public_config | ||||
|     if (config && config['app.sso_providers']) { | ||||
|       oidcProviders.value = config['app.sso_providers'] || [] | ||||
|     } | ||||
|   } catch (error) { | ||||
|     emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { | ||||
|       variant: 'destructive', | ||||
| @@ -204,6 +207,9 @@ const loginAction = () => { | ||||
|       if (resp?.data?.data) { | ||||
|         userStore.setCurrentUser(resp.data.data) | ||||
|       } | ||||
|       // Also fetch general setting as user's logged in. | ||||
|       appSettingsStore.fetchSettings('general') | ||||
|       // Navigate to inboxes | ||||
|       router.push({ name: 'inboxes' }) | ||||
|     }) | ||||
|     .catch((error) => { | ||||
|   | ||||
							
								
								
									
										4
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								go.mod
									
									
									
									
									
								
							| @@ -7,6 +7,7 @@ require ( | ||||
| 	github.com/coreos/go-oidc/v3 v3.11.0 | ||||
| 	github.com/disintegration/imaging v1.6.2 | ||||
| 	github.com/emersion/go-imap/v2 v2.0.0-beta.3 | ||||
| 	github.com/emersion/go-message v0.18.1 | ||||
| 	github.com/fasthttp/websocket v1.5.9 | ||||
| 	github.com/ferluci/fast-realip v1.0.1 | ||||
| 	github.com/google/uuid v1.6.0 | ||||
| @@ -38,7 +39,7 @@ require ( | ||||
| 	github.com/zerodha/simplesessions/v3 v3.0.0 | ||||
| 	golang.org/x/crypto v0.38.0 | ||||
| 	golang.org/x/mod v0.17.0 | ||||
| 	golang.org/x/oauth2 v0.21.0 | ||||
| 	golang.org/x/oauth2 v0.27.0 | ||||
| ) | ||||
|  | ||||
| require ( | ||||
| @@ -49,7 +50,6 @@ require ( | ||||
| 	github.com/cespare/xxhash/v2 v2.3.0 // indirect | ||||
| 	github.com/davecgh/go-spew v1.1.1 // indirect | ||||
| 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect | ||||
| 	github.com/emersion/go-message v0.18.1 // indirect | ||||
| 	github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect | ||||
| 	github.com/fasthttp/router v1.5.0 // indirect | ||||
| 	github.com/fsnotify/fsnotify v1.6.0 // indirect | ||||
|   | ||||
							
								
								
									
										6
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										6
									
								
								go.sum
									
									
									
									
									
								
							| @@ -140,8 +140,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb | ||||
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||||
| github.com/redis/go-redis/v9 v9.5.5 h1:51VEyMF8eOO+NUHFm8fpg+IOc1xFuFOhxs3R+kPu1FM= | ||||
| github.com/redis/go-redis/v9 v9.5.5/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= | ||||
| github.com/rhnvrm/simples3 v0.9.0 h1:It6/glyqRTRooRzXcYOuqpKwjGg3lsXgNmeGgxpBtjA= | ||||
| github.com/rhnvrm/simples3 v0.9.0/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA= | ||||
| github.com/rhnvrm/simples3 v0.9.1 h1:pYfEe2wTjx8B2zFzUdy4kZn3I3Otd9ZvzIhHkFR85kE= | ||||
| github.com/rhnvrm/simples3 v0.9.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA= | ||||
| github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= | ||||
| @@ -211,8 +209,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug | ||||
| golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= | ||||
| golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= | ||||
| golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= | ||||
| golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= | ||||
| golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= | ||||
| golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= | ||||
| golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= | ||||
| golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
|   | ||||
							
								
								
									
										25
									
								
								i18n/en.json
									
									
									
									
									
								
							
							
						
						
									
										25
									
								
								i18n/en.json
									
									
									
									
									
								
							| @@ -188,6 +188,7 @@ | ||||
|   "globals.terms.recipient": "Recipient | Recipients", | ||||
|   "globals.terms.tls": "TLS | TLSs", | ||||
|   "globals.terms.credential": "Credential | Credentials", | ||||
|   "globals.messages.welcomeToLibredesk": "Welcome to Libredesk", | ||||
|   "globals.messages.invalid": "Invalid {name}", | ||||
|   "globals.messages.custom": "Custom {name}", | ||||
|   "globals.messages.replying": "Replying", | ||||
| @@ -294,6 +295,8 @@ | ||||
|   "globals.messages.submit": "Submit", | ||||
|   "globals.messages.send": "Send {name}", | ||||
|   "globals.messages.update": "Update {name}", | ||||
|   "globals.messages.setUp": "Set up", | ||||
|   "globals.messages.invite": "Invite", | ||||
|   "globals.messages.enable": "Enable", | ||||
|   "globals.messages.disable": "Disable", | ||||
|   "globals.messages.block": "Block {name}", | ||||
| @@ -306,6 +309,12 @@ | ||||
|   "globals.messages.reset": "Reset {name}", | ||||
|   "globals.messages.lastNItems": "Last {n} {name} | Last {n} {name}", | ||||
|   "globals.messages.correctEmailErrors": "Please correct the email errors", | ||||
|   "globals.messages.additionalFeedback": "Additional feedback (optional)", | ||||
|   "globals.messages.pleaseSelect": "Please select {name} before submitting", | ||||
|   "globals.messages.poweredBy": "Powered by", | ||||
|   "globals.messages.thankYou": "Thank you!", | ||||
|   "globals.messages.pageNotFound": "Page not found", | ||||
|   "globals.messages.somethingWentWrong": "Something went wrong", | ||||
|   "form.error.min": "Must be at least {min} characters", | ||||
|   "form.error.max": "Must be at most {max} characters", | ||||
|   "form.error.minmax": "Must be between {min} and {max} characters", | ||||
| @@ -339,6 +348,14 @@ | ||||
|   "conversationStatus.alreadyInUse": "Cannot delete status as it is in use, Please remove this status from all conversations before deleting", | ||||
|   "conversationStatus.cannotUpdateDefault": "Cannot update default conversation status", | ||||
|   "csat.alreadySubmitted": "CSAT already submitted", | ||||
|   "csat.rateYourInteraction": "Rate your recent interaction", | ||||
|   "csat.rating.poor": "Poor", | ||||
|   "csat.rating.fair": "Fair", | ||||
|   "csat.rating.good": "Good", | ||||
|   "csat.rating.great": "Great", | ||||
|   "csat.rating.excellent": "Excellent", | ||||
|   "csat.pageTitle": "Rate your interaction with us", | ||||
|   "csat.thankYouMessage": "We appreciate you taking the time to submit your feedback.", | ||||
|   "auth.csrfTokenMismatch": "CSRF token mismatch", | ||||
|   "auth.invalidOrExpiredSession": "Invalid or expired session", | ||||
|   "auth.invalidOrExpiredSessionClearCookie": "Invalid or expired session. Please clear your cookies and try again.", | ||||
| @@ -508,6 +525,7 @@ | ||||
|   "admin.role.contactNotes.write": "Add Contact Notes", | ||||
|   "admin.role.contactNotes.delete": "Delete Contact Notes", | ||||
|   "admin.role.customAttributes.manage": "Manage Custom Attributes", | ||||
|   "admin.role.webhooks.manage": "Manage Webhooks", | ||||
|   "admin.role.activityLog.manage": "Manage Activity Log", | ||||
|   "admin.automation.newConversation.description": "Rules that run when a new conversation is created, drag and drop to reorder rules.", | ||||
|   "admin.automation.conversationUpdate": "Conversation Update", | ||||
| @@ -533,6 +551,7 @@ | ||||
|   "admin.automation.event.message.incoming": "Incoming message", | ||||
|   "admin.automation.invalid": "Make sure you have atleast one action and one rule and their values are not empty.", | ||||
|   "admin.notification.restartApp": "Settings updated successfully, Please restart the app for changes to take effect.", | ||||
|   "admin.banner.restartMessage": "Some settings have been changed that require an application restart to take effect.", | ||||
|   "admin.template.outgoingEmailTemplates": "Outgoing email templates", | ||||
|   "admin.template.emailNotificationTemplates": "Email notification templates", | ||||
|   "admin.template.makeSureTemplateHasContent": "Make sure the template has {content} only once.", | ||||
| @@ -568,6 +587,7 @@ | ||||
|   "search.noResultsForQuery": "No results found for query `{query}`. Try a different search term.", | ||||
|   "search.minQueryLength": " Please enter at least {length} characters to search.", | ||||
|   "search.searchBy": "Search by reference number, contact email address or messages in conversations.", | ||||
|   "search.adjustSearchTerms": "Try adjusting your search terms or filters.", | ||||
|   "sla.overdueBy": "Overdue by", | ||||
|   "sla.met": "SLA met", | ||||
|   "view.form.description": "Create and save custom filter views for quick access to your conversations.", | ||||
| @@ -621,5 +641,8 @@ | ||||
|   "contact.unblockConfirm": "Are you sure you want to unblock this contact? They will be able to interact with you again.", | ||||
|   "contact.alreadyExistsWithEmail": "Another contact with same email already exists", | ||||
|   "contact.notes.empty": "No notes yet", | ||||
|   "contact.notes.help": "Add note for this contact to keep track of important information and conversations." | ||||
|   "contact.notes.help": "Add note for this contact to keep track of important information and conversations.", | ||||
|   "setup.completeYourSetup": "Complete your setup", | ||||
|   "setup.createFirstInbox": "Create your first inbox", | ||||
|   "setup.inviteTeammates": "Invite teammates" | ||||
| } | ||||
| @@ -82,18 +82,9 @@ func (m *Manager) GetAll(order, orderBy, filtersJSON string, page, pageSize int) | ||||
| 	return activityLogs, nil | ||||
| } | ||||
|  | ||||
| // Create adds a new activity log. | ||||
| func (m *Manager) Create(activityType, activityDescription string, actorID int, targetModelType string, targetModelID int, ip string) error { | ||||
| 	if _, err := m.q.InsertActivity.Exec(activityType, activityDescription, actorID, targetModelType, targetModelID, ip); err != nil { | ||||
| 		m.lo.Error("error inserting activity", "error", err) | ||||
| 		return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.activityLog}"), nil) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // Login records a login event for the given user. | ||||
| func (al *Manager) Login(userID int, email, ip string) error { | ||||
| 	return al.Create( | ||||
| 	return al.create( | ||||
| 		models.AgentLogin, | ||||
| 		fmt.Sprintf("%s (#%d) logged in", email, userID), | ||||
| 		userID, | ||||
| @@ -105,7 +96,7 @@ func (al *Manager) Login(userID int, email, ip string) error { | ||||
|  | ||||
| // Logout records a logout event for the given user. | ||||
| func (al *Manager) Logout(userID int, email, ip string) error { | ||||
| 	return al.Create( | ||||
| 	return al.create( | ||||
| 		models.AgentLogout, | ||||
| 		fmt.Sprintf("%s (#%d) logged out", email, userID), | ||||
| 		userID, | ||||
| @@ -123,7 +114,7 @@ func (al *Manager) Away(actorID int, actorEmail, ip string, targetID int, target | ||||
| 	} else { | ||||
| 		description = fmt.Sprintf("%s (#%d) is away", actorEmail, actorID) | ||||
| 	} | ||||
| 	return al.Create( | ||||
| 	return al.create( | ||||
| 		models.AgentAway, /* activity type*/ | ||||
| 		description, | ||||
| 		actorID,           /*actor_id*/ | ||||
| @@ -141,7 +132,7 @@ func (al *Manager) AwayReassigned(actorID int, actorEmail, ip string, targetID i | ||||
| 	} else { | ||||
| 		description = fmt.Sprintf("%s (#%d) is away and reassigning", actorEmail, actorID) | ||||
| 	} | ||||
| 	return al.Create( | ||||
| 	return al.create( | ||||
| 		models.AgentAwayReassigned, /* activity type*/ | ||||
| 		description, | ||||
| 		actorID,           /*actor_id*/ | ||||
| @@ -159,7 +150,7 @@ func (al *Manager) Online(actorID int, actorEmail, ip string, targetID int, targ | ||||
| 	} else { | ||||
| 		description = fmt.Sprintf("%s (#%d) is online", actorEmail, actorID) | ||||
| 	} | ||||
| 	return al.Create( | ||||
| 	return al.create( | ||||
| 		models.AgentOnline, /* activity type*/ | ||||
| 		description, | ||||
| 		actorID,           /*actor_id*/ | ||||
| @@ -190,6 +181,16 @@ func (al *Manager) UserAvailability(actorID int, actorEmail, status, ip, targetE | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // create creates a new activity log in DB. | ||||
| func (m *Manager) create(activityType, activityDescription string, actorID int, targetModelType string, targetModelID int, ip string) error { | ||||
| 	var activityLog models.ActivityLog | ||||
| 	if err := m.q.InsertActivity.Get(&activityLog, activityType, activityDescription, actorID, targetModelType, targetModelID, ip); err != nil { | ||||
| 		m.lo.Error("error inserting activity log", "error", err) | ||||
| 		return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.activityLog}"), nil) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // makeQuery constructs the SQL query for fetching activity logs with filters and pagination. | ||||
| func (m *Manager) makeQuery(page, pageSize int, order, orderBy, filtersJSON string) (string, []any, error) { | ||||
| 	var ( | ||||
|   | ||||
| @@ -2,10 +2,10 @@ | ||||
| SELECT id, name, provider, config, is_default FROM ai_providers where is_default is true; | ||||
|  | ||||
| -- name: get-prompt | ||||
| SELECT id, key, title, content FROM ai_prompts where key = $1; | ||||
| SELECT id, created_at, updated_at, key, title, content FROM ai_prompts where key = $1; | ||||
|  | ||||
| -- name: get-prompts | ||||
| SELECT id, key, title FROM ai_prompts order by title; | ||||
| SELECT id, created_at, updated_at, key, title FROM ai_prompts order by title; | ||||
|  | ||||
| -- name: set-openai-key | ||||
| UPDATE ai_providers  | ||||
|   | ||||
| @@ -183,6 +183,7 @@ func (e *Enforcer) EnforceConversationAccess(user umodels.User, conversation cmo | ||||
| // EnforceMediaAccess checks for read access on linked model to media. | ||||
| func (e *Enforcer) EnforceMediaAccess(user umodels.User, model string) (bool, error) { | ||||
| 	switch model { | ||||
| 	// TODO: Pick this table / model name from the package/models/models.go | ||||
| 	case "messages": | ||||
| 		allowed, err := e.Enforce(user, model, "read") | ||||
| 		if err != nil { | ||||
|   | ||||
| @@ -33,7 +33,7 @@ type conversationStore interface { | ||||
|  | ||||
| type teamStore interface { | ||||
| 	GetAll() ([]tmodels.Team, error) | ||||
| 	GetMembers(teamID int) ([]umodels.User, error) | ||||
| 	GetMembers(teamID int) ([]tmodels.TeamMember, error) | ||||
| } | ||||
|  | ||||
| // Engine represents a manager for assigning unassigned conversations | ||||
|   | ||||
| @@ -7,10 +7,10 @@ select | ||||
| from automation_rules where enabled is TRUE ORDER BY weight ASC; | ||||
|  | ||||
| -- name: get-all | ||||
| SELECT id, created_at, updated_at, enabled, name, description, type, events, rules, execution_mode from automation_rules where type = $1 ORDER BY weight ASC; | ||||
| SELECT id, created_at, updated_at, "name", description, "type", rules, events, enabled, weight, execution_mode from automation_rules where type = $1 ORDER BY weight ASC; | ||||
|  | ||||
| -- name: get-rule | ||||
| SELECT id, created_at, updated_at, enabled, name, description, type, events, rules, execution_mode from automation_rules where id = $1; | ||||
| SELECT id, created_at, updated_at, "name", description, "type", rules, events, enabled, weight, execution_mode from automation_rules where id = $1; | ||||
|  | ||||
| -- name: update-rule | ||||
| INSERT INTO automation_rules(id, name, description, type, events, rules, enabled) | ||||
|   | ||||
| @@ -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; | ||||
|  | ||||
|   | ||||
| @@ -200,7 +200,7 @@ type queries struct { | ||||
| 	GetConversationsCreatedAfter       *sqlx.Stmt `query:"get-conversations-created-after"` | ||||
| 	GetUnassignedConversations         *sqlx.Stmt `query:"get-unassigned-conversations"` | ||||
| 	GetConversations                   string     `query:"get-conversations"` | ||||
| 	GetContactConversations            *sqlx.Stmt `query:"get-contact-conversations"` | ||||
| 	GetContactPreviousConversations    *sqlx.Stmt `query:"get-contact-previous-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"` | ||||
| @@ -280,11 +280,11 @@ func (c *Manager) GetConversation(id int, uuid string) (models.Conversation, err | ||||
| 	return conversation, nil | ||||
| } | ||||
|  | ||||
| // GetContactConversations retrieves conversations for a contact. | ||||
| func (c *Manager) GetContactConversations(contactID int) ([]models.Conversation, error) { | ||||
| 	var conversations = make([]models.Conversation, 0) | ||||
| 	if err := c.q.GetContactConversations.Select(&conversations, contactID); err != nil { | ||||
| 		c.lo.Error("error fetching conversations", "error", err) | ||||
| // GetContactPreviousConversations retrieves previous conversations for a contact with a configurable limit. | ||||
| func (c *Manager) GetContactPreviousConversations(contactID int, limit int) ([]models.PreviousConversation, error) { | ||||
| 	var conversations = make([]models.PreviousConversation, 0) | ||||
| 	if err := c.q.GetContactPreviousConversations.Select(&conversations, contactID, limit); err != nil { | ||||
| 		c.lo.Error("error fetching previous conversations", "error", err) | ||||
| 		return conversations, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil) | ||||
| 	} | ||||
| 	return conversations, nil | ||||
| @@ -348,32 +348,32 @@ func (c *Manager) GetConversationUUID(id int) (string, error) { | ||||
| } | ||||
|  | ||||
| // GetAllConversationsList retrieves all conversations with optional filtering, ordering, and pagination. | ||||
| func (c *Manager) GetAllConversationsList(order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) { | ||||
| func (c *Manager) GetAllConversationsList(order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) { | ||||
| 	return c.GetConversations(0, []int{}, []string{models.AllConversations}, order, orderBy, filters, page, pageSize) | ||||
| } | ||||
|  | ||||
| // GetAssignedConversationsList retrieves conversations assigned to a specific user with optional filtering, ordering, and pagination. | ||||
| func (c *Manager) GetAssignedConversationsList(userID int, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) { | ||||
| func (c *Manager) GetAssignedConversationsList(userID int, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) { | ||||
| 	return c.GetConversations(userID, []int{}, []string{models.AssignedConversations}, order, orderBy, filters, page, pageSize) | ||||
| } | ||||
|  | ||||
| // GetUnassignedConversationsList retrieves conversations assigned to a team the user is part of with optional filtering, ordering, and pagination. | ||||
| func (c *Manager) GetUnassignedConversationsList(order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) { | ||||
| func (c *Manager) GetUnassignedConversationsList(order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) { | ||||
| 	return c.GetConversations(0, []int{}, []string{models.UnassignedConversations}, order, orderBy, filters, page, pageSize) | ||||
| } | ||||
|  | ||||
| // GetTeamUnassignedConversationsList retrieves conversations assigned to a team with optional filtering, ordering, and pagination. | ||||
| func (c *Manager) GetTeamUnassignedConversationsList(teamID int, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) { | ||||
| func (c *Manager) GetTeamUnassignedConversationsList(teamID int, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) { | ||||
| 	return c.GetConversations(0, []int{teamID}, []string{models.TeamUnassignedConversations}, order, orderBy, filters, page, pageSize) | ||||
| } | ||||
|  | ||||
| func (c *Manager) GetViewConversationsList(userID int, teamIDs []int, listType []string, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) { | ||||
| func (c *Manager) GetViewConversationsList(userID int, teamIDs []int, listType []string, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) { | ||||
| 	return c.GetConversations(userID, teamIDs, listType, order, orderBy, filters, page, pageSize) | ||||
| } | ||||
|  | ||||
| // GetConversations retrieves conversations list based on user ID, type, and optional filtering, ordering, and pagination. | ||||
| func (c *Manager) GetConversations(userID int, teamIDs []int, listTypes []string, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) { | ||||
| 	var conversations = make([]models.Conversation, 0) | ||||
| func (c *Manager) GetConversations(userID int, teamIDs []int, listTypes []string, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) { | ||||
| 	var conversations = make([]models.ConversationListItem, 0) | ||||
|  | ||||
| 	// Make the query. | ||||
| 	query, qArgs, err := c.makeConversationsListQuery(userID, teamIDs, listTypes, c.q.GetConversations, order, orderBy, page, pageSize, filters) | ||||
| @@ -930,7 +930,7 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("making recipients for reply action: %w", err) | ||||
| 		} | ||||
| 		_, err = m.SendReply( | ||||
| 		_, err = m.QueueReply( | ||||
| 			[]mmodels.Media{}, | ||||
| 			conv.InboxID, | ||||
| 			user.ID, | ||||
| @@ -1001,8 +1001,8 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio | ||||
| 		return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.csat}"), nil) | ||||
| 	} | ||||
|  | ||||
| 	// Send CSAT reply. | ||||
| 	_, err = m.SendReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.UUID, message, to, cc, bcc, meta) | ||||
| 	// Queue CSAT reply. | ||||
| 	_, err = m.QueueReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.UUID, message, to, cc, bcc, meta) | ||||
| 	if err != nil { | ||||
| 		m.lo.Error("error sending CSAT reply", "conversation_uuid", conversation.UUID, "error", err) | ||||
| 		return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.csat}"), nil) | ||||
|   | ||||
| @@ -167,7 +167,7 @@ func (m *Manager) sendOutgoingMessage(message models.Message) { | ||||
| 	} | ||||
|  | ||||
| 	// References is sorted in DESC i.e newest message first, so reverse it to keep the references in order. | ||||
| 	stringutil.ReverseSlice(message.References) | ||||
| 	slices.Reverse(message.References) | ||||
|  | ||||
| 	// Remove the current message ID from the references. | ||||
| 	message.References = stringutil.RemoveItemByValue(message.References, message.SourceID.String) | ||||
| @@ -347,9 +347,10 @@ func (m *Manager) UpdateMessageStatus(messageUUID string, status string) error { | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // MarkMessageAsPending updates message status to `Pending`, so if it's a outgoing message it can be picked up again by a worker. | ||||
| // MarkMessageAsPending updates message status to `Pending`, enqueuing it for sending. | ||||
| func (m *Manager) MarkMessageAsPending(uuid string) error { | ||||
| 	if err := m.UpdateMessageStatus(uuid, models.MessageStatusPending); err != nil { | ||||
| 		m.lo.Error("error marking message as pending", "uuid", uuid, "error", err) | ||||
| 		return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil) | ||||
| 	} | ||||
| 	return nil | ||||
| @@ -374,8 +375,27 @@ func (m *Manager) SendPrivateNote(media []mmodels.Media, senderID int, conversat | ||||
| 	return message, nil | ||||
| } | ||||
|  | ||||
| // 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{}) (models.Message, error) { | ||||
| // CreateContactMessage creates a contact message in a conversation. | ||||
| func (m *Manager) CreateContactMessage(media []mmodels.Media, contactID int, conversationUUID, content, contentType string) (models.Message, error) { | ||||
| 	message := models.Message{ | ||||
| 		ConversationUUID: conversationUUID, | ||||
| 		SenderID:         contactID, | ||||
| 		Type:             models.MessageIncoming, | ||||
| 		SenderType:       models.SenderTypeContact, | ||||
| 		Status:           models.MessageStatusReceived, | ||||
| 		Content:          content, | ||||
| 		ContentType:      contentType, | ||||
| 		Private:          false, | ||||
| 		Media:            media, | ||||
| 	} | ||||
| 	if err := m.InsertMessage(&message); err != nil { | ||||
| 		return models.Message{}, err | ||||
| 	} | ||||
| 	return message, nil | ||||
| } | ||||
|  | ||||
| // QueueReply queues a reply message in a conversation. | ||||
| func (m *Manager) QueueReply(media []mmodels.Media, inboxID, senderID int, conversationUUID, content string, to, cc, bcc []string, meta map[string]interface{}) (models.Message, error) { | ||||
| 	var ( | ||||
| 		message = models.Message{} | ||||
| 	) | ||||
| @@ -402,7 +422,7 @@ func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID int, conver | ||||
| 		return message, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorMarshalling", "name", "{globals.terms.meta}"), nil) | ||||
| 	} | ||||
|  | ||||
| 	// Generage unique source ID i.e. message-id for email. | ||||
| 	// Generate unique source ID i.e. message-id for email. | ||||
| 	inbox, err := m.inboxStore.GetDBRecord(inboxID) | ||||
| 	if err != nil { | ||||
| 		return message, err | ||||
|   | ||||
| @@ -52,48 +52,124 @@ var ( | ||||
| 	ContentTypeHTML = "html" | ||||
| ) | ||||
|  | ||||
| // ConversationListItem represents a conversation in list views | ||||
| type ConversationListItem struct { | ||||
| 	Total              int                     `db:"total" json:"-"` | ||||
| 	ID                 int                     `db:"id" json:"id"` | ||||
| 	CreatedAt          time.Time               `db:"created_at" json:"created_at"` | ||||
| 	UpdatedAt          time.Time               `db:"updated_at" json:"updated_at"` | ||||
| 	UUID               string                  `db:"uuid" json:"uuid"` | ||||
| 	WaitingSince       null.Time               `db:"waiting_since" json:"waiting_since"` | ||||
| 	AssigneeLastSeenAt null.Time               `db:"assignee_last_seen_at" json:"assignee_last_seen_at"` | ||||
| 	Contact            ConversationListContact `db:"contact" json:"contact"` | ||||
| 	InboxChannel       string                  `db:"inbox_channel" json:"inbox_channel"` | ||||
| 	InboxName          string                  `db:"inbox_name" json:"inbox_name"` | ||||
| 	SLAPolicyID        null.Int                `db:"sla_policy_id" json:"sla_policy_id"` | ||||
| 	FirstReplyAt       null.Time               `db:"first_reply_at" json:"first_reply_at"` | ||||
| 	LastReplyAt        null.Time               `db:"last_reply_at" json:"last_reply_at"` | ||||
| 	ResolvedAt         null.Time               `db:"resolved_at" json:"resolved_at"` | ||||
| 	Subject            null.String             `db:"subject" json:"subject"` | ||||
| 	LastMessage        null.String             `db:"last_message" json:"last_message"` | ||||
| 	LastMessageAt      null.Time               `db:"last_message_at" json:"last_message_at"` | ||||
| 	LastMessageSender  null.String             `db:"last_message_sender" json:"last_message_sender"` | ||||
| 	NextSLADeadlineAt  null.Time               `db:"next_sla_deadline_at" json:"next_sla_deadline_at"` | ||||
| 	PriorityID         null.Int                `db:"priority_id" json:"priority_id"` | ||||
| 	UnreadMessageCount int                     `db:"unread_message_count" json:"unread_message_count"` | ||||
| 	Status             null.String             `db:"status" json:"status"` | ||||
| 	Priority           null.String             `db:"priority" json:"priority"` | ||||
| 	FirstResponseDueAt null.Time               `db:"first_response_deadline_at" json:"first_response_deadline_at"` | ||||
| 	ResolutionDueAt    null.Time               `db:"resolution_deadline_at" json:"resolution_deadline_at"` | ||||
| 	AppliedSLAID       null.Int                `db:"applied_sla_id" json:"applied_sla_id"` | ||||
| 	NextResponseDueAt  null.Time               `db:"next_response_deadline_at" json:"next_response_deadline_at"` | ||||
| 	NextResponseMetAt  null.Time               `db:"next_response_met_at" json:"next_response_met_at"` | ||||
| } | ||||
|  | ||||
| // ConversationListContact represents contact info in conversation list views | ||||
| type ConversationListContact struct { | ||||
| 	CreatedAt time.Time   `db:"created_at" json:"created_at"` | ||||
| 	UpdatedAt time.Time   `db:"updated_at" json:"updated_at"` | ||||
| 	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"` | ||||
| } | ||||
|  | ||||
| type Conversation 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"` | ||||
| 	ContactID             int             `db:"contact_id" json:"contact_id"` | ||||
| 	InboxID               int             `db:"inbox_id" json:"inbox_id,omitempty"` | ||||
| 	ClosedAt              null.Time       `db:"closed_at" json:"closed_at,omitempty"` | ||||
| 	ResolvedAt            null.Time       `db:"resolved_at" json:"resolved_at,omitempty"` | ||||
| 	ReferenceNumber       string          `db:"reference_number" json:"reference_number,omitempty"` | ||||
| 	Priority              null.String     `db:"priority" json:"priority"` | ||||
| 	PriorityID            null.Int        `db:"priority_id" json:"priority_id"` | ||||
| 	Status                null.String     `db:"status" json:"status"` | ||||
| 	StatusID              null.Int        `db:"status_id" json:"status_id"` | ||||
| 	FirstReplyAt          null.Time       `db:"first_reply_at" json:"first_reply_at"` | ||||
| 	LastReplyAt           null.Time       `db:"last_reply_at" json:"last_reply_at"` | ||||
| 	AssignedUserID        null.Int        `db:"assigned_user_id" json:"assigned_user_id"` | ||||
| 	AssignedTeamID        null.Int        `db:"assigned_team_id" json:"assigned_team_id"` | ||||
| 	AssigneeLastSeenAt    null.Time       `db:"assignee_last_seen_at" json:"assignee_last_seen_at"` | ||||
| 	WaitingSince          null.Time       `db:"waiting_since" json:"waiting_since"` | ||||
| 	Subject               null.String     `db:"subject" json:"subject"` | ||||
| 	UnreadMessageCount    int             `db:"unread_message_count" json:"unread_message_count"` | ||||
| 	InboxMail             string          `db:"inbox_mail" json:"inbox_mail"` | ||||
| 	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"` | ||||
| 	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"` | ||||
| 	LastMessageSender     null.String     `db:"last_message_sender" json:"last_message_sender"` | ||||
| 	Contact               umodels.User    `db:"contact" json:"contact"` | ||||
| 	SLAPolicyID           null.Int        `db:"sla_policy_id" json:"sla_policy_id"` | ||||
| 	SlaPolicyName         null.String     `db:"sla_policy_name" json:"sla_policy_name"` | ||||
| 	AppliedSLAID          null.Int        `db:"applied_sla_id" json:"applied_sla_id"` | ||||
| 	NextSLADeadlineAt     null.Time       `db:"next_sla_deadline_at" json:"next_sla_deadline_at"` | ||||
| 	FirstResponseDueAt    null.Time       `db:"first_response_deadline_at" json:"first_response_deadline_at"` | ||||
| 	ResolutionDueAt       null.Time       `db:"resolution_deadline_at" json:"resolution_deadline_at"` | ||||
| 	NextResponseDueAt     null.Time       `db:"next_response_deadline_at" json:"next_response_deadline_at"` | ||||
| 	NextResponseMetAt     null.Time       `db:"next_response_met_at" json:"next_response_met_at"` | ||||
| 	PreviousConversations []Conversation  `db:"-" json:"previous_conversations"` | ||||
| 	Total                 int             `db:"total" json:"-"` | ||||
| 	ID                    int                    `db:"id" json:"id"` | ||||
| 	CreatedAt             time.Time              `db:"created_at" json:"created_at"` | ||||
| 	UpdatedAt             time.Time              `db:"updated_at" json:"updated_at"` | ||||
| 	UUID                  string                 `db:"uuid" json:"uuid"` | ||||
| 	ContactID             int                    `db:"contact_id" json:"contact_id"` | ||||
| 	InboxID               int                    `db:"inbox_id" json:"inbox_id"` | ||||
| 	ClosedAt              null.Time              `db:"closed_at" json:"closed_at"` | ||||
| 	ResolvedAt            null.Time              `db:"resolved_at" json:"resolved_at"` | ||||
| 	ReferenceNumber       string                 `db:"reference_number" json:"reference_number"` | ||||
| 	Priority              null.String            `db:"priority" json:"priority"` | ||||
| 	PriorityID            null.Int               `db:"priority_id" json:"priority_id"` | ||||
| 	Status                null.String            `db:"status" json:"status"` | ||||
| 	StatusID              null.Int               `db:"status_id" json:"status_id"` | ||||
| 	FirstReplyAt          null.Time              `db:"first_reply_at" json:"first_reply_at"` | ||||
| 	LastReplyAt           null.Time              `db:"last_reply_at" json:"last_reply_at"` | ||||
| 	AssignedUserID        null.Int               `db:"assigned_user_id" json:"assigned_user_id"` | ||||
| 	AssignedTeamID        null.Int               `db:"assigned_team_id" json:"assigned_team_id"` | ||||
| 	AssigneeLastSeenAt    null.Time              `db:"assignee_last_seen_at" json:"assignee_last_seen_at"` | ||||
| 	WaitingSince          null.Time              `db:"waiting_since" json:"waiting_since"` | ||||
| 	Subject               null.String            `db:"subject" json:"subject"` | ||||
| 	InboxMail             string                 `db:"inbox_mail" json:"inbox_mail"` | ||||
| 	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"` | ||||
| 	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"` | ||||
| 	LastMessageSender     null.String            `db:"last_message_sender" json:"last_message_sender"` | ||||
| 	Contact               ConversationContact    `db:"contact" json:"contact"` | ||||
| 	SLAPolicyID           null.Int               `db:"sla_policy_id" json:"sla_policy_id"` | ||||
| 	SlaPolicyName         null.String            `db:"sla_policy_name" json:"sla_policy_name"` | ||||
| 	AppliedSLAID          null.Int               `db:"applied_sla_id" json:"applied_sla_id"` | ||||
| 	FirstResponseDueAt    null.Time              `db:"first_response_deadline_at" json:"first_response_deadline_at"` | ||||
| 	ResolutionDueAt       null.Time              `db:"resolution_deadline_at" json:"resolution_deadline_at"` | ||||
| 	NextResponseDueAt     null.Time              `db:"next_response_deadline_at" json:"next_response_deadline_at"` | ||||
| 	NextResponseMetAt     null.Time              `db:"next_response_met_at" json:"next_response_met_at"` | ||||
| 	PreviousConversations []PreviousConversation `db:"-" json:"previous_conversations"` | ||||
| } | ||||
|  | ||||
| type ConversationContact struct { | ||||
| 	ID                     int             `db:"id" json:"id"` | ||||
| 	CreatedAt              time.Time       `db:"created_at" json:"created_at"` | ||||
| 	UpdatedAt              time.Time       `db:"updated_at" json:"updated_at"` | ||||
| 	FirstName              string          `db:"first_name" json:"first_name"` | ||||
| 	LastName               string          `db:"last_name" json:"last_name"` | ||||
| 	Email                  null.String     `db:"email" json:"email"` | ||||
| 	Type                   string          `db:"type" json:"type"` | ||||
| 	AvailabilityStatus     string          `db:"availability_status" json:"availability_status"` | ||||
| 	AvatarURL              null.String     `db:"avatar_url" json:"avatar_url"` | ||||
| 	PhoneNumber            null.String     `db:"phone_number" json:"phone_number"` | ||||
| 	PhoneNumberCallingCode null.String     `db:"phone_number_calling_code" json:"phone_number_calling_code"` | ||||
| 	CustomAttributes       json.RawMessage `db:"custom_attributes" json:"custom_attributes"` | ||||
| 	Enabled                bool            `db:"enabled" json:"enabled"` | ||||
| 	LastActiveAt           null.Time       `db:"last_active_at" json:"last_active_at"` | ||||
| 	LastLoginAt            null.Time       `db:"last_login_at" json:"last_login_at"` | ||||
| } | ||||
|  | ||||
| func (c *ConversationContact) FullName() string { | ||||
| 	return c.FirstName + " " + c.LastName | ||||
| } | ||||
|  | ||||
| type PreviousConversation struct { | ||||
| 	ID            int                         `db:"id" json:"id"` | ||||
| 	CreatedAt     time.Time                   `db:"created_at" json:"created_at"` | ||||
| 	UpdatedAt     time.Time                   `db:"updated_at" json:"updated_at"` | ||||
| 	UUID          string                      `db:"uuid" json:"uuid"` | ||||
| 	Contact       PreviousConversationContact `db:"contact" json:"contact"` | ||||
| 	LastMessage   null.String                 `db:"last_message" json:"last_message"` | ||||
| 	LastMessageAt null.Time                   `db:"last_message_at" json:"last_message_at"` | ||||
| } | ||||
|  | ||||
| type PreviousConversationContact struct { | ||||
| 	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"` | ||||
| } | ||||
|  | ||||
| type ConversationParticipant struct { | ||||
| @@ -117,13 +193,15 @@ type NewConversationsStats struct { | ||||
|  | ||||
| // Message represents a message in a conversation | ||||
| type Message struct { | ||||
| 	ID               int                    `db:"id" json:"id,omitempty"` | ||||
| 	Total            int                    `db:"total" json:"-"` | ||||
| 	ID               int                    `db:"id" json:"id"` | ||||
| 	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"` | ||||
| 	ConversationUUID string                 `db:"conversation_uuid" json:"conversation_uuid"` | ||||
| 	Content          string                 `db:"content" json:"content"` | ||||
| 	TextContent      string                 `db:"text_content" json:"text_content"` | ||||
| 	ContentType      string                 `db:"content_type" json:"content_type"` | ||||
| @@ -134,7 +212,6 @@ type Message struct { | ||||
| 	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:"-"` | ||||
| @@ -144,10 +221,9 @@ type Message struct { | ||||
| 	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:"-"` | ||||
| 	AltContent       string                 `json:"-"` | ||||
| 	Media            []mmodels.Media        `json:"-"` | ||||
| 	IsCSAT           bool                   `json:"-"` | ||||
| } | ||||
|  | ||||
| // CensorCSATContent redacts the content of a CSAT message to prevent leaking the CSAT survey public link. | ||||
|   | ||||
| @@ -99,6 +99,8 @@ SELECT | ||||
|    c.closed_at, | ||||
|    c.resolved_at, | ||||
|    c.inbox_id, | ||||
|    c.assignee_last_seen_at, | ||||
|    inb.name as inbox_name, | ||||
|    COALESCE(inb.from, '') as inbox_mail, | ||||
|    COALESCE(inb.channel::TEXT, '') as inbox_channel, | ||||
|    c.status_id, | ||||
| @@ -140,7 +142,6 @@ SELECT | ||||
|    ct.phone_number as "contact.phone_number", | ||||
|    ct.phone_number_calling_code as "contact.phone_number_calling_code", | ||||
|    ct.custom_attributes as "contact.custom_attributes", | ||||
|    ct.avatar_url as "contact.avatar_url", | ||||
|    ct.enabled as "contact.enabled", | ||||
|    ct.last_active_at as "contact.last_active_at", | ||||
|    ct.last_login_at as "contact.last_login_at", | ||||
| @@ -183,8 +184,11 @@ SELECT | ||||
| FROM conversations c | ||||
| WHERE c.created_at > $1; | ||||
|  | ||||
| -- name: get-contact-conversations | ||||
| -- name: get-contact-previous-conversations | ||||
| SELECT | ||||
|     c.id, | ||||
|     c.created_at, | ||||
|     c.updated_at, | ||||
|     c.uuid, | ||||
|     u.first_name AS "contact.first_name", | ||||
|     u.last_name AS "contact.last_name", | ||||
| @@ -195,7 +199,7 @@ 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; | ||||
| LIMIT $2; | ||||
|  | ||||
| -- name: get-conversation-uuid | ||||
| SELECT uuid from conversations where id = $1; | ||||
| @@ -400,22 +404,27 @@ LIMIT $2; | ||||
|  | ||||
| -- name: get-outgoing-pending-messages | ||||
| SELECT | ||||
|     m.created_at, | ||||
|     m.id, | ||||
|     m.uuid, | ||||
|     m.sender_id, | ||||
|     m.type, | ||||
|     m.private, | ||||
|     m.created_at, | ||||
|     m.updated_at, | ||||
|     m.status, | ||||
|     m.type, | ||||
|     m.content, | ||||
|     m.text_content, | ||||
|     m.content_type, | ||||
|     m.conversation_id, | ||||
|     m.uuid, | ||||
|     m.private, | ||||
|     m.sender_type, | ||||
|     m.sender_id, | ||||
|     m.meta, | ||||
|     c.uuid as conversation_uuid, | ||||
|     m.content_type, | ||||
|     m.source_id, | ||||
|     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 | ||||
| FROM conversation_messages m | ||||
| INNER JOIN conversations c ON c.id = m.conversation_id | ||||
| @@ -438,6 +447,7 @@ SELECT | ||||
|     m.sender_type, | ||||
|     m.sender_id, | ||||
|     m.meta, | ||||
|     c.uuid as conversation_uuid, | ||||
|     COALESCE( | ||||
|         json_agg( | ||||
|             json_build_object( | ||||
| @@ -452,25 +462,31 @@ SELECT | ||||
|         '[]'::json | ||||
|     ) AS attachments | ||||
| FROM conversation_messages m | ||||
| INNER JOIN conversations c ON c.id = m.conversation_id | ||||
| LEFT JOIN media ON media.model_type = 'messages' AND media.model_id = m.id | ||||
| WHERE m.uuid = $1 | ||||
| GROUP BY  | ||||
|     m.id, m.created_at, m.updated_at, m.status, m.type, m.content, m.uuid, m.private, m.sender_type | ||||
|     m.id, m.created_at, m.updated_at, m.status, m.type, m.content, m.uuid, m.private, m.sender_type, c.uuid | ||||
| ORDER BY m.created_at; | ||||
|  | ||||
| -- name: get-messages | ||||
| SELECT | ||||
|    COUNT(*) OVER() AS total, | ||||
|    m.id, | ||||
|    m.created_at, | ||||
|    m.updated_at, | ||||
|    m.status, | ||||
|    m.type,  | ||||
|    m.content, | ||||
|    m.text_content, | ||||
|    m.content_type, | ||||
|    m.conversation_id, | ||||
|    m.uuid, | ||||
|    m.private, | ||||
|    m.sender_id, | ||||
|    m.sender_type, | ||||
|    m.meta, | ||||
|    $1::uuid AS conversation_uuid, | ||||
|    COALESCE( | ||||
|      (SELECT json_agg( | ||||
|        json_build_object( | ||||
|   | ||||
| @@ -93,7 +93,7 @@ func (m *Manager) UpdateResponse(uuid string, score int, feedback string) error | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if csat.Score > 0 || !csat.ResponseTimestamp.IsZero() { | ||||
| 	if csat.Rating > 0 || !csat.ResponseTimestamp.IsZero() { | ||||
| 		return envelope.NewError(envelope.InputError, m.i18n.T("csat.alreadySubmitted"), nil) | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -10,11 +10,11 @@ import ( | ||||
| // CSATResponse represents a customer satisfaction survey response. | ||||
| type CSATResponse struct { | ||||
| 	ID                int         `db:"id"` | ||||
| 	UUID              string      `db:"uuid"` | ||||
| 	CreatedAt         time.Time   `db:"created_at"` | ||||
| 	UpdatedAt         time.Time   `db:"updated_at"` | ||||
| 	UUID              string      `db:"uuid"` | ||||
| 	ConversationID    int         `db:"conversation_id"` | ||||
| 	Score             int         `db:"rating"` | ||||
| 	Rating            int         `db:"rating"` | ||||
| 	Feedback          null.String `db:"feedback"` | ||||
| 	ResponseTimestamp null.Time   `db:"response_timestamp"` | ||||
| } | ||||
|   | ||||
| @@ -10,9 +10,9 @@ type CustomAttribute struct { | ||||
| 	ID          int            `db:"id" json:"id"` | ||||
| 	CreatedAt   time.Time      `db:"created_at" json:"created_at"` | ||||
| 	UpdatedAt   time.Time      `db:"updated_at" json:"updated_at"` | ||||
| 	AppliesTo   string         `db:"applies_to" json:"applies_to"` | ||||
| 	Name        string         `db:"name" json:"name"` | ||||
| 	Description string         `db:"description" json:"description"` | ||||
| 	AppliesTo   string         `db:"applies_to" json:"applies_to"` | ||||
| 	Key         string         `db:"key" json:"key"` | ||||
| 	Values      pq.StringArray `db:"values" json:"values"` | ||||
| 	DataType    string         `db:"data_type" json:"data_type"` | ||||
|   | ||||
| @@ -3,9 +3,9 @@ SELECT | ||||
|     id, | ||||
|     created_at, | ||||
|     updated_at, | ||||
|     applies_to, | ||||
|     name, | ||||
|     description, | ||||
|     applies_to, | ||||
|     key, | ||||
|     values, | ||||
|     data_type, | ||||
| @@ -25,9 +25,9 @@ SELECT | ||||
|     id, | ||||
|     created_at, | ||||
|     updated_at, | ||||
|     applies_to, | ||||
|     name, | ||||
|     description, | ||||
|     applies_to, | ||||
|     key, | ||||
|     values, | ||||
|     data_type, | ||||
|   | ||||
| @@ -2,8 +2,6 @@ | ||||
| package envelope | ||||
|  | ||||
| import ( | ||||
| 	"net/http" | ||||
|  | ||||
| 	"github.com/valyala/fasthttp" | ||||
| ) | ||||
|  | ||||
| @@ -53,13 +51,13 @@ func NewError(etype string, message string, data interface{}) error { | ||||
| 	case GeneralError: | ||||
| 		err.Code = fasthttp.StatusInternalServerError | ||||
| 	case PermissionError: | ||||
| 		err.Code = http.StatusForbidden | ||||
| 		err.Code = fasthttp.StatusForbidden | ||||
| 	case InputError: | ||||
| 		err.Code = fasthttp.StatusBadRequest | ||||
| 	case DataError: | ||||
| 		err.Code = http.StatusBadGateway | ||||
| 		err.Code = fasthttp.StatusUnprocessableEntity | ||||
| 	case NetworkError: | ||||
| 		err.Code = http.StatusGatewayTimeout | ||||
| 		err.Code = fasthttp.StatusGatewayTimeout | ||||
| 	case NotFoundError: | ||||
| 		err.Code = fasthttp.StatusNotFound | ||||
| 	case ConflictError: | ||||
|   | ||||
| @@ -140,6 +140,7 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient. | ||||
| 					headerAutoSubmitted, | ||||
| 					headerAutoreply, | ||||
| 					headerLibredeskLoopPrevention, | ||||
| 					headerMessageID, | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| @@ -147,10 +148,11 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient. | ||||
|  | ||||
| 	// Collect messages to process later. | ||||
| 	type msgData struct { | ||||
| 		env       *imap.Envelope | ||||
| 		seqNum    uint32 | ||||
| 		autoReply bool | ||||
| 		isLoop    bool | ||||
| 		env                *imap.Envelope | ||||
| 		seqNum             uint32 | ||||
| 		autoReply          bool | ||||
| 		isLoop             bool | ||||
| 		extractedMessageID string | ||||
| 	} | ||||
| 	var messages []msgData | ||||
|  | ||||
| @@ -182,9 +184,10 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient. | ||||
| 		} | ||||
|  | ||||
| 		var ( | ||||
| 			env       *imap.Envelope | ||||
| 			autoReply bool | ||||
| 			isLoop    bool | ||||
| 			env                *imap.Envelope | ||||
| 			autoReply          bool | ||||
| 			isLoop             bool | ||||
| 			extractedMessageID string | ||||
| 		) | ||||
| 		// Process all fetch items for the current message. | ||||
| 		for { | ||||
| @@ -215,6 +218,9 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient. | ||||
| 				if isLoopMessage(envelope, inboxEmail) { | ||||
| 					isLoop = true | ||||
| 				} | ||||
|  | ||||
| 				// Extract Message-Id from raw headers as fallback for problematic Message IDs | ||||
| 				extractedMessageID = extractMessageIDFromHeaders(envelope) | ||||
| 			} | ||||
|  | ||||
| 			// Envelope. | ||||
| @@ -223,12 +229,13 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient. | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		// Skip if we couldn't get headers or envelope. | ||||
| 		// Skip if we couldn't get the envelope. | ||||
| 		if env == nil { | ||||
| 			e.lo.Warn("skipping message without envelope", "seq_num", msg.SeqNum, "inbox_id", e.Identifier()) | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		messages = append(messages, msgData{env: env, seqNum: msg.SeqNum, autoReply: autoReply, isLoop: isLoop}) | ||||
| 		messages = append(messages, msgData{env: env, seqNum: msg.SeqNum, autoReply: autoReply, isLoop: isLoop, extractedMessageID: extractedMessageID}) | ||||
| 	} | ||||
|  | ||||
| 	// Now process each collected message. | ||||
| @@ -253,7 +260,7 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient. | ||||
| 		} | ||||
|  | ||||
| 		// Process the envelope. | ||||
| 		if err := e.processEnvelope(ctx, client, msgData.env, msgData.seqNum, inboxID); err != nil && err != context.Canceled { | ||||
| 		if err := e.processEnvelope(ctx, client, msgData.env, msgData.seqNum, inboxID, msgData.extractedMessageID); err != nil && err != context.Canceled { | ||||
| 			e.lo.Error("error processing envelope", "error", err) | ||||
| 		} | ||||
| 	} | ||||
| @@ -262,17 +269,32 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient. | ||||
| } | ||||
|  | ||||
| // processEnvelope processes a single email envelope. | ||||
| func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client, env *imap.Envelope, seqNum uint32, inboxID int) error { | ||||
| func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client, env *imap.Envelope, seqNum uint32, inboxID int, extractedMessageID string) error { | ||||
| 	if len(env.From) == 0 { | ||||
| 		e.lo.Warn("no sender received for email", "message_id", env.MessageID) | ||||
| 		return nil | ||||
| 	} | ||||
| 	var fromAddress = strings.ToLower(env.From[0].Addr()) | ||||
|  | ||||
| 	// Determine final Message ID - prefer IMAP-parsed, fallback to raw header extraction | ||||
| 	messageID := env.MessageID | ||||
| 	if messageID == "" { | ||||
| 		messageID = extractedMessageID | ||||
| 		if messageID != "" { | ||||
| 			e.lo.Debug("using raw header Message-ID as fallback for malformed ID", "message_id", messageID, "subject", env.Subject, "from", fromAddress) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Drop message if we still don't have a valid Message ID | ||||
| 	if messageID == "" { | ||||
| 		e.lo.Error("dropping message: no valid Message-ID found in IMAP parsing or raw headers", "subject", env.Subject, "from", fromAddress) | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	// Check if the message already exists in the database; if it does, ignore it. | ||||
| 	exists, err := e.messageStore.MessageExists(env.MessageID) | ||||
| 	exists, err := e.messageStore.MessageExists(messageID) | ||||
| 	if err != nil { | ||||
| 		e.lo.Error("error checking if message exists", "message_id", env.MessageID) | ||||
| 		e.lo.Error("error checking if message exists", "message_id", messageID) | ||||
| 		return fmt.Errorf("checking if message exists in DB: %w", err) | ||||
| 	} | ||||
| 	if exists { | ||||
| @@ -291,7 +313,7 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client, | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	e.lo.Debug("processing new incoming message", "message_id", env.MessageID, "subject", env.Subject, "from", fromAddress, "inbox_id", inboxID) | ||||
| 	e.lo.Debug("processing new incoming message", "message_id", messageID, "subject", env.Subject, "from", fromAddress, "inbox_id", inboxID) | ||||
|  | ||||
| 	// Make contact. | ||||
| 	firstName, lastName := getContactName(env.From[0]) | ||||
| @@ -350,7 +372,7 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client, | ||||
| 			InboxID:    inboxID, | ||||
| 			Status:     models.MessageStatusReceived, | ||||
| 			Subject:    env.Subject, | ||||
| 			SourceID:   null.StringFrom(env.MessageID), | ||||
| 			SourceID:   null.StringFrom(messageID), | ||||
| 			Meta:       meta, | ||||
| 		}, | ||||
| 		Contact: contact, | ||||
| @@ -385,7 +407,7 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client, | ||||
| 		} | ||||
|  | ||||
| 		if fullItem, ok := fullFetchItem.(imapclient.FetchItemDataBodySection); ok { | ||||
| 			e.lo.Debug("fetching full message body", "message_id", env.MessageID) | ||||
| 			e.lo.Debug("fetching full message body", "message_id", messageID) | ||||
| 			return e.processFullMessage(fullItem, incomingMsg) | ||||
| 		} | ||||
| 	} | ||||
| @@ -534,3 +556,13 @@ func extractAllHTMLParts(part *enmime.Part) []string { | ||||
|  | ||||
| 	return htmlParts | ||||
| } | ||||
|  | ||||
| // extractMessageIDFromHeaders extracts and cleans the Message-ID from email headers. | ||||
| // This function handles problematic Message IDs by extracting them from raw headers | ||||
| // and cleaning them of angle brackets and whitespace. | ||||
| func extractMessageIDFromHeaders(envelope *enmime.Envelope) string { | ||||
| 	if rawMessageID := envelope.GetHeader(headerMessageID); rawMessageID != "" { | ||||
| 		return strings.TrimSpace(strings.Trim(rawMessageID, "<>")) | ||||
| 	} | ||||
| 	return "" | ||||
| } | ||||
|   | ||||
							
								
								
									
										123
									
								
								internal/inbox/channel/email/imap_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								internal/inbox/channel/email/imap_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,123 @@ | ||||
| package email | ||||
|  | ||||
| import ( | ||||
| 	"strings" | ||||
| 	"testing" | ||||
|  | ||||
| 	"github.com/emersion/go-message/mail" | ||||
| 	"github.com/jhillyerd/enmime" | ||||
| ) | ||||
|  | ||||
|  | ||||
| // TestGoIMAPMessageIDParsing shows how go-imap fails to parse malformed Message-IDs | ||||
| // and demonstrates the fallback solution. | ||||
| // go-imap uses mail.Header.MessageID() which strictly follows RFC 5322 and returns | ||||
| // empty strings for Message-IDs with multiple @ symbols. | ||||
| // | ||||
| // This caused emails to be dropped since we require Message-IDs for deduplication. | ||||
| // References: | ||||
| // - https://community.mailcow.email/d/701-multiple-at-in-message-id/5 | ||||
| // - https://github.com/emersion/go-message/issues/154#issuecomment-1425634946 | ||||
| func TestGoIMAPMessageIDParsing(t *testing.T) { | ||||
| 	testCases := []struct { | ||||
| 		input            string | ||||
| 		expectedIMAP     string | ||||
| 		expectedFallback string | ||||
| 		name             string | ||||
| 	}{ | ||||
| 		{"<normal@example.com>", "normal@example.com", "normal@example.com", "normal message ID"}, | ||||
| 		{"<malformed@@example.com>", "", "malformed@@example.com", "double @ - IMAP fails, fallback works"}, | ||||
| 		{"<001c01d710db$a8137a50$f83a6ef0$@jones.smith@example.com>", "", "001c01d710db$a8137a50$f83a6ef0$@jones.smith@example.com", "mailcow-style - IMAP fails, fallback works"}, | ||||
| 		{"<test@@@domain.com>", "", "test@@@domain.com", "triple @ - IMAP fails, fallback works"}, | ||||
| 		{"  <abc123@example.com>  ", "abc123@example.com", "abc123@example.com", "with whitespace - both handle correctly"}, | ||||
| 		{"abc123@example.com", "", "abc123@example.com", "no angle brackets - IMAP fails, fallback works"}, | ||||
| 		{"", "", "", "empty input"}, | ||||
| 		{"<>", "", "", "empty brackets"}, | ||||
| 		{"<CAFnQjQFhY8z@mail.example.com@gateway.company.com>", "", "CAFnQjQFhY8z@mail.example.com@gateway.company.com", "gateway-style - IMAP fails, fallback works"}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tc := range testCases { | ||||
| 		t.Run(tc.name, func(t *testing.T) { | ||||
| 			// Test go-imap parsing behavior | ||||
| 			var h mail.Header | ||||
| 			h.Set("Message-Id", tc.input) | ||||
| 			imapResult, _ := h.MessageID() | ||||
|  | ||||
| 			if imapResult != tc.expectedIMAP { | ||||
| 				t.Errorf("IMAP parsing of %q: expected %q, got %q", tc.input, tc.expectedIMAP, imapResult) | ||||
| 			} | ||||
|  | ||||
| 			// Test fallback solution | ||||
| 			if tc.input != "" { | ||||
| 				rawEmail := "From: test@example.com\nMessage-ID: " + tc.input + "\n\nBody" | ||||
| 				envelope, err := enmime.ReadEnvelope(strings.NewReader(rawEmail)) | ||||
| 				if err != nil { | ||||
| 					t.Fatal(err) | ||||
| 				} | ||||
|  | ||||
| 				fallbackResult := extractMessageIDFromHeaders(envelope) | ||||
| 				if fallbackResult != tc.expectedFallback { | ||||
| 					t.Errorf("Fallback extraction of %q: expected %q, got %q", tc.input, tc.expectedFallback, fallbackResult) | ||||
| 				} | ||||
|  | ||||
| 				// Critical check: ensure fallback works when IMAP fails | ||||
| 				if imapResult == "" && tc.expectedFallback != "" && fallbackResult == "" { | ||||
| 					t.Errorf("CRITICAL: Both IMAP and fallback failed for %q - would drop email!", tc.input) | ||||
| 				} | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
|  | ||||
| // TestEdgeCasesMessageID tests additional edge cases for Message-ID extraction. | ||||
| func TestEdgeCasesMessageID(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name     string | ||||
| 		email    string | ||||
| 		expected string | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name: "no Message-ID header", | ||||
| 			email: `From: test@example.com | ||||
| To: inbox@test.com | ||||
| Subject: Test | ||||
|  | ||||
| Body`, | ||||
| 			expected: "", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "malformed header syntax", | ||||
| 			email: `From: test@example.com | ||||
| Message-ID: malformed-no-brackets@@domain.com | ||||
| To: inbox@test.com | ||||
|  | ||||
| Body`, | ||||
| 			expected: "malformed-no-brackets@@domain.com", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "multiple Message-ID headers (first wins)", | ||||
| 			email: `From: test@example.com | ||||
| Message-ID: <first@example.com> | ||||
| Message-ID: <second@@example.com> | ||||
| To: inbox@test.com | ||||
|  | ||||
| Body`, | ||||
| 			expected: "first@example.com", | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			envelope, err := enmime.ReadEnvelope(strings.NewReader(tt.email)) | ||||
| 			if err != nil { | ||||
| 				t.Fatal(err) | ||||
| 			} | ||||
|  | ||||
| 			result := extractMessageIDFromHeaders(envelope) | ||||
| 			if result != tt.expected { | ||||
| 				t.Errorf("Expected %q, got %q", tt.expected, result) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
| @@ -1,8 +1,8 @@ | ||||
| -- name: get-active-inboxes | ||||
| SELECT * from inboxes where enabled is TRUE and deleted_at is NULL; | ||||
| SELECT id, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, config, "from" FROM inboxes where enabled is TRUE and deleted_at is NULL; | ||||
|  | ||||
| -- name: get-all-inboxes | ||||
| SELECT id, created_at, updated_at, name, channel, enabled from inboxes where deleted_at is NULL; | ||||
| SELECT id, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, config, "from" FROM inboxes where deleted_at is NULL; | ||||
|  | ||||
| -- name: insert-inbox | ||||
| INSERT INTO inboxes | ||||
| @@ -11,7 +11,7 @@ VALUES($1, $2, $3, $4, $5) | ||||
| RETURNING * | ||||
|  | ||||
| -- name: get-inbox | ||||
| SELECT * from inboxes where id = $1 and deleted_at is NULL; | ||||
| SELECT id, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, config, "from" FROM inboxes where id = $1 and deleted_at is NULL; | ||||
|  | ||||
| -- name: update | ||||
| UPDATE inboxes | ||||
| @@ -20,7 +20,7 @@ where id = $1 and deleted_at is NULL | ||||
| RETURNING *; | ||||
|  | ||||
| -- name: soft-delete | ||||
| UPDATE inboxes set deleted_at = now(), config = '{}' where id = $1 and deleted_at is NULL; | ||||
| UPDATE inboxes set deleted_at = now(), updated_at = now(), config = '{}' where id = $1 and deleted_at is NULL; | ||||
|  | ||||
| -- name: toggle | ||||
| UPDATE inboxes  | ||||
|   | ||||
| @@ -12,11 +12,11 @@ type Macro struct { | ||||
| 	CreatedAt      time.Time       `db:"created_at" json:"created_at"` | ||||
| 	UpdatedAt      time.Time       `db:"updated_at" json:"updated_at"` | ||||
| 	Name           string          `db:"name" json:"name"` | ||||
| 	MessageContent string          `db:"message_content" json:"message_content"` | ||||
| 	VisibleWhen    pq.StringArray  `db:"visible_when" json:"visible_when"` | ||||
| 	Actions        json.RawMessage `db:"actions" json:"actions"` | ||||
| 	Visibility     string          `db:"visibility" json:"visibility"` | ||||
| 	VisibleWhen    pq.StringArray  `db:"visible_when" json:"visible_when"` | ||||
| 	MessageContent string          `db:"message_content" json:"message_content"` | ||||
| 	UserID         *int            `db:"user_id" json:"user_id,string"` | ||||
| 	TeamID         *int            `db:"team_id" json:"team_id,string"` | ||||
| 	UsageCount     int             `db:"usage_count" json:"usage_count"` | ||||
| 	Actions        json.RawMessage `db:"actions" json:"actions"` | ||||
| } | ||||
|   | ||||
| @@ -1,15 +1,15 @@ | ||||
| -- name: get | ||||
| SELECT | ||||
|     id, | ||||
|     name, | ||||
|     message_content, | ||||
|     created_at, | ||||
|     updated_at, | ||||
|     name, | ||||
|     actions, | ||||
|     visibility, | ||||
|     visible_when, | ||||
|     message_content, | ||||
|     user_id, | ||||
|     team_id, | ||||
|     actions, | ||||
|     visible_when, | ||||
|     usage_count | ||||
| FROM | ||||
|     macros | ||||
| @@ -19,15 +19,15 @@ WHERE | ||||
| -- name: get-all | ||||
| SELECT | ||||
|     id, | ||||
|     name, | ||||
|     message_content, | ||||
|     created_at, | ||||
|     updated_at, | ||||
|     name, | ||||
|     actions, | ||||
|     visibility, | ||||
|     visible_when, | ||||
|     message_content, | ||||
|     user_id, | ||||
|     team_id, | ||||
|     actions, | ||||
|     visible_when, | ||||
|     usage_count | ||||
| FROM | ||||
|     macros | ||||
| @@ -67,7 +67,6 @@ WHERE | ||||
| UPDATE | ||||
|     macros | ||||
| SET | ||||
|     usage_count = usage_count + 1, | ||||
|     updated_at = NOW() | ||||
|     usage_count = usage_count + 1 | ||||
| WHERE | ||||
|     id = $1; | ||||
| @@ -214,6 +214,7 @@ func (m *Manager) deleteUnlinkedMessageMedia() error { | ||||
| 			m.lo.Error("error deleting unlinked media", "error", err) | ||||
| 			continue | ||||
| 		} | ||||
| 		// TODO: If it's an image also delete the `thumb_uuid` image. | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|   | ||||
| @@ -1,31 +1,37 @@ | ||||
| package models | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/volatiletech/null/v9" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	// TODO: pick these table names from their respective package/models/models.go | ||||
| 	ModelMessages = "messages" | ||||
| 	ModelUser     = "users" | ||||
|  | ||||
| 	DispositionInline = "inline" | ||||
| ) | ||||
|  | ||||
| // Media represents an uploaded object. | ||||
| // Media represents an uploaded object in DB and storage backend. | ||||
| 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"` | ||||
| 	UpdatedAt   time.Time       `db:"updated_at" json:"updated_at"` | ||||
| 	UUID        string          `db:"uuid" json:"uuid"` | ||||
| 	Store       string          `db:"store" json:"store"` | ||||
| 	Filename    string          `db:"filename" json:"filename"` | ||||
| 	ContentType string          `db:"content_type" json:"content_type"` | ||||
| 	ContentID   string          `db:"content_id" json:"content_id"` | ||||
| 	ModelID     null.Int        `db:"model_id" json:"model_id"` | ||||
| 	Model       null.String     `db:"model_type" json:"model_type"` | ||||
| 	Disposition null.String     `db:"disposition" json:"disposition"` | ||||
| 	Size        int             `db:"size" json:"size"` | ||||
| 	Meta        json.RawMessage `db:"meta" json:"meta"` | ||||
|  | ||||
| 	// Pseudo fields | ||||
| 	URL     string `json:"url"` | ||||
| 	Content []byte `json:"-"` | ||||
| } | ||||
|   | ||||
| @@ -15,7 +15,7 @@ VALUES( | ||||
| RETURNING id; | ||||
|  | ||||
| -- name: get-media | ||||
| SELECT id, created_at, "uuid", store, filename, content_type, model_id, model_type, "size", disposition | ||||
| SELECT id, created_at, updated_at, "uuid", store, filename, content_type, content_id, model_id, model_type, disposition, "size", meta | ||||
| FROM media | ||||
| WHERE  | ||||
|    ($1 > 0 AND id = $1) | ||||
| @@ -23,7 +23,7 @@ WHERE | ||||
|    ($2 != '' AND uuid = $2::uuid) | ||||
|  | ||||
| -- name: get-media-by-uuid | ||||
| SELECT id, created_at, "uuid", store, filename, content_type, model_id, model_type, "size", disposition | ||||
| SELECT id, created_at, updated_at, "uuid", store, filename, content_type, content_id, model_id, model_type, disposition, "size", meta | ||||
| FROM media | ||||
| WHERE uuid = $1; | ||||
|  | ||||
| @@ -38,13 +38,13 @@ SET model_type = $2, | ||||
| WHERE id = $1; | ||||
|  | ||||
| -- name: get-model-media | ||||
| SELECT id, created_at, "uuid", store, filename, content_type, model_id, model_type, "size", disposition | ||||
| SELECT id, created_at, updated_at, "uuid", store, filename, content_type, content_id, model_id, model_type, disposition, "size", meta | ||||
| FROM media | ||||
| WHERE model_type = $1 | ||||
|     AND model_id = $2; | ||||
|  | ||||
| -- name: get-unlinked-message-media | ||||
| SELECT id, created_at, "uuid", store, filename, content_type, model_id, model_type, "size", disposition | ||||
| SELECT id, created_at, updated_at, "uuid", store, filename, content_type, content_id, model_id, model_type, disposition, "size", meta | ||||
| FROM media | ||||
| WHERE model_type = 'messages'  | ||||
|   AND (model_id IS NULL OR model_id = 0)  | ||||
|   | ||||
| @@ -4,6 +4,12 @@ import ( | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| // providerLogos holds known provider logos. | ||||
| var providerLogos = map[string]string{ | ||||
| 	"Google": "/images/google-logo.png", | ||||
| 	"Custom": "", | ||||
| } | ||||
|  | ||||
| // OIDC represents an OpenID Connect configuration. | ||||
| type OIDC struct { | ||||
| 	ID              int       `db:"id" json:"id"` | ||||
| @@ -19,12 +25,6 @@ type OIDC struct { | ||||
| 	ProviderLogoURL string    `db:"-" json:"logo_url"` | ||||
| } | ||||
|  | ||||
| // providerLogos holds known provider logos. | ||||
| var providerLogos = map[string]string{ | ||||
| 	"Google": "/images/google-logo.png", | ||||
| 	"Custom": "", | ||||
| } | ||||
|  | ||||
| // SetProviderLogo provider logo to the OIDC model. | ||||
| func (oidc *OIDC) SetProviderLogo() { | ||||
| 	for provider, logo := range providerLogos { | ||||
|   | ||||
| @@ -38,10 +38,9 @@ type Opts struct { | ||||
|  | ||||
| // queries contains prepared SQL queries. | ||||
| type queries struct { | ||||
| 	GetAllOIDC    *sqlx.Stmt `query:"get-all-oidc"` | ||||
| 	GetAllEnabled *sqlx.Stmt `query:"get-all-enabled"` | ||||
| 	GetOIDC       *sqlx.Stmt `query:"get-oidc"` | ||||
| 	InsertOIDC    *sqlx.Stmt `query:"insert-oidc"` | ||||
| 	GetAllOIDC *sqlx.Stmt `query:"get-all-oidc"` | ||||
| 	GetOIDC    *sqlx.Stmt `query:"get-oidc"` | ||||
| 	InsertOIDC *sqlx.Stmt `query:"insert-oidc"` | ||||
| 	UpdateOIDC    *sqlx.Stmt `query:"update-oidc"` | ||||
| 	DeleteOIDC    *sqlx.Stmt `query:"delete-oidc"` | ||||
| } | ||||
| @@ -111,19 +110,6 @@ func (o *Manager) GetAll() ([]models.OIDC, error) { | ||||
| 	return oidc, nil | ||||
| } | ||||
|  | ||||
| // GetAllEnabled retrieves all enabled oidc. | ||||
| func (o *Manager) GetAllEnabled() ([]models.OIDC, error) { | ||||
| 	var oidc = make([]models.OIDC, 0) | ||||
| 	if err := o.q.GetAllEnabled.Select(&oidc); err != nil { | ||||
| 		o.lo.Error("error fetching oidc", "error", err) | ||||
| 		return oidc, envelope.NewError(envelope.GeneralError, o.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.oidcProvider}"), nil) | ||||
| 	} | ||||
| 	for i := range oidc { | ||||
| 		oidc[i].SetProviderLogo() | ||||
| 	} | ||||
| 	return oidc, nil | ||||
| } | ||||
|  | ||||
| // Create adds a new oidc. | ||||
| func (o *Manager) Create(oidc models.OIDC) (models.OIDC, error) { | ||||
| 	var createdOIDC models.OIDC | ||||
|   | ||||
| @@ -1,11 +1,8 @@ | ||||
| -- name: get-all-oidc | ||||
| SELECT id, created_at, updated_at, name, provider, client_id, client_secret, provider_url, enabled FROM oidc order by updated_at desc; | ||||
|  | ||||
| -- name: get-all-enabled | ||||
| SELECT id, name, enabled, provider, client_id, updated_at FROM oidc WHERE enabled = true order by updated_at desc; | ||||
| SELECT id, created_at, updated_at, name, provider_url, client_id, client_secret, enabled, provider FROM oidc order by updated_at desc; | ||||
|  | ||||
| -- name: get-oidc | ||||
| SELECT * FROM oidc WHERE id = $1; | ||||
| SELECT id, created_at, updated_at, name, provider_url, client_id, client_secret, enabled, provider FROM oidc WHERE id = $1; | ||||
|  | ||||
| -- name: insert-oidc | ||||
| INSERT INTO oidc (name, provider, provider_url, client_id, client_secret)  | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
| SELECT id, created_at, updated_at, name, description, permissions FROM roles; | ||||
|  | ||||
| -- name: get-role | ||||
| SELECT * FROM roles where id = $1; | ||||
| SELECT id, created_at, updated_at, name, description, permissions FROM roles where id = $1; | ||||
|  | ||||
| -- name: delete-role | ||||
| DELETE FROM roles where id = $1; | ||||
|   | ||||
| @@ -153,7 +153,7 @@ func (u *Manager) filterValidPermissions(permissions []string) ([]string, error) | ||||
| 		if amodels.PermissionExists(perm) { | ||||
| 			validPermissions = append(validPermissions, perm) | ||||
| 		} else { | ||||
| 			u.lo.Warn("ignoring unknown permission", "permission", perm) | ||||
| 			u.lo.Warn("skipping unknown permission for role", "permission", perm) | ||||
| 		} | ||||
| 	} | ||||
| 	return validPermissions, nil | ||||
|   | ||||
| @@ -2,14 +2,14 @@ package models | ||||
|  | ||||
| import "time" | ||||
|  | ||||
| type Conversation struct { | ||||
| type ConversationResult struct { | ||||
| 	CreatedAt       time.Time `db:"created_at" json:"created_at"` | ||||
| 	UUID            string    `db:"uuid" json:"uuid"` | ||||
| 	ReferenceNumber string    `db:"reference_number" json:"reference_number"` | ||||
| 	Subject         string    `db:"subject" json:"subject"` | ||||
| } | ||||
|  | ||||
| type Message struct { | ||||
| type MessageResult struct { | ||||
| 	CreatedAt                   time.Time `db:"created_at" json:"created_at"` | ||||
| 	TextContent                 string    `db:"text_content" json:"text_content"` | ||||
| 	ConversationCreatedAt       time.Time `db:"conversation_created_at" json:"conversation_created_at"` | ||||
| @@ -17,7 +17,7 @@ type Message struct { | ||||
| 	ConversationReferenceNumber string    `db:"conversation_reference_number" json:"conversation_reference_number"` | ||||
| } | ||||
|  | ||||
| type Contact struct { | ||||
| type ContactResult struct { | ||||
| 	CreatedAt time.Time `db:"created_at" json:"created_at"` | ||||
| 	FirstName string    `db:"first_name" json:"first_name"` | ||||
| 	LastName  string    `db:"last_name" json:"last_name"` | ||||
|   | ||||
| @@ -13,73 +13,73 @@ import ( | ||||
| ) | ||||
|  | ||||
| var ( | ||||
|    //go:embed queries.sql | ||||
|    efs embed.FS | ||||
| 	//go:embed queries.sql | ||||
| 	efs embed.FS | ||||
| ) | ||||
|  | ||||
| // Manager is the search manager | ||||
| type Manager struct { | ||||
|    q    queries | ||||
|    lo   *logf.Logger | ||||
|    i18n *i18n.I18n | ||||
| 	q    queries | ||||
| 	lo   *logf.Logger | ||||
| 	i18n *i18n.I18n | ||||
| } | ||||
|  | ||||
| // Opts contains the options for creating a new search manager | ||||
| type Opts struct { | ||||
|    DB   *sqlx.DB | ||||
|    Lo   *logf.Logger | ||||
|    I18n *i18n.I18n | ||||
| 	DB   *sqlx.DB | ||||
| 	Lo   *logf.Logger | ||||
| 	I18n *i18n.I18n | ||||
| } | ||||
|  | ||||
| // queries contains all the prepared queries | ||||
| type queries struct { | ||||
|    SearchConversationsByRefNum       *sqlx.Stmt `query:"search-conversations-by-reference-number"` | ||||
|    SearchConversationsByContactEmail *sqlx.Stmt `query:"search-conversations-by-contact-email"` | ||||
|    SearchMessages                    *sqlx.Stmt `query:"search-messages"` | ||||
|    SearchContacts                    *sqlx.Stmt `query:"search-contacts"` | ||||
| 	SearchConversationsByRefNum       *sqlx.Stmt `query:"search-conversations-by-reference-number"` | ||||
| 	SearchConversationsByContactEmail *sqlx.Stmt `query:"search-conversations-by-contact-email"` | ||||
| 	SearchMessages                    *sqlx.Stmt `query:"search-messages"` | ||||
| 	SearchContacts                    *sqlx.Stmt `query:"search-contacts"` | ||||
| } | ||||
|  | ||||
| // New creates a new search manager | ||||
| func New(opts Opts) (*Manager, error) { | ||||
|    var q queries | ||||
|    if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil { | ||||
|    	return nil, err | ||||
|    } | ||||
|    return &Manager{q: q, lo: opts.Lo, i18n: opts.I18n}, nil | ||||
| 	var q queries | ||||
| 	if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return &Manager{q: q, lo: opts.Lo, i18n: opts.I18n}, nil | ||||
| } | ||||
|  | ||||
| // Conversations searches conversations based on the query | ||||
| func (s *Manager) Conversations(query string) ([]models.Conversation, error) { | ||||
|    var refNumResults = make([]models.Conversation, 0) | ||||
|    if err := s.q.SearchConversationsByRefNum.Select(&refNumResults, query); err != nil { | ||||
|    	s.lo.Error("error searching conversations", "error", err) | ||||
|    	return nil, envelope.NewError(envelope.GeneralError, s.i18n.Ts("globals.messages.errorSearching", "name", s.i18n.Ts("globals.terms.conversation")), nil) | ||||
|    } | ||||
| func (s *Manager) Conversations(query string) ([]models.ConversationResult, error) { | ||||
| 	var refNumResults = make([]models.ConversationResult, 0) | ||||
| 	if err := s.q.SearchConversationsByRefNum.Select(&refNumResults, query); err != nil { | ||||
| 		s.lo.Error("error searching conversations", "error", err) | ||||
| 		return nil, envelope.NewError(envelope.GeneralError, s.i18n.Ts("globals.messages.errorSearching", "name", s.i18n.Ts("globals.terms.conversation")), nil) | ||||
| 	} | ||||
|  | ||||
|    var emailResults = make([]models.Conversation, 0) | ||||
|    if err := s.q.SearchConversationsByContactEmail.Select(&emailResults, query); err != nil { | ||||
|    	s.lo.Error("error searching conversations", "error", err) | ||||
|    	return nil, envelope.NewError(envelope.GeneralError, s.i18n.Ts("globals.messages.errorSearching", "name", s.i18n.Ts("globals.terms.conversation")), nil) | ||||
|    } | ||||
|    return append(refNumResults, emailResults...), nil | ||||
| 	var emailResults = make([]models.ConversationResult, 0) | ||||
| 	if err := s.q.SearchConversationsByContactEmail.Select(&emailResults, query); err != nil { | ||||
| 		s.lo.Error("error searching conversations", "error", err) | ||||
| 		return nil, envelope.NewError(envelope.GeneralError, s.i18n.Ts("globals.messages.errorSearching", "name", s.i18n.Ts("globals.terms.conversation")), nil) | ||||
| 	} | ||||
| 	return append(refNumResults, emailResults...), nil | ||||
| } | ||||
|  | ||||
| // Messages searches messages based on the query | ||||
| func (s *Manager) Messages(query string) ([]models.Message, error) { | ||||
|    var results = make([]models.Message, 0) | ||||
|    if err := s.q.SearchMessages.Select(&results, query); err != nil { | ||||
|    	s.lo.Error("error searching messages", "error", err) | ||||
|    	return nil, envelope.NewError(envelope.GeneralError, s.i18n.Ts("globals.messages.errorSearching", "name", s.i18n.Ts("globals.terms.message")), nil) | ||||
|    } | ||||
|    return results, nil | ||||
| func (s *Manager) Messages(query string) ([]models.MessageResult, error) { | ||||
| 	var results = make([]models.MessageResult, 0) | ||||
| 	if err := s.q.SearchMessages.Select(&results, query); err != nil { | ||||
| 		s.lo.Error("error searching messages", "error", err) | ||||
| 		return nil, envelope.NewError(envelope.GeneralError, s.i18n.Ts("globals.messages.errorSearching", "name", s.i18n.Ts("globals.terms.message")), nil) | ||||
| 	} | ||||
| 	return results, nil | ||||
| } | ||||
|  | ||||
| // Contacts searches contacts based on the query | ||||
| func (s *Manager) Contacts(query string) ([]models.Contact, error) { | ||||
|    var results = make([]models.Contact, 0) | ||||
|    if err := s.q.SearchContacts.Select(&results, query); err != nil { | ||||
|    	s.lo.Error("error searching contacts", "error", err) | ||||
|    	return nil, envelope.NewError(envelope.GeneralError, s.i18n.Ts("globals.messages.errorSearching", "name", s.i18n.Ts("globals.terms.contact")), nil) | ||||
|    } | ||||
|    return results, nil | ||||
| } | ||||
| func (s *Manager) Contacts(query string) ([]models.ContactResult, error) { | ||||
| 	var results = make([]models.ContactResult, 0) | ||||
| 	if err := s.q.SearchContacts.Select(&results, query); err != nil { | ||||
| 		s.lo.Error("error searching contacts", "error", err) | ||||
| 		return nil, envelope.NewError(envelope.GeneralError, s.i18n.Ts("globals.messages.errorSearching", "name", s.i18n.Ts("globals.terms.contact")), nil) | ||||
| 	} | ||||
| 	return results, nil | ||||
| } | ||||
|   | ||||
| @@ -11,7 +11,6 @@ import ( | ||||
| 	"github.com/abhinavxd/libredesk/internal/setting/models" | ||||
| 	"github.com/jmoiron/sqlx" | ||||
| 	"github.com/jmoiron/sqlx/types" | ||||
| 	"github.com/knadh/go-i18n" | ||||
| 	"github.com/zerodha/logf" | ||||
| ) | ||||
|  | ||||
| @@ -22,16 +21,14 @@ var ( | ||||
|  | ||||
| // Manager handles setting-related operations. | ||||
| type Manager struct { | ||||
| 	q    queries | ||||
| 	lo   *logf.Logger | ||||
| 	i18n *i18n.I18n | ||||
| 	q  queries | ||||
| 	lo *logf.Logger | ||||
| } | ||||
|  | ||||
| // Opts contains options for initializing the Manager. | ||||
| type Opts struct { | ||||
| 	DB   *sqlx.DB | ||||
| 	Lo   *logf.Logger | ||||
| 	I18n *i18n.I18n | ||||
| 	DB *sqlx.DB | ||||
| 	Lo *logf.Logger | ||||
| } | ||||
|  | ||||
| // queries contains prepared SQL queries. | ||||
| @@ -51,9 +48,8 @@ func New(opts Opts) (*Manager, error) { | ||||
| 	} | ||||
|  | ||||
| 	return &Manager{ | ||||
| 		q:    q, | ||||
| 		lo:   opts.Lo, | ||||
| 		i18n: opts.I18n, | ||||
| 		q:  q, | ||||
| 		lo: opts.Lo, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| @@ -85,15 +81,15 @@ func (m *Manager) GetAllJSON() (types.JSONText, error) { | ||||
| 	return b, nil | ||||
| } | ||||
|  | ||||
| // Update updates settings. | ||||
| func (m *Manager) Update(s interface{}) error { | ||||
| // Update updates settings with the passed values. | ||||
| func (m *Manager) Update(s any) error { | ||||
| 	// Marshal settings. | ||||
| 	b, err := json.Marshal(s) | ||||
| 	if err != nil { | ||||
| 		m.lo.Error("error marshalling settings", "error", err) | ||||
| 		return envelope.NewError( | ||||
| 			envelope.GeneralError, | ||||
| 			m.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.setting}"), | ||||
| 			"Error marshalling settings", | ||||
| 			nil, | ||||
| 		) | ||||
| 	} | ||||
| @@ -102,21 +98,21 @@ func (m *Manager) Update(s interface{}) error { | ||||
| 		m.lo.Error("error updating settings", "error", err) | ||||
| 		return envelope.NewError( | ||||
| 			envelope.GeneralError, | ||||
| 			m.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.setting}"), | ||||
| 			"Error updating settings", | ||||
| 			nil, | ||||
| 		) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // GetByPrefix retrieves settings by prefix as JSON. | ||||
| // GetByPrefix retrieves all settings start with the given prefix. | ||||
| func (m *Manager) GetByPrefix(prefix string) (types.JSONText, error) { | ||||
| 	var b types.JSONText | ||||
| 	if err := m.q.GetByPrefix.Get(&b, prefix+"%"); err != nil { | ||||
| 		m.lo.Error("error fetching settings", "prefix", prefix, "error", err) | ||||
| 		return b, envelope.NewError( | ||||
| 			envelope.GeneralError, | ||||
| 			m.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.setting}"), | ||||
| 			"Error fetching settings", | ||||
| 			nil, | ||||
| 		) | ||||
| 	} | ||||
| @@ -130,7 +126,7 @@ func (m *Manager) Get(key string) (types.JSONText, error) { | ||||
| 		m.lo.Error("error fetching setting", "key", key, "error", err) | ||||
| 		return b, envelope.NewError( | ||||
| 			envelope.GeneralError, | ||||
| 			m.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.setting}"), | ||||
| 			"Error fetching settings", | ||||
| 			nil, | ||||
| 		) | ||||
| 	} | ||||
| @@ -144,7 +140,7 @@ func (m *Manager) GetAppRootURL() (string, error) { | ||||
| 		m.lo.Error("error fetching root URL", "error", err) | ||||
| 		return "", envelope.NewError( | ||||
| 			envelope.GeneralError, | ||||
| 			m.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.appRootURL}"), | ||||
| 			"Error fetching root URL", | ||||
| 			nil, | ||||
| 		) | ||||
| 	} | ||||
|   | ||||
| @@ -144,13 +144,6 @@ func GenerateEmailMessageID(messageID string, fromAddress string) (string, error | ||||
| 	), nil | ||||
| } | ||||
|  | ||||
| // ReverseSlice reverses a slice of strings in place. | ||||
| func ReverseSlice(source []string) { | ||||
| 	for i, j := 0, len(source)-1; i < j; i, j = i+1, j-1 { | ||||
| 		source[i], source[j] = source[j], source[i] | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // RemoveItemByValue removes all instances of a value from a slice of strings. | ||||
| func RemoveItemByValue(slice []string, value string) []string { | ||||
| 	result := []string{} | ||||
|   | ||||
| @@ -5,46 +5,6 @@ import ( | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| func TestReverseSlice(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name     string | ||||
| 		input    []string | ||||
| 		expected []string | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:     "empty slice", | ||||
| 			input:    []string{}, | ||||
| 			expected: []string{}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:     "single element", | ||||
| 			input:    []string{"a"}, | ||||
| 			expected: []string{"a"}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:     "multiple elements", | ||||
| 			input:    []string{"a", "b", "c"}, | ||||
| 			expected: []string{"c", "b", "a"}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			input := make([]string, len(tt.input)) | ||||
| 			copy(input, tt.input) | ||||
| 			ReverseSlice(input) | ||||
| 			if len(input) != len(tt.expected) { | ||||
| 				t.Errorf("got len %d, want %d", len(input), len(tt.expected)) | ||||
| 			} | ||||
| 			for i := range input { | ||||
| 				if input[i] != tt.expected[i] { | ||||
| 					t.Errorf("at index %d got %s, want %s", i, input[i], tt.expected[i]) | ||||
| 				} | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestRemoveItemByValue(t *testing.T) { | ||||
| 	tests := []struct { | ||||
| 		name     string | ||||
|   | ||||
| @@ -15,17 +15,37 @@ type Team struct { | ||||
| 	UpdatedAt                    time.Time   `db:"updated_at" json:"updated_at"` | ||||
| 	Emoji                        null.String `db:"emoji" json:"emoji"` | ||||
| 	Name                         string      `db:"name" json:"name"` | ||||
| 	ConversationAssignmentType   string      `db:"conversation_assignment_type" json:"conversation_assignment_type,omitempty"` | ||||
| 	Timezone                     string      `db:"timezone" json:"timezone,omitempty"` | ||||
| 	BusinessHoursID              null.Int    `db:"business_hours_id" json:"business_hours_id,omitempty"` | ||||
| 	SLAPolicyID                  null.Int    `db:"sla_policy_id" json:"sla_policy_id,omitempty"` | ||||
| 	ConversationAssignmentType   string      `db:"conversation_assignment_type" json:"conversation_assignment_type"` | ||||
| 	Timezone                     string      `db:"timezone" json:"timezone"` | ||||
| 	BusinessHoursID              null.Int    `db:"business_hours_id" json:"business_hours_id"` | ||||
| 	SLAPolicyID                  null.Int    `db:"sla_policy_id" json:"sla_policy_id"` | ||||
| 	MaxAutoAssignedConversations int         `db:"max_auto_assigned_conversations" json:"max_auto_assigned_conversations"` | ||||
| } | ||||
|  | ||||
| type Teams []Team | ||||
| type TeamCompact struct { | ||||
| 	ID    int         `db:"id" json:"id"` | ||||
| 	Name  string      `db:"name" json:"name"` | ||||
| 	Emoji null.String `db:"emoji" json:"emoji"` | ||||
| } | ||||
|  | ||||
| type TeamMember struct { | ||||
| 	ID                 int    `db:"id" json:"id"` | ||||
| 	AvailabilityStatus string `db:"availability_status" json:"availability_status"` | ||||
| 	TeamID             int    `db:"team_id" json:"team_id"` | ||||
| } | ||||
|  | ||||
| type TeamsCompact []TeamCompact | ||||
|  | ||||
| func (t TeamsCompact) IDs() []int { | ||||
| 	ids := make([]int, len(t)) | ||||
| 	for i, team := range t { | ||||
| 		ids[i] = team.ID | ||||
| 	} | ||||
| 	return ids | ||||
| } | ||||
|  | ||||
| // Scan implements the sql.Scanner interface for Teams | ||||
| func (t *Teams) Scan(src interface{}) error { | ||||
| func (t *TeamsCompact) Scan(src interface{}) error { | ||||
| 	if src == nil { | ||||
| 		*t = nil | ||||
| 		return nil | ||||
| @@ -40,24 +60,6 @@ func (t *Teams) Scan(src interface{}) error { | ||||
| } | ||||
|  | ||||
| // Value implements the driver.Valuer interface for Teams | ||||
| func (t Teams) Value() (driver.Value, error) { | ||||
| func (t TeamsCompact) Value() (driver.Value, error) { | ||||
| 	return json.Marshal(t) | ||||
| } | ||||
|  | ||||
| // Names returns the names of the teams in Teams slice. | ||||
| func (t Teams) Names() []string { | ||||
| 	names := make([]string, len(t)) | ||||
| 	for i, team := range t { | ||||
| 		names[i] = team.Name | ||||
| 	} | ||||
| 	return names | ||||
| } | ||||
|  | ||||
| // IDs returns a slice of all team IDs in the Teams slice. | ||||
| func (t Teams) IDs() []int { | ||||
| 	ids := make([]int, len(t)) | ||||
| 	for i, team := range t { | ||||
| 		ids[i] = team.ID | ||||
| 	} | ||||
| 	return ids | ||||
| } | ||||
|   | ||||
| @@ -1,14 +1,14 @@ | ||||
| -- name: get-teams | ||||
| SELECT id, emoji, created_at, updated_at, name, conversation_assignment_type, timezone, max_auto_assigned_conversations from teams order by updated_at desc; | ||||
| SELECT id, created_at, updated_at, name, emoji, conversation_assignment_type, max_auto_assigned_conversations, business_hours_id, sla_policy_id, timezone from teams order by updated_at desc; | ||||
|  | ||||
| -- name: get-teams-compact | ||||
| SELECT id, name, emoji from teams order by name; | ||||
|  | ||||
| -- name: get-user-teams | ||||
| SELECT id, emoji, created_at, updated_at, name, conversation_assignment_type, timezone, max_auto_assigned_conversations from teams WHERE id IN (SELECT team_id FROM team_members WHERE user_id = $1) order by updated_at desc; | ||||
| SELECT id, created_at, updated_at, name, emoji, conversation_assignment_type, max_auto_assigned_conversations, business_hours_id, sla_policy_id, timezone from teams WHERE id IN (SELECT team_id FROM team_members WHERE user_id = $1) order by updated_at desc; | ||||
|  | ||||
| -- name: get-team | ||||
| SELECT id, emoji, name, conversation_assignment_type, timezone, business_hours_id, sla_policy_id, max_auto_assigned_conversations from teams where id = $1; | ||||
| SELECT id, created_at, updated_at, name, emoji, conversation_assignment_type, max_auto_assigned_conversations, business_hours_id, sla_policy_id, timezone from teams where id = $1; | ||||
|  | ||||
| -- name: get-team-members | ||||
| SELECT u.id, t.id as team_id, u.availability_status | ||||
|   | ||||
| @@ -10,7 +10,6 @@ import ( | ||||
| 	"github.com/abhinavxd/libredesk/internal/dbutil" | ||||
| 	"github.com/abhinavxd/libredesk/internal/envelope" | ||||
| 	"github.com/abhinavxd/libredesk/internal/team/models" | ||||
| 	umodels "github.com/abhinavxd/libredesk/internal/user/models" | ||||
| 	"github.com/jmoiron/sqlx" | ||||
| 	"github.com/knadh/go-i18n" | ||||
| 	"github.com/lib/pq" | ||||
| @@ -78,8 +77,8 @@ func (u *Manager) GetAll() ([]models.Team, error) { | ||||
| } | ||||
|  | ||||
| // GetAllCompact retrieves all teams with limited fields. | ||||
| func (u *Manager) GetAllCompact() ([]models.Team, error) { | ||||
| 	var teams = make([]models.Team, 0) | ||||
| func (u *Manager) GetAllCompact() ([]models.TeamCompact, error) { | ||||
| 	var teams = make([]models.TeamCompact, 0) | ||||
| 	if err := u.q.GetTeamsCompact.Select(&teams); err != nil { | ||||
| 		if errors.Is(err, sql.ErrNoRows) { | ||||
| 			return teams, nil | ||||
| @@ -169,14 +168,14 @@ func (u *Manager) UserBelongsToTeam(teamID, userID int) (bool, error) { | ||||
| } | ||||
|  | ||||
| // GetMembers retrieves members of a team. | ||||
| func (u *Manager) GetMembers(id int) ([]umodels.User, error) { | ||||
| 	var users []umodels.User | ||||
| 	if err := u.q.GetTeamMembers.Select(&users, id); err != nil { | ||||
| func (u *Manager) GetMembers(id int) ([]models.TeamMember, error) { | ||||
| 	var members = make([]models.TeamMember, 0) | ||||
| 	if err := u.q.GetTeamMembers.Select(&members, id); err != nil { | ||||
| 		if errors.Is(err, sql.ErrNoRows) { | ||||
| 			return users, nil | ||||
| 			return members, nil | ||||
| 		} | ||||
| 		u.lo.Error("error fetching team members", "team_id", id, "error", err) | ||||
| 		return users, fmt.Errorf("fetching team members: %w", err) | ||||
| 		return members, fmt.Errorf("fetching team members: %w", err) | ||||
| 	} | ||||
| 	return users, nil | ||||
| 	return members, nil | ||||
| } | ||||
|   | ||||
| @@ -19,19 +19,19 @@ WITH u AS ( | ||||
| SELECT * FROM u LIMIT 1; | ||||
|  | ||||
| -- name: get-default | ||||
| SELECT id, type, name, body, subject FROM templates WHERE is_default is TRUE; | ||||
| SELECT id, created_at, updated_at, type, body, is_default, name, subject, is_builtin FROM templates WHERE is_default is TRUE; | ||||
|  | ||||
| -- name: get-all | ||||
| SELECT id, created_at, updated_at, type, name, is_default, is_builtin FROM templates WHERE type = $1 ORDER BY updated_at DESC; | ||||
| SELECT id, created_at, updated_at, type, body, is_default, name, subject, is_builtin FROM templates WHERE type = $1 ORDER BY updated_at DESC; | ||||
|  | ||||
| -- name: get-template | ||||
| SELECT id, type, name, body, subject, is_default, type FROM templates WHERE id = $1; | ||||
| SELECT id, created_at, updated_at, type, body, is_default, name, subject, is_builtin FROM templates WHERE id = $1; | ||||
|  | ||||
| -- name: delete | ||||
| DELETE FROM templates WHERE id = $1; | ||||
|  | ||||
| -- name: get-by-name | ||||
| SELECT id, type, name, body, subject, is_default, type FROM templates WHERE name = $1; | ||||
| SELECT id, created_at, updated_at, type, body, is_default, name, subject, is_builtin FROM templates WHERE name = $1; | ||||
|  | ||||
| -- name: is-builtin | ||||
| SELECT EXISTS(SELECT 1 FROM templates WHERE id = $1 AND is_builtin is TRUE); | ||||
| @@ -2,8 +2,6 @@ package user | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"database/sql" | ||||
| 	"errors" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| @@ -69,13 +67,10 @@ func (u *Manager) InvalidateAgentCache(id int) { | ||||
| 	delete(u.agentCache, id) | ||||
| } | ||||
|  | ||||
| // GetAgentsCompact returns a compact list of users with limited fields. | ||||
| func (u *Manager) GetAgentsCompact() ([]models.User, error) { | ||||
| 	var users = make([]models.User, 0) | ||||
| 	if err := u.q.GetAgentsCompact.Select(&users); err != nil { | ||||
| 		if errors.Is(err, sql.ErrNoRows) { | ||||
| 			return users, nil | ||||
| 		} | ||||
| // GetAgentsCompact returns a compact list of agents with limited fields. | ||||
| func (u *Manager) GetAgentsCompact() ([]models.UserCompact, error) { | ||||
| 	var users = make([]models.UserCompact, 0) | ||||
| 	if err := u.db.Select(&users, u.q.GetUsersCompact, pq.Array([]string{models.UserTypeAgent})); err != nil { | ||||
| 		u.lo.Error("error fetching users from db", "error", err) | ||||
| 		return users, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", u.i18n.P("globals.terms.user")), nil) | ||||
| 	} | ||||
| @@ -83,36 +78,39 @@ func (u *Manager) GetAgentsCompact() ([]models.User, error) { | ||||
| } | ||||
|  | ||||
| // CreateAgent creates a new agent user. | ||||
| func (u *Manager) CreateAgent(user *models.User) (error) { | ||||
| func (u *Manager) CreateAgent(firstName, lastName, email string, roles []string) (models.User, error) { | ||||
| 	password, err := u.generatePassword() | ||||
| 	if err != nil { | ||||
| 		u.lo.Error("error generating password", "error", err) | ||||
| 		return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}"), nil) | ||||
| 		return models.User{}, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}"), nil) | ||||
| 	} | ||||
| 	user.Email = null.NewString(strings.TrimSpace(strings.ToLower(user.Email.String)), user.Email.Valid) | ||||
| 	if err := u.q.InsertAgent.QueryRow(user.Email, user.FirstName, user.LastName, password, user.AvatarURL, pq.Array(user.Roles)).Scan(&user.ID); err != nil { | ||||
|  | ||||
| 	var id = 0 | ||||
| 	avatarURL := null.String{} | ||||
| 	email = strings.TrimSpace(strings.ToLower(email)) | ||||
| 	if err := u.q.InsertAgent.QueryRow(email, firstName, lastName, password, avatarURL, pq.Array(roles)).Scan(&id); err != nil { | ||||
| 		if dbutil.IsUniqueViolationError(err) { | ||||
| 			return envelope.NewError(envelope.GeneralError, u.i18n.T("user.sameEmailAlreadyExists"), nil) | ||||
| 			return models.User{}, envelope.NewError(envelope.GeneralError, u.i18n.T("user.sameEmailAlreadyExists"), nil) | ||||
| 		} | ||||
| 		u.lo.Error("error creating user", "error", err) | ||||
| 		return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}"), nil) | ||||
| 		return models.User{}, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}"), nil) | ||||
| 	} | ||||
| 	return nil | ||||
| 	return u.Get(id, "", models.UserTypeAgent) | ||||
| } | ||||
|  | ||||
| // UpdateAgent updates an agent in the database, including their password if provided. | ||||
| func (u *Manager) UpdateAgent(id int, user models.User) error { | ||||
| // UpdateAgent updates an agent with individual field parameters | ||||
| func (u *Manager) UpdateAgent(id int, firstName, lastName, email string, roles []string, enabled bool, availabilityStatus, newPassword string) error { | ||||
| 	var ( | ||||
| 		hashedPassword any | ||||
| 		err            error | ||||
| 	) | ||||
|  | ||||
| 	// Set password? | ||||
| 	if user.NewPassword != "" { | ||||
| 		if !IsStrongPassword(user.NewPassword) { | ||||
| 	if newPassword != "" { | ||||
| 		if !IsStrongPassword(newPassword) { | ||||
| 			return envelope.NewError(envelope.InputError, PasswordHint, nil) | ||||
| 		} | ||||
| 		hashedPassword, err = bcrypt.GenerateFromPassword([]byte(user.NewPassword), bcrypt.DefaultCost) | ||||
| 		hashedPassword, err = bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) | ||||
| 		if err != nil { | ||||
| 			u.lo.Error("error generating bcrypt password", "error", err) | ||||
| 			return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil) | ||||
| @@ -121,7 +119,10 @@ func (u *Manager) UpdateAgent(id int, user models.User) error { | ||||
| 	} | ||||
|  | ||||
| 	// Update user in the database and clear cache. | ||||
| 	if _, err := u.q.UpdateAgent.Exec(id, user.FirstName, user.LastName, user.Email, pq.Array(user.Roles), user.AvatarURL, hashedPassword, user.Enabled, user.AvailabilityStatus); err != nil { | ||||
| 	if _, err := u.q.UpdateAgent.Exec(id, firstName, lastName, email, pq.Array(roles), null.String{}, hashedPassword, enabled, availabilityStatus); err != nil { | ||||
| 		if dbutil.IsUniqueViolationError(err) { | ||||
| 			return envelope.NewError(envelope.GeneralError, u.i18n.T("user.sameEmailAlreadyExists"), nil) | ||||
| 		} | ||||
| 		u.lo.Error("error updating user", "error", err) | ||||
| 		return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil) | ||||
| 	} | ||||
| @@ -159,7 +160,7 @@ func (u *Manager) markInactiveAgentsOffline() { | ||||
| } | ||||
|  | ||||
| // GetAllAgents returns a list of all agents. | ||||
| func (u *Manager) GetAgents() ([]models.User, error) { | ||||
| func (u *Manager) GetAgents() ([]models.UserCompact, error) { | ||||
| 	// Some dirty hack. | ||||
| 	return u.GetAllUsers(1, 999999999, models.UserTypeAgent, "desc", "users.updated_at", "") | ||||
| } | ||||
|   | ||||
| @@ -42,7 +42,7 @@ func (u *Manager) GetContact(id int, email string) (models.User, error) { | ||||
| } | ||||
|  | ||||
| // GetAllContacts returns a list of all contacts. | ||||
| func (u *Manager) GetContacts(page, pageSize int, order, orderBy string, filtersJSON string) ([]models.User, error) { | ||||
| func (u *Manager) GetContacts(page, pageSize int, order, orderBy string, filtersJSON string) ([]models.UserCompact, error) { | ||||
| 	if pageSize > maxListPageSize { | ||||
| 		return nil, envelope.NewError(envelope.InputError, u.i18n.Ts("globals.messages.pageTooLarge", "max", fmt.Sprintf("%d", maxListPageSize)), nil) | ||||
| 	} | ||||
|   | ||||
| @@ -30,40 +30,51 @@ const ( | ||||
| 	AwayAndReassigning = "away_and_reassigning" | ||||
| ) | ||||
|  | ||||
| type UserCompact struct { | ||||
| 	ID        int         `db:"id" json:"id"` | ||||
| 	Type      string      `db:"type" json:"type"` | ||||
| 	FirstName string      `db:"first_name" json:"first_name"` | ||||
| 	LastName  string      `db:"last_name" json:"last_name"` | ||||
| 	Email     null.String `db:"email" json:"email"` | ||||
| 	Enabled   bool        `db:"enabled" json:"enabled"` | ||||
| 	AvatarURL null.String `db:"avatar_url" json:"avatar_url"` | ||||
| 	CreatedAt time.Time   `db:"created_at" json:"created_at"` | ||||
| 	UpdatedAt time.Time   `db:"updated_at" json:"updated_at"` | ||||
|  | ||||
| 	Total int `db:"total" json:"total"` | ||||
| } | ||||
|  | ||||
| type User struct { | ||||
| 	ID                     int             `db:"id" json:"id"` | ||||
| 	CreatedAt              time.Time       `db:"created_at" json:"created_at"` | ||||
| 	UpdatedAt              time.Time       `db:"updated_at" json:"updated_at"` | ||||
| 	FirstName              string          `db:"first_name" json:"first_name"` | ||||
| 	LastName               string          `db:"last_name" json:"last_name"` | ||||
| 	Email                  null.String     `db:"email" json:"email"` | ||||
| 	Type                   string          `db:"type" json:"type"` | ||||
| 	AvailabilityStatus     string          `db:"availability_status" json:"availability_status"` | ||||
| 	PhoneNumberCallingCode null.String     `db:"phone_number_calling_code" json:"phone_number_calling_code"` | ||||
| 	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:"-"` | ||||
| 	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"` | ||||
| 	Permissions            pq.StringArray  `db:"permissions" json:"permissions"` | ||||
| 	Meta                   pq.StringArray  `db:"meta" json:"meta"` | ||||
| 	CustomAttributes       json.RawMessage `db:"custom_attributes" json:"custom_attributes"` | ||||
| 	Teams                  tmodels.Teams   `db:"teams" json:"teams"` | ||||
| 	ContactChannelID       int             `db:"contact_channel_id" json:"contact_channel_id,omitempty"` | ||||
| 	NewPassword            string          `db:"-" json:"new_password,omitempty"` | ||||
| 	SendWelcomeEmail       bool            `db:"-" json:"send_welcome_email,omitempty"` | ||||
| 	InboxID                int             `json:"-"` | ||||
| 	SourceChannel          null.String     `json:"-"` | ||||
| 	SourceChannelID        null.String     `json:"-"` | ||||
| 	ID                     int                  `db:"id" json:"id"` | ||||
| 	CreatedAt              time.Time            `db:"created_at" json:"created_at"` | ||||
| 	UpdatedAt              time.Time            `db:"updated_at" json:"updated_at"` | ||||
| 	FirstName              string               `db:"first_name" json:"first_name"` | ||||
| 	LastName               string               `db:"last_name" json:"last_name"` | ||||
| 	Email                  null.String          `db:"email" json:"email"` | ||||
| 	Type                   string               `db:"type" json:"type"` | ||||
| 	AvailabilityStatus     string               `db:"availability_status" json:"availability_status"` | ||||
| 	PhoneNumberCallingCode null.String          `db:"phone_number_calling_code" json:"phone_number_calling_code"` | ||||
| 	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:"-"` | ||||
| 	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"` | ||||
| 	Permissions            pq.StringArray       `db:"permissions" json:"permissions"` | ||||
| 	CustomAttributes       json.RawMessage      `db:"custom_attributes" json:"custom_attributes"` | ||||
| 	Teams                  tmodels.TeamsCompact `db:"teams" json:"teams"` | ||||
| 	ContactChannelID       int                  `db:"contact_channel_id" json:"contact_channel_id,omitempty"` | ||||
| 	NewPassword            string               `db:"-" json:"new_password,omitempty"` | ||||
| 	SendWelcomeEmail       bool                 `db:"-" json:"send_welcome_email,omitempty"` | ||||
| 	InboxID                int                  `json:"-"` | ||||
| 	SourceChannel          null.String          `json:"-"` | ||||
| 	SourceChannelID        null.String          `json:"-"` | ||||
|  | ||||
| 	// API Key fields | ||||
| 	APIKey           null.String `db:"api_key" json:"api_key"` | ||||
| 	APIKeyLastUsedAt null.Time   `db:"api_key_last_used_at" json:"api_key_last_used_at"` | ||||
| 	APISecret        null.String `db:"api_secret" json:"-"` | ||||
|  | ||||
| 	Total int `json:"total,omitempty"` | ||||
| } | ||||
|  | ||||
| type Note struct { | ||||
|   | ||||
| @@ -26,12 +26,13 @@ func (u *Manager) GetNote(id int) (models.Note, error) { | ||||
| } | ||||
|  | ||||
| // CreateNote creates a new note for a user. | ||||
| func (u *Manager) CreateNote(userID, authorID int, note string) error { | ||||
| 	if _, err := u.q.InsertNote.Exec(userID, authorID, note); err != nil { | ||||
| func (u *Manager) CreateNote(userID, authorID int, note string) (models.Note, error) { | ||||
| 	var createdNote models.Note | ||||
| 	if err := u.q.InsertNote.Get(&createdNote, userID, authorID, note); err != nil { | ||||
| 		u.lo.Error("error creating user note", "error", err) | ||||
| 		return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorCreating", "name", u.i18n.P("globals.terms.note")), nil) | ||||
| 		return createdNote, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorCreating", "name", u.i18n.P("globals.terms.note")), nil) | ||||
| 	} | ||||
| 	return nil | ||||
| 	return createdNote, nil | ||||
| } | ||||
|  | ||||
| // DeleteNote deletes a note for a user. | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| -- name: get-users | ||||
| -- name: get-users-compact | ||||
| -- TODO: Remove hardcoded `type` of user in some queries in this file. | ||||
| SELECT COUNT(*) OVER() as total, users.id, users.avatar_url, users.type, users.created_at, users.updated_at, users.first_name, users.last_name, users.email, users.enabled | ||||
| FROM users | ||||
| WHERE users.email != 'System' AND users.deleted_at IS NULL AND type = $1 | ||||
| WHERE users.email != 'System' AND users.deleted_at IS NULL AND type = ANY($1) | ||||
|  | ||||
| -- name: soft-delete-agent | ||||
| WITH soft_delete AS ( | ||||
| @@ -23,12 +24,6 @@ delete_user_roles AS ( | ||||
| ) | ||||
| SELECT 1; | ||||
|  | ||||
| -- name: get-agents-compact | ||||
| SELECT u.id, u.type, u.first_name, u.last_name, u.enabled, u.avatar_url | ||||
| FROM users u | ||||
| WHERE u.email != 'System' AND u.deleted_at IS NULL AND u.type = 'agent' | ||||
| ORDER BY u.updated_at DESC; | ||||
|  | ||||
| -- name: get-user | ||||
| SELECT | ||||
|     u.id, | ||||
| @@ -37,8 +32,6 @@ SELECT | ||||
|     u.email, | ||||
|     u.password, | ||||
|     u.type, | ||||
|     u.created_at, | ||||
|     u.updated_at, | ||||
|     u.enabled, | ||||
|     u.avatar_url, | ||||
|     u.first_name, | ||||
| @@ -50,6 +43,7 @@ SELECT | ||||
|     u.phone_number, | ||||
|     u.api_key, | ||||
|     u.api_key_last_used_at, | ||||
|     u.api_secret, | ||||
|     array_agg(DISTINCT r.name) FILTER (WHERE r.name IS NOT NULL) AS roles, | ||||
|     COALESCE( | ||||
|         (SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji)) | ||||
| @@ -135,7 +129,7 @@ WHERE id = $1 AND type = 'agent'; | ||||
| -- name: set-password | ||||
| UPDATE users   | ||||
| SET password = $1, reset_password_token = NULL, reset_password_token_expiry = NULL | ||||
| WHERE reset_password_token = $2 AND reset_password_token_expiry > now() AND type = 'agent'; | ||||
| WHERE reset_password_token = $2 AND reset_password_token_expiry > now(); | ||||
|  | ||||
| -- name: insert-agent | ||||
| WITH inserted_user AS ( | ||||
| @@ -202,7 +196,8 @@ ORDER BY cn.created_at DESC; | ||||
|  | ||||
| -- name: insert-note | ||||
| INSERT INTO contact_notes (contact_id, user_id, note) | ||||
| VALUES ($1, $2, $3); | ||||
| VALUES ($1, $2, $3) | ||||
| RETURNING *; | ||||
|  | ||||
| -- name: delete-note | ||||
| DELETE FROM contact_notes | ||||
| @@ -229,6 +224,7 @@ SELECT | ||||
|     u.created_at, | ||||
|     u.updated_at, | ||||
|     u.email, | ||||
|     u.password, | ||||
|     u.type, | ||||
|     u.enabled, | ||||
|     u.avatar_url, | ||||
| @@ -239,6 +235,8 @@ SELECT | ||||
|     u.last_login_at, | ||||
|     u.phone_number_calling_code, | ||||
|     u.phone_number, | ||||
|     u.api_key, | ||||
|     u.api_key_last_used_at, | ||||
|     u.api_secret, | ||||
|     array_agg(DISTINCT r.name) FILTER (WHERE r.name IS NOT NULL) AS roles, | ||||
|     COALESCE( | ||||
| @@ -256,7 +254,7 @@ LEFT JOIN LATERAL unnest(r.permissions) AS p ON true | ||||
| WHERE u.api_key = $1 AND u.enabled = true AND u.deleted_at IS NULL | ||||
| GROUP BY u.id; | ||||
|  | ||||
| -- name: generate-api-key | ||||
| -- name: set-api-key | ||||
| UPDATE users  | ||||
| SET api_key = $2, api_secret = $3, api_key_last_used_at = NULL, updated_at = now() | ||||
| WHERE id = $1; | ||||
|   | ||||
| @@ -22,6 +22,7 @@ import ( | ||||
| 	"github.com/abhinavxd/libredesk/internal/user/models" | ||||
| 	"github.com/jmoiron/sqlx" | ||||
| 	"github.com/knadh/go-i18n" | ||||
| 	"github.com/lib/pq" | ||||
| 	"github.com/volatiletech/null/v9" | ||||
| 	"github.com/zerodha/logf" | ||||
| 	"golang.org/x/crypto/bcrypt" | ||||
| @@ -61,10 +62,9 @@ type Opts struct { | ||||
| // queries contains prepared SQL queries. | ||||
| type queries struct { | ||||
| 	GetUser                *sqlx.Stmt `query:"get-user"` | ||||
| 	GetUsers               string     `query:"get-users"` | ||||
| 	GetNotes               *sqlx.Stmt `query:"get-notes"` | ||||
| 	GetNote                *sqlx.Stmt `query:"get-note"` | ||||
| 	GetAgentsCompact       *sqlx.Stmt `query:"get-agents-compact"` | ||||
| 	GetUsersCompact        string     `query:"get-users-compact"` | ||||
| 	UpdateContact          *sqlx.Stmt `query:"update-contact"` | ||||
| 	UpdateAgent            *sqlx.Stmt `query:"update-agent"` | ||||
| 	UpdateCustomAttributes *sqlx.Stmt `query:"update-custom-attributes"` | ||||
| @@ -84,7 +84,7 @@ type queries struct { | ||||
| 	ToggleEnable           *sqlx.Stmt `query:"toggle-enable"` | ||||
| 	// API key queries | ||||
| 	GetUserByAPIKey      *sqlx.Stmt `query:"get-user-by-api-key"` | ||||
| 	GenerateAPIKey       *sqlx.Stmt `query:"generate-api-key"` | ||||
| 	SetAPIKey            *sqlx.Stmt `query:"set-api-key"` | ||||
| 	RevokeAPIKey         *sqlx.Stmt `query:"revoke-api-key"` | ||||
| 	UpdateAPIKeyLastUsed *sqlx.Stmt `query:"update-api-key-last-used"` | ||||
| } | ||||
| @@ -93,7 +93,7 @@ type queries struct { | ||||
| func New(i18n *i18n.I18n, opts Opts) (*Manager, error) { | ||||
| 	var q queries | ||||
| 	if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil { | ||||
| 		return nil, err | ||||
| 		return nil, fmt.Errorf("error scanning SQL file: %w", err) | ||||
| 	} | ||||
| 	return &Manager{ | ||||
| 		q:          q, | ||||
| @@ -121,7 +121,7 @@ func (u *Manager) VerifyPassword(email string, password []byte) (models.User, er | ||||
| } | ||||
|  | ||||
| // GetAllUsers returns a list of all users. | ||||
| func (u *Manager) GetAllUsers(page, pageSize int, userType, order, orderBy string, filtersJSON string) ([]models.User, error) { | ||||
| func (u *Manager) GetAllUsers(page, pageSize int, userType, order, orderBy string, filtersJSON string) ([]models.UserCompact, error) { | ||||
| 	query, qArgs, err := u.makeUserListQuery(page, pageSize, userType, order, orderBy, filtersJSON) | ||||
| 	if err != nil { | ||||
| 		u.lo.Error("error creating user list query", "error", err) | ||||
| @@ -139,7 +139,7 @@ func (u *Manager) GetAllUsers(page, pageSize int, userType, order, orderBy strin | ||||
| 	defer tx.Rollback() | ||||
|  | ||||
| 	// Execute query | ||||
| 	var users = make([]models.User, 0) | ||||
| 	var users = make([]models.UserCompact, 0) | ||||
| 	if err := tx.Select(&users, query, qArgs...); err != nil { | ||||
| 		u.lo.Error("error fetching users", "error", err) | ||||
| 		return nil, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil) | ||||
| @@ -186,6 +186,7 @@ func (u *Manager) UpdateLastLoginAt(id int) error { | ||||
|  | ||||
| // SetResetPasswordToken sets a reset password token for an user and returns the token. | ||||
| func (u *Manager) SetResetPasswordToken(id int) (string, error) { | ||||
| 	// TODO: column `reset_password_token`, does not have a UNIQUE constraint. Add it in a future migration. | ||||
| 	token, err := stringutil.RandomAlphanumeric(32) | ||||
| 	if err != nil { | ||||
| 		u.lo.Error("error generating reset password token", "error", err) | ||||
| @@ -198,7 +199,7 @@ func (u *Manager) SetResetPasswordToken(id int) (string, error) { | ||||
| 	return token, nil | ||||
| } | ||||
|  | ||||
| // ResetPassword sets a new password for an user. | ||||
| // ResetPassword sets a password for a given user's reset password token. | ||||
| func (u *Manager) ResetPassword(token, password string) error { | ||||
| 	if !IsStrongPassword(password) { | ||||
| 		return envelope.NewError(envelope.InputError, "Password is not strong enough, "+PasswordHint, nil) | ||||
| @@ -255,44 +256,6 @@ func (u *Manager) UpdateCustomAttributes(id int, customAttributes map[string]any | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // makeUserListQuery generates a query to fetch users based on the provided filters. | ||||
| func (u *Manager) makeUserListQuery(page, pageSize int, typ, order, orderBy, filtersJSON string) (string, []interface{}, error) { | ||||
| 	var ( | ||||
| 		baseQuery = u.q.GetUsers | ||||
| 		qArgs     []any | ||||
| 	) | ||||
| 	// Set the type of user to fetch. | ||||
| 	qArgs = append(qArgs, typ) | ||||
| 	return dbutil.BuildPaginatedQuery(baseQuery, qArgs, dbutil.PaginationOptions{ | ||||
| 		Order:    order, | ||||
| 		OrderBy:  orderBy, | ||||
| 		Page:     page, | ||||
| 		PageSize: pageSize, | ||||
| 	}, filtersJSON, dbutil.AllowedFields{ | ||||
| 		"users": {"email", "created_at", "updated_at"}, | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| // verifyPassword compares the provided password with the stored password hash. | ||||
| func (u *Manager) verifyPassword(pwd []byte, pwdHash string) error { | ||||
| 	if err := bcrypt.CompareHashAndPassword([]byte(pwdHash), pwd); err != nil { | ||||
| 		u.lo.Error("error verifying password", "error", err) | ||||
| 		return fmt.Errorf("error verifying password: %w", err) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // generatePassword generates a random password and returns its bcrypt hash. | ||||
| func (u *Manager) generatePassword() ([]byte, error) { | ||||
| 	password, _ := stringutil.RandomAlphanumeric(70) | ||||
| 	bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) | ||||
| 	if err != nil { | ||||
| 		u.lo.Error("error generating bcrypt password", "error", err) | ||||
| 		return nil, fmt.Errorf("generating bcrypt password: %w", err) | ||||
| 	} | ||||
| 	return bytes, nil | ||||
| } | ||||
|  | ||||
| // ToggleEnabled toggles the enabled status of an user. | ||||
| func (u *Manager) ToggleEnabled(id int, typ string, enabled bool) error { | ||||
| 	if _, err := u.q.ToggleEnable.Exec(id, typ, enabled); err != nil { | ||||
| @@ -326,7 +289,7 @@ func (u *Manager) GenerateAPIKey(userID int) (string, string, error) { | ||||
| 	} | ||||
|  | ||||
| 	// Update user with API key. | ||||
| 	if _, err := u.q.GenerateAPIKey.Exec(userID, apiKey, string(secretHash)); err != nil { | ||||
| 	if _, err := u.q.SetAPIKey.Exec(userID, apiKey, string(secretHash)); err != nil { | ||||
| 		u.lo.Error("error saving API key", "error", err, "user_id", userID) | ||||
| 		return "", "", envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorGenerating", "name", "{globals.terms.apiKey}"), nil) | ||||
| 	} | ||||
| @@ -469,3 +432,37 @@ func updateSystemUserPassword(db *sqlx.DB, hashedPassword []byte) error { | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // makeUserListQuery generates a query to fetch users based on the provided filters. | ||||
| func (u *Manager) makeUserListQuery(page, pageSize int, typ, order, orderBy, filtersJSON string) (string, []interface{}, error) { | ||||
| 	var qArgs []any | ||||
| 	qArgs = append(qArgs, pq.Array([]string{typ})) | ||||
| 	return dbutil.BuildPaginatedQuery(u.q.GetUsersCompact, qArgs, dbutil.PaginationOptions{ | ||||
| 		Order:    order, | ||||
| 		OrderBy:  orderBy, | ||||
| 		Page:     page, | ||||
| 		PageSize: pageSize, | ||||
| 	}, filtersJSON, dbutil.AllowedFields{ | ||||
| 		"users": {"email", "created_at", "updated_at"}, | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| // verifyPassword compares the provided password with the stored password hash. | ||||
| func (u *Manager) verifyPassword(pwd []byte, pwdHash string) error { | ||||
| 	if err := bcrypt.CompareHashAndPassword([]byte(pwdHash), pwd); err != nil { | ||||
| 		u.lo.Error("error verifying password", "error", err) | ||||
| 		return fmt.Errorf("error verifying password: %w", err) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // generatePassword generates a random password and returns its bcrypt hash. | ||||
| func (u *Manager) generatePassword() ([]byte, error) { | ||||
| 	password, _ := stringutil.RandomAlphanumeric(70) | ||||
| 	bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) | ||||
| 	if err != nil { | ||||
| 		u.lo.Error("error generating bcrypt password", "error", err) | ||||
| 		return nil, fmt.Errorf("generating bcrypt password: %w", err) | ||||
| 	} | ||||
| 	return bytes, nil | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| package models | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/lib/pq" | ||||
| @@ -37,10 +36,3 @@ const ( | ||||
| 	// Test event | ||||
| 	EventWebhookTest WebhookEvent = "webhook.test" | ||||
| ) | ||||
|  | ||||
| // WebhookPayload represents the payload sent to a webhook | ||||
| type WebhookPayload struct { | ||||
| 	Event     WebhookEvent    `json:"event"` | ||||
| 	Timestamp time.Time       `json:"timestamp"` | ||||
| 	Data      json.RawMessage `json:",inline"` | ||||
| } | ||||
|   | ||||
| @@ -5,7 +5,7 @@ | ||||
|     {{ if ne SiteName "" }} | ||||
|     Welcome to {{ SiteName }} | ||||
|     {{ else }} | ||||
|     Welcome | ||||
|     Welcome to Libredesk | ||||
|     {{ end }} | ||||
| </h1> | ||||
|  | ||||
|   | ||||
| @@ -183,7 +183,7 @@ footer.container { | ||||
|   margin-top: 2rem; | ||||
|   text-align: center; | ||||
|   color: #9ca3af; | ||||
|   font-size: 0.875rem; | ||||
|   font-size: 0.70rem; | ||||
| } | ||||
|  | ||||
| footer a { | ||||
|   | ||||
| @@ -2,10 +2,7 @@ | ||||
| {{ template "header" . }} | ||||
| <div class="csat-container"> | ||||
|     <div class="csat-header"> | ||||
|         <h2>Rate your recent interaction</h2> | ||||
|         {{ if .Data.Conversation.Subject }} | ||||
|         <p class="conversation-subject"><i>{{ .Data.Conversation.Subject }}</i></p> | ||||
|         {{ end }} | ||||
|         <h2>{{ L.T "csat.rateYourInteraction" }}</h2> | ||||
|     </div> | ||||
|  | ||||
|     <form action="/csat/{{ .Data.CSAT.UUID }}" method="POST" class="csat-form" novalidate> | ||||
| @@ -16,7 +13,7 @@ | ||||
|                     <div class="emoji-wrapper"> | ||||
|                         <span class="emoji">😢</span> | ||||
|                     </div> | ||||
|                     <span class="rating-text">Poor</span> | ||||
|                     <span class="rating-text">{{ L.T "csat.rating.poor" }}</span> | ||||
|                 </label> | ||||
|  | ||||
|                 <input type="radio" id="rating-2" name="rating" value="2"> | ||||
| @@ -24,7 +21,7 @@ | ||||
|                     <div class="emoji-wrapper"> | ||||
|                         <span class="emoji">😕</span> | ||||
|                     </div> | ||||
|                     <span class="rating-text">Fair</span> | ||||
|                     <span class="rating-text">{{ L.T "csat.rating.fair" }}</span> | ||||
|                 </label> | ||||
|  | ||||
|                 <input type="radio" id="rating-3" name="rating" value="3"> | ||||
| @@ -32,7 +29,7 @@ | ||||
|                     <div class="emoji-wrapper"> | ||||
|                         <span class="emoji">😊</span> | ||||
|                     </div> | ||||
|                     <span class="rating-text">Good</span> | ||||
|                     <span class="rating-text">{{ L.T "csat.rating.good" }}</span> | ||||
|                 </label> | ||||
|  | ||||
|                 <input type="radio" id="rating-4" name="rating" value="4"> | ||||
| @@ -40,7 +37,7 @@ | ||||
|                     <div class="emoji-wrapper"> | ||||
|                         <span class="emoji">😃</span> | ||||
|                     </div> | ||||
|                     <span class="rating-text">Great</span> | ||||
|                     <span class="rating-text">{{ L.T "csat.rating.great" }}</span> | ||||
|                 </label> | ||||
|  | ||||
|                 <input type="radio" id="rating-5" name="rating" value="5"> | ||||
| @@ -48,18 +45,18 @@ | ||||
|                     <div class="emoji-wrapper"> | ||||
|                         <span class="emoji">🤩</span> | ||||
|                     </div> | ||||
|                     <span class="rating-text">Excellent</span> | ||||
|                     <span class="rating-text">{{ L.T "csat.rating.excellent" }}</span> | ||||
|                 </label> | ||||
|             </div> | ||||
|             <!-- Validation message for rating --> | ||||
|             <div class="validation-message" id="ratingValidationMessage" | ||||
|                 style="display: none; color: #dc2626; text-align: center; margin-top: 10px; font-size: 0.9em;"> | ||||
|                 Please select a rating before submitting. | ||||
|                 {{ L.Ts "globals.messages.pleaseSelect" "name" "rating" }} | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <div class="feedback-container"> | ||||
|             <label for="feedback" class="feedback-label">Additional feedback (optional)</label> | ||||
|             <label for="feedback" class="feedback-label">{{ L.T "globals.messages.additionalFeedback" }}</label> | ||||
|             <textarea id="feedback" name="feedback" placeholder="" rows="6" maxlength="1000" | ||||
|                 onkeyup="updateCharCount(this)"></textarea> | ||||
|             <div class="char-counter"> | ||||
| @@ -67,7 +64,7 @@ | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <button type="submit" class="button submit-button">Submit</button> | ||||
|         <button type="submit" class="button submit-button">{{ L.T "globals.messages.submit" }}</button> | ||||
|     </form> | ||||
| </div> | ||||
|  | ||||
| @@ -148,9 +145,9 @@ | ||||
|     .rating-options { | ||||
|         display: flex; | ||||
|         justify-content: center; | ||||
|         gap: 25px; | ||||
|         flex-wrap: wrap; | ||||
|         gap: 15px; | ||||
|         margin-top: 30px; | ||||
|         align-items: center; | ||||
|     } | ||||
|  | ||||
|     .rating-options input[type="radio"] { | ||||
| @@ -163,9 +160,10 @@ | ||||
|         align-items: center; | ||||
|         cursor: pointer; | ||||
|         transition: all 0.3s ease; | ||||
|         padding: 15px; | ||||
|         padding: 12px; | ||||
|         position: relative; | ||||
|         width: 110px; | ||||
|         min-width: 90px; | ||||
|         flex-shrink: 0; | ||||
|     } | ||||
|  | ||||
|     .rating-option:hover { | ||||
| @@ -173,7 +171,8 @@ | ||||
|     } | ||||
|  | ||||
|     .rating-option:focus { | ||||
|         outline: 2px solid #0055d4; | ||||
|         outline: 2px solid #3b82f6; | ||||
|         outline-offset: 2px; | ||||
|         border-radius: 8px; | ||||
|     } | ||||
|  | ||||
| @@ -181,41 +180,33 @@ | ||||
|         transform: translateY(-3px); | ||||
|     } | ||||
|  | ||||
|     .rating-options input[type="radio"]:checked+.rating-option::after { | ||||
|         content: ''; | ||||
|         position: absolute; | ||||
|         bottom: 0; | ||||
|         left: 50%; | ||||
|         transform: translateX(-50%); | ||||
|         width: 40px; | ||||
|         height: 3px; | ||||
|         background-color: #0055d4; | ||||
|         border-radius: 2px; | ||||
|     } | ||||
|  | ||||
|     .emoji-wrapper { | ||||
|         background: #f8f9ff; | ||||
|         background: #f8fafc; | ||||
|         border-radius: 50%; | ||||
|         width: 70px; | ||||
|         height: 70px; | ||||
|         width: 65px; | ||||
|         height: 65px; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         margin-bottom: 12px; | ||||
|         margin-bottom: 8px; | ||||
|         transition: all 0.3s ease; | ||||
|         border: 2px solid transparent; | ||||
|         border: 2px solid #e2e8f0; | ||||
|         box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); | ||||
|     } | ||||
|  | ||||
|     .rating-option:hover .emoji-wrapper { | ||||
|         transform: scale(1.1); | ||||
|         background: #f0f5ff; | ||||
|         border-color: #0055d4; | ||||
|         transform: scale(1.05); | ||||
|         background: #f1f5f9; | ||||
|         border-color: #3b82f6; | ||||
|         box-shadow: 0 4px 8px rgba(59, 130, 246, 0.15); | ||||
|     } | ||||
|  | ||||
|     .rating-options input[type="radio"]:checked+.rating-option .emoji-wrapper { | ||||
|         transform: scale(1.1); | ||||
|         background: #e8f0ff; | ||||
|         border-color: #0055d4; | ||||
|         transform: scale(1.05); | ||||
|         background: #dbeafe; | ||||
|         border-color: #3b82f6; | ||||
|         box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); | ||||
|     } | ||||
|  | ||||
|     .emoji { | ||||
| @@ -225,10 +216,11 @@ | ||||
|     } | ||||
|  | ||||
|     .rating-text { | ||||
|         font-size: 0.9em; | ||||
|         font-size: 0.85em; | ||||
|         text-align: center; | ||||
|         color: #666; | ||||
|         color: #64748b; | ||||
|         font-weight: 500; | ||||
|         line-height: 1.2; | ||||
|     } | ||||
|  | ||||
|     .feedback-container { | ||||
| @@ -254,8 +246,9 @@ | ||||
|     } | ||||
|  | ||||
|     textarea:focus { | ||||
|         border-color: #0055d4; | ||||
|         border-color: #3b82f6; | ||||
|         outline: none; | ||||
|         box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); | ||||
|     } | ||||
|  | ||||
|     .char-counter { | ||||
| @@ -279,28 +272,23 @@ | ||||
|         transition: all 0.3s ease; | ||||
|     } | ||||
|  | ||||
|     @media screen and (max-width: 650px) { | ||||
|  | ||||
|     @media screen and (max-width: 600px) { | ||||
|         .csat-container { | ||||
|             margin: 0; | ||||
|             padding: 30px; | ||||
|             padding: 20px; | ||||
|             border-radius: 0; | ||||
|         } | ||||
|  | ||||
|         .rating-options { | ||||
|             flex-direction: column; | ||||
|             gap: 8px; | ||||
|         } | ||||
|  | ||||
|         .rating-option { | ||||
|             flex-direction: row; | ||||
|             justify-content: flex-start; | ||||
|             gap: 15px; | ||||
|             width: 100%; | ||||
|             padding: 15px; | ||||
|             min-width: 70px; | ||||
|             padding: 8px; | ||||
|         } | ||||
|  | ||||
|         .emoji-wrapper { | ||||
|             margin-bottom: 0; | ||||
|             width: 50px; | ||||
|             height: 50px; | ||||
|         } | ||||
| @@ -310,7 +298,31 @@ | ||||
|         } | ||||
|  | ||||
|         .rating-text { | ||||
|             text-align: left; | ||||
|             font-size: 0.8em; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @media screen and (max-width: 480px) { | ||||
|         .rating-options { | ||||
|             gap: 5px; | ||||
|         } | ||||
|  | ||||
|         .rating-option { | ||||
|             min-width: 60px; | ||||
|             padding: 6px; | ||||
|         } | ||||
|  | ||||
|         .emoji-wrapper { | ||||
|             width: 45px; | ||||
|             height: 45px; | ||||
|         } | ||||
|  | ||||
|         .emoji { | ||||
|             font-size: 1.6em; | ||||
|         } | ||||
|  | ||||
|         .rating-text { | ||||
|             font-size: 0.75em; | ||||
|         } | ||||
|     } | ||||
| </style> | ||||
|   | ||||
| @@ -31,7 +31,7 @@ | ||||
| {{ define "footer" }} | ||||
| 	</div> | ||||
| 	<footer class="container"> | ||||
| 		Powered by <a target="_blank" rel="noreferrer" href="https://libredesk.io/">Libredesk</a> | ||||
| 		{{ L.T "globals.messages.poweredBy" }} <a target="_blank" rel="noreferrer" href="https://libredesk.io/">Libredesk</a> | ||||
| 	</footer> | ||||
| </body> | ||||
| </html> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user