From 4d28f4ef831f1105331e0921c2a597c5712446cc Mon Sep 17 00:00:00 2001 From: csr4422 Date: Sat, 27 Dec 2025 17:51:33 +0530 Subject: [PATCH 01/13] feat: add importer package --- internal/importer/importer.go | 92 +++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 internal/importer/importer.go diff --git a/internal/importer/importer.go b/internal/importer/importer.go new file mode 100644 index 00000000..8291132b --- /dev/null +++ b/internal/importer/importer.go @@ -0,0 +1,92 @@ +package importer + +import ( + "fmt" + "sync" + "time" +) + +type JobStatus struct { + Running bool `json:"running"` + Logs []string `json:"logs"` + Total int `json:"total"` + Success int `json:"success"` + Errors int `json:"errors"` + StartedAt time.Time `json:"started_at"` + EndedAt time.Time `json:"ended_at"` +} + +type Importer struct { + jobs map[string]*JobStatus + mu sync.RWMutex +} + +func NewImporter() *Importer { + i := &Importer{ + jobs: make(map[string]*JobStatus), + } + go i.cleanUp() + return i +} + +func (i *Importer) Submit(namespace string, fn func() error) error { + i.mu.Lock() + + if status, exists := i.jobs[namespace]; exists && status.Running { + i.mu.Unlock() + return fmt.Errorf("import already running for namespace: %s", namespace) + } + + status := &JobStatus{ + Running: true, + Logs: []string{}, + StartedAt: time.Now(), + } + i.jobs[namespace] = status + i.mu.Unlock() + + go func() { + defer func() { + i.mu.Lock() + status.Running = false + status.EndedAt = time.Now() + i.mu.Unlock() + }() + + if err := fn(); err != nil { + i.mu.Lock() + status.Logs = append(status.Logs, fmt.Sprintf("Error: %v", err)) + i.mu.Unlock() + } + }() + + return nil +} + +func (i *Importer) GetStatus(namespace string) (*JobStatus, error) { + i.mu.RLock() + defer i.mu.RUnlock() + + status, exists := i.jobs[namespace] + if !exists { + return nil, fmt.Errorf("no import job found for namespace: %s", namespace) + } + + return status, nil +} + +func (i *Importer) cleanUp() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for range ticker.C { + i.mu.Lock() + now := time.Now() + for namespace, status := range i.jobs { + if !status.Running && now.Sub(status.EndedAt) > 24*time.Hour { + delete(i.jobs, namespace) + } + } + i.mu.Unlock() + } +} From 88f1c92fa76966dc0f8f1cb2337282faacb1da31 Mon Sep 17 00:00:00 2001 From: csr4422 Date: Mon, 29 Dec 2025 10:26:24 +0530 Subject: [PATCH 02/13] feat: add importer to App initialization --- cmd/main.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/main.go b/cmd/main.go index 2842e843..a6a18140 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -32,6 +32,7 @@ import ( "github.com/abhinavxd/libredesk/internal/conversation" "github.com/abhinavxd/libredesk/internal/conversation/priority" "github.com/abhinavxd/libredesk/internal/conversation/status" + "github.com/abhinavxd/libredesk/internal/importer" "github.com/abhinavxd/libredesk/internal/inbox" "github.com/abhinavxd/libredesk/internal/media" "github.com/abhinavxd/libredesk/internal/oidc" @@ -94,6 +95,7 @@ type App struct { customAttribute *customAttribute.Manager report *report.Manager webhook *webhook.Manager + importer *importer.Importer // Global state that stores data on an available app update. update *AppUpdate @@ -238,6 +240,7 @@ func main() { conversation: conversation, automation: automation, businessHours: businessHours, + importer: importer.NewImporter(), activityLog: initActivityLog(db, i18n), customAttribute: initCustomAttribute(db, i18n), authz: initAuthz(i18n), From e3141b1aa91f78c243dba8fc3bc3c3fa4274cb69 Mon Sep 17 00:00:00 2001 From: csr4422 Date: Thu, 1 Jan 2026 21:35:48 +0530 Subject: [PATCH 03/13] feat: add csv import handler and file processing --- cmd/agent_import.go | 197 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 cmd/agent_import.go diff --git a/cmd/agent_import.go b/cmd/agent_import.go new file mode 100644 index 00000000..c05406b1 --- /dev/null +++ b/cmd/agent_import.go @@ -0,0 +1,197 @@ +package main + +import ( + "encoding/csv" + "fmt" + "strings" + + "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/stringutil" + "github.com/valyala/fasthttp" + "github.com/zerodha/fastglue" +) + +// handleImportAgents handles CSV upload and starts import job +func handleImportAgents(r *fastglue.Request) error { + var app = r.Context.(*App) + + // Get file from form + file, err := r.RequestCtx.FormFile("file") + if err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "No file provided", nil, envelope.InputError) + } + + // Open file + fileContent, err := file.Open() + if err != nil { + app.lo.Error("error opening uploaded file", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Failed to read file", nil, envelope.GeneralError) + } + defer fileContent.Close() + + // Parse CSV + reader := csv.NewReader(fileContent) + reader.TrimLeadingSpace = true + records, err := reader.ReadAll() + if err != nil { + app.lo.Error("error parsing CSV", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid CSV format", nil, envelope.InputError) + } + + if len(records) == 0 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty CSV file", nil, envelope.InputError) + } + + // Submit import job + err = app.importer.Submit("agents", func() error { + return processAgentImport(app, records) + }) + + if err != nil { + return r.SendErrorEnvelope(fasthttp.StatusConflict, err.Error(), nil, envelope.GeneralError) + } + + return r.SendEnvelope(map[string]string{ + "message": "Import started", + }) +} + +// handleGetAgentImportStatus returns current import status +func handleGetAgentImportStatus(r *fastglue.Request) error { + var app = r.Context.(*App) + + status, err := app.importer.GetStatus("agents") + if err != nil { + return r.SendErrorEnvelope(fasthttp.StatusNotFound, err.Error(), nil, envelope.NotFoundError) + } + + return r.SendEnvelope(status) +} + +// processAgentImport processes CSV records and creates agents +func processAgentImport(app *App, records [][]string) error { + if len(records) < 2 { + return fmt.Errorf("CSV must have headers and at least one data row") + } + + // Parse headers + headers := records[0] + headerMap := make(map[string]int) + for i, h := range headers { + headerMap[strings.TrimSpace(strings.ToLower(h))] = i + } + + // Validate required columns + required := []string{"first_name", "last_name", "email", "roles", "teams"} + for _, r := range required { + if _, ok := headerMap[r]; !ok { + return fmt.Errorf("missing required column: %s", r) + } + } + + // Set total count + total := len(records) - 1 + app.importer.UpdateCounts("agents", total, 0, 0) + app.importer.AddLog("agents", fmt.Sprintf("Starting import of %d agents", total)) + + // Process each row + for i, record := range records[1:] { + rowNum := i + 2 // +2 for header and 1-based indexing + + // Skip empty rows + if len(record) == 0 || (len(record) == 1 && strings.TrimSpace(record[0]) == "") { + continue + } + + // Parse fields + firstName := getField(record, headerMap, "first_name") + lastName := getField(record, headerMap, "last_name") + email := strings.TrimSpace(strings.ToLower(getField(record, headerMap, "email"))) + rolesStr := getField(record, headerMap, "roles") + teamsStr := getField(record, headerMap, "teams") + + // Validate required fields + if firstName == "" || lastName == "" || email == "" || rolesStr == "" || teamsStr == "" { + app.importer.UpdateCounts("agents", 0, 0, 1) + app.importer.AddLog("agents", fmt.Sprintf("Row %d: Missing required fields", rowNum)) + continue + } + + // Validate email + if !stringutil.ValidEmail(email) { + app.importer.UpdateCounts("agents", 0, 0, 1) + app.importer.AddLog("agents", fmt.Sprintf("Row %d: Invalid email format - %s", rowNum, email)) + continue + } + + // Parse roles (comma or semicolon separated) + rolesStr = strings.ReplaceAll(rolesStr, ";", ",") + rolesParts := strings.Split(rolesStr, ",") + var roles []string + for _, role := range rolesParts { + if r := strings.TrimSpace(role); r != "" { + roles = append(roles, r) + } + } + + if len(roles) == 0 { + app.importer.UpdateCounts("agents", 0, 0, 1) + app.importer.AddLog("agents", fmt.Sprintf("Row %d: At least one role is required", rowNum)) + continue + } + + // Parse teams (comma or semicolon separated) + teamsStr = strings.ReplaceAll(teamsStr, ";", ",") + teamsParts := strings.Split(teamsStr, ",") + var teams []string + for _, team := range teamsParts { + if t := strings.TrimSpace(team); t != "" { + teams = append(teams, t) + } + } + + if len(teams) == 0 { + app.importer.UpdateCounts("agents", 0, 0, 1) + app.importer.AddLog("agents", fmt.Sprintf("Row %d: At least one team is required", rowNum)) + continue + } + + // Create agent + agent, err := app.user.CreateAgent(firstName, lastName, email, roles) + if err != nil { + app.importer.UpdateCounts("agents", 0, 0, 1) + app.importer.AddLog("agents", fmt.Sprintf("Row %d: Failed to create - %v", rowNum, err)) + continue + } + + // Assign teams + if err := app.team.UpsertUserTeams(agent.ID, teams); err != nil { + app.importer.UpdateCounts("agents", 0, 0, 1) + app.importer.AddLog("agents", fmt.Sprintf("Row %d: Created agent but failed to assign teams - %v", rowNum, err)) + continue + } + + app.importer.UpdateCounts("agents", 0, 1, 0) + app.importer.AddLog("agents", fmt.Sprintf("Row %d: Created agent %s (%s) with teams", rowNum, agent.FullName(), agent.Email.String)) + + // Log progress every 10 records + if (i+1)%10 == 0 { + app.importer.AddLog("agents", fmt.Sprintf("Progress: %d/%d processed", i+1, total)) + } + } + + // Final summary + status, _ := app.importer.GetStatus("agents") + app.importer.AddLog("agents", fmt.Sprintf("Import completed: %d successful, %d failed out of %d total", + status.Success, status.Errors, status.Total)) + + return nil +} + +// getField safely retrieves a field from CSV record +func getField(record []string, headerMap map[string]int, name string) string { + if idx, ok := headerMap[name]; ok && idx < len(record) { + return strings.TrimSpace(record[idx]) + } + return "" +} From 1250c7f7c453b09ba5fe5d7158be1c4a31d40998 Mon Sep 17 00:00:00 2001 From: csr4422 Date: Thu, 1 Jan 2026 21:39:47 +0530 Subject: [PATCH 04/13] feat:add job logs and count tracking --- internal/importer/importer.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/internal/importer/importer.go b/internal/importer/importer.go index 8291132b..bedd3618 100644 --- a/internal/importer/importer.go +++ b/internal/importer/importer.go @@ -90,3 +90,31 @@ func (i *Importer) cleanUp() { i.mu.Unlock() } } + +// AddLog appends a log message to the job status +func (i *Importer) AddLog(namespace, message string) { + i.mu.Lock() + defer i.mu.Unlock() + + if status, exists := i.jobs[namespace]; exists { + status.Logs = append(status.Logs, message) + } +} + +// UpdateCounts updates the success/error counts and total +func (i *Importer) UpdateCounts(namespace string, total, success, errors int) { + i.mu.Lock() + defer i.mu.Unlock() + + if status, exists := i.jobs[namespace]; exists { + if total > 0 { + status.Total = total + } + if success > 0 { + status.Success += success + } + if errors > 0 { + status.Errors += errors + } + } +} From 294b3a9b48e4d755141d197062f8f6bc123838a0 Mon Sep 17 00:00:00 2001 From: csr4422 Date: Thu, 1 Jan 2026 21:41:30 +0530 Subject: [PATCH 05/13] feat: add agent import API routes --- cmd/handlers.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/handlers.go b/cmd/handlers.go index c75b3c1e..c21f991c 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -112,6 +112,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.POST("/api/v1/agents", perm(handleCreateAgent, "users:manage")) g.PUT("/api/v1/agents/{id}", perm(handleUpdateAgent, "users:manage")) g.DELETE("/api/v1/agents/{id}", perm(handleDeleteAgent, "users:manage")) + g.POST("/api/v1/agents/import", perm(handleImportAgents, "users:manage")) + g.GET("/api/v1/agents/import/status", perm(handleGetAgentImportStatus, "users:manage")) g.POST("/api/v1/agents/{id}/api-key", perm(handleGenerateAPIKey, "users:manage")) g.DELETE("/api/v1/agents/{id}/api-key", perm(handleRevokeAPIKey, "users:manage")) g.POST("/api/v1/agents/reset-password", tryAuth(handleResetPassword)) From 4b77d89df88bd264eb3593ce99a39fcb14f8c32b Mon Sep 17 00:00:00 2001 From: csr4422 Date: Sat, 3 Jan 2026 23:10:18 +0530 Subject: [PATCH 06/13] feat: add import agents dialog with CSV upload and progress view --- .../src/components/ui/importer/Importer.vue | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 frontend/src/components/ui/importer/Importer.vue diff --git a/frontend/src/components/ui/importer/Importer.vue b/frontend/src/components/ui/importer/Importer.vue new file mode 100644 index 00000000..89081617 --- /dev/null +++ b/frontend/src/components/ui/importer/Importer.vue @@ -0,0 +1,216 @@ + + + \ No newline at end of file From 65b05ca975186baefcad0eb4a780a2d5a7441aed Mon Sep 17 00:00:00 2001 From: csr4422 Date: Sat, 3 Jan 2026 23:18:07 +0530 Subject: [PATCH 07/13] feat: add importer button to agents list page --- frontend/src/views/admin/agents/AgentList.vue | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/admin/agents/AgentList.vue b/frontend/src/views/admin/agents/AgentList.vue index 8ed790a9..681bb7a1 100644 --- a/frontend/src/views/admin/agents/AgentList.vue +++ b/frontend/src/views/admin/agents/AgentList.vue @@ -1,7 +1,8 @@ diff --git a/i18n/en.json b/i18n/en.json index 4b3d9eca..6667a6dc 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -18,8 +18,10 @@ "globals.terms.permission": "Permission | Permissions", "globals.terms.request": "Request | Requests", "globals.terms.file": "File | Files", + "globals.terms.csvFile": "CSV file | CSV files", "globals.terms.actor": "Actor | Actors", "globals.terms.page": "Page | Pages", + "globals.terms.log": "Log | Logs", "globals.terms.activityLog": "Activity log | Activity logs", "activityLog.type.agentLogin": "Agent login", "activityLog.type.agentLogout": "Agent logout", @@ -331,6 +333,10 @@ "globals.messages.upload": "Upload", "globals.messages.back": "Back", "globals.messages.close": "Close", + "globals.messages.import": "Import {name}", + "globals.messages.selectFile": "Select {name}", + "globals.messages.uploadFailed": "Upload failed", + "globals.messages.inProgressConfirmClose": "{action} in progress. Close?", "globals.messages.apply": "Apply {name}", "globals.messages.reset": "Reset {name}", "globals.messages.lastNItems": "Last {n} {name} | Last {n} {name}", @@ -720,5 +726,10 @@ "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" + "setup.inviteTeammates": "Invite teammates", + "importer.requiredCSVFormat": "Required CSV format", + "importer.importCompleted": "Import completed: {success} of {total} successful, {errors} failed", + "importer.csvMustContainHeadersAndData": "CSV must contain headers and at least one data row", + "importer.importAlreadyInProgress": "Import already in progress", + "importer.caseSensitiveNote": "Roles and teams must match exactly (case-sensitive)" } \ No newline at end of file diff --git a/internal/importer/importer.go b/internal/importer/importer.go index bedd3618..6d5a9147 100644 --- a/internal/importer/importer.go +++ b/internal/importer/importer.go @@ -1,12 +1,18 @@ package importer import ( + "context" "fmt" "sync" "time" + + "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/knadh/go-i18n" + "github.com/zerodha/logf" ) -type JobStatus struct { +// Job represents the status of an import job. +type Job struct { Running bool `json:"running"` Logs []string `json:"logs"` Total int `json:"total"` @@ -16,28 +22,49 @@ type JobStatus struct { EndedAt time.Time `json:"ended_at"` } +// Importer manages background import jobs. type Importer struct { - jobs map[string]*JobStatus - mu sync.RWMutex + lo *logf.Logger + i18n *i18n.I18n + jobs map[string]*Job + mu sync.RWMutex + wg sync.WaitGroup + ctx context.Context + cancel context.CancelFunc } -func NewImporter() *Importer { +// Opts contains options for initializing the Importer. +type Opts struct { + Lo *logf.Logger + I18n *i18n.I18n +} + +// New creates and returns a new instance of the Importer. +func New(opts Opts) *Importer { + ctx, cancel := context.WithCancel(context.Background()) i := &Importer{ - jobs: make(map[string]*JobStatus), + lo: opts.Lo, + i18n: opts.I18n, + jobs: make(map[string]*Job), + ctx: ctx, + cancel: cancel, } + i.wg.Add(1) go i.cleanUp() return i } +// Submit submits a new import job for execution. func (i *Importer) Submit(namespace string, fn func() error) error { i.mu.Lock() if status, exists := i.jobs[namespace]; exists && status.Running { i.mu.Unlock() - return fmt.Errorf("import already running for namespace: %s", namespace) + return envelope.NewError(envelope.ConflictError, + i.i18n.T("importer.importAlreadyInProgress"), nil) } - status := &JobStatus{ + status := &Job{ Running: true, Logs: []string{}, StartedAt: time.Now(), @@ -45,53 +72,53 @@ func (i *Importer) Submit(namespace string, fn func() error) error { i.jobs[namespace] = status i.mu.Unlock() + i.lo.Info("starting import job", "namespace", namespace) + go func() { defer func() { + // Recover from panics + if r := recover(); r != nil { + i.mu.Lock() + status.Logs = append(status.Logs, fmt.Sprintf("Panic: %v", r)) + i.mu.Unlock() + i.lo.Error("import job panicked", "namespace", namespace, "panic", r) + } + i.mu.Lock() status.Running = false status.EndedAt = time.Now() i.mu.Unlock() + + i.lo.Info("import job completed", "namespace", namespace, + "total", status.Total, "success", status.Success, "errors", status.Errors) }() if err := fn(); err != nil { i.mu.Lock() status.Logs = append(status.Logs, fmt.Sprintf("Error: %v", err)) i.mu.Unlock() + i.lo.Error("import job failed", "namespace", namespace, "error", err) } }() return nil } -func (i *Importer) GetStatus(namespace string) (*JobStatus, error) { +// GetStatus returns the status of an import job. +func (i *Importer) GetStatus(namespace string) (*Job, error) { i.mu.RLock() defer i.mu.RUnlock() status, exists := i.jobs[namespace] if !exists { - return nil, fmt.Errorf("no import job found for namespace: %s", namespace) + return nil, envelope.NewError(envelope.NotFoundError, + i.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.import}"), nil) } return status, nil } -func (i *Importer) cleanUp() { - ticker := time.NewTicker(1 * time.Hour) - defer ticker.Stop() - - for range ticker.C { - i.mu.Lock() - now := time.Now() - for namespace, status := range i.jobs { - if !status.Running && now.Sub(status.EndedAt) > 24*time.Hour { - delete(i.jobs, namespace) - } - } - i.mu.Unlock() - } -} - -// AddLog appends a log message to the job status +// AddLog appends a log message to the job status. func (i *Importer) AddLog(namespace, message string) { i.mu.Lock() defer i.mu.Unlock() @@ -101,7 +128,7 @@ func (i *Importer) AddLog(namespace, message string) { } } -// UpdateCounts updates the success/error counts and total +// UpdateCounts updates the success/error counts and total. func (i *Importer) UpdateCounts(namespace string, total, success, errors int) { i.mu.Lock() defer i.mu.Unlock() @@ -118,3 +145,34 @@ func (i *Importer) UpdateCounts(namespace string, total, success, errors int) { } } } + +// Close gracefully shuts down the importer. +func (i *Importer) Close() { + i.cancel() + i.wg.Wait() +} + +// cleanUp periodically removes old completed jobs. +func (i *Importer) cleanUp() { + defer i.wg.Done() + + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-i.ctx.Done(): + return + case <-ticker.C: + i.mu.Lock() + now := time.Now() + for namespace, status := range i.jobs { + if !status.Running && now.Sub(status.EndedAt) > 1*time.Hour { + delete(i.jobs, namespace) + i.lo.Debug("cleaned up old import job", "namespace", namespace) + } + } + i.mu.Unlock() + } + } +} From 7c91ca8813f20787008722a86ac107ac98150508 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 9 Jan 2026 03:28:56 +0530 Subject: [PATCH 13/13] refactor: update translation keys for file selection and remove unused messages --- frontend/src/components/importer/Importer.vue | 9 +-------- i18n/en.json | 3 --- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/frontend/src/components/importer/Importer.vue b/frontend/src/components/importer/Importer.vue index 9d87fb83..d4ecc0b1 100644 --- a/frontend/src/components/importer/Importer.vue +++ b/frontend/src/components/importer/Importer.vue @@ -24,7 +24,7 @@ {{ file ? file.name - : $t('globals.messages.selectFile', { name: $t('globals.terms.csvFile') }) + : $t('globals.messages.select', { name: $t('globals.terms.csvFile') }) }} @@ -229,13 +229,6 @@ const stopPolling = () => { } const closeDialog = () => { - if ( - (loading.value || status.value?.running) && - !confirm(t('globals.messages.inProgressConfirmClose', { action: t('globals.terms.import') })) - ) - return - stopPolling() - resetState() showDialog.value = false } diff --git a/i18n/en.json b/i18n/en.json index 6667a6dc..b21b0160 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -334,9 +334,6 @@ "globals.messages.back": "Back", "globals.messages.close": "Close", "globals.messages.import": "Import {name}", - "globals.messages.selectFile": "Select {name}", - "globals.messages.uploadFailed": "Upload failed", - "globals.messages.inProgressConfirmClose": "{action} in progress. Close?", "globals.messages.apply": "Apply {name}", "globals.messages.reset": "Reset {name}", "globals.messages.lastNItems": "Last {n} {name} | Last {n} {name}",