diff --git a/cmd/agent_import.go b/cmd/agent_import.go new file mode 100644 index 00000000..3d0a09df --- /dev/null +++ b/cmd/agent_import.go @@ -0,0 +1,218 @@ +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) + + file, err := r.RequestCtx.FormFile("file") + if err != nil { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.file}"), nil, envelope.InputError) + } + + fileContent, err := file.Open() + if err != nil { + app.lo.Error("error opening uploaded file", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil, envelope.GeneralError) + } + defer fileContent.Close() + + 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, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.csvFile}"), nil, envelope.InputError) + } + + if len(records) < 2 { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("importer.csvMustContainHeadersAndData"), nil, envelope.InputError) + } + + err = app.importer.Submit("agents", func() error { + return processAgentImport(app, records) + }) + + if err != nil { + return r.SendErrorEnvelope(fasthttp.StatusConflict, app.i18n.T("importer.importAlreadyInProgress"), nil, envelope.GeneralError) + } + + return r.SendEnvelope(true) +} + +// 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 sendErrorEnvelope(r, err) + } + return r.SendEnvelope(status) +} + +func processAgentImport(app *App, records [][]string) error { + // Parse headers + headerMap := make(map[string]int) + for i, h := range records[0] { + headerMap[strings.TrimSpace(strings.ToLower(h))] = i + } + + // Validate required columns + required := []string{"first_name", "last_name", "email", "roles"} + for _, col := range required { + if _, ok := headerMap[col]; !ok { + return fmt.Errorf("missing required column: %s", col) + } + } + + // Fetch valid teams and roles once + allTeams, err := app.team.GetAll() + if err != nil { + return fmt.Errorf("failed to fetch teams: %v", err) + } + + allRoles, err := app.role.GetAll() + if err != nil { + return fmt.Errorf("failed to fetch roles: %v", err) + } + + validTeams := make(map[string]bool) + for _, t := range allTeams { + validTeams[t.Name] = true + } + + validRoles := make(map[string]bool) + for _, r := range allRoles { + validRoles[r.Name] = true + } + + // Initialize import + 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 + 1 + + // 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 == "" { + app.importer.UpdateCounts("agents", 0, 0, 1) + app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - missing required fields", rowNum)) + continue + } + + // Validate email format + if !stringutil.ValidEmail(email) { + app.importer.UpdateCounts("agents", 0, 0, 1) + app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - invalid email format", rowNum)) + continue + } + + // Parse and validate roles + roles := parseList(rolesStr) + if len(roles) == 0 { + app.importer.UpdateCounts("agents", 0, 0, 1) + app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - at least one role required", rowNum)) + continue + } + + invalidRoles := findInvalid(roles, validRoles) + if len(invalidRoles) > 0 { + app.importer.UpdateCounts("agents", 0, 0, 1) + app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - invalid role(s): %s", rowNum, strings.Join(invalidRoles, ", "))) + continue + } + + // Parse and validate teams (optional) + teams := parseList(teamsStr) + if len(teams) > 0 { + invalidTeams := findInvalid(teams, validTeams) + if len(invalidTeams) > 0 { + app.importer.UpdateCounts("agents", 0, 0, 1) + app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - invalid team(s): %s", rowNum, strings.Join(invalidTeams, ", "))) + continue + } + } + + // Check if agent already exists + if _, err := app.user.GetAgent(0, email); err == nil { + app.importer.UpdateCounts("agents", 0, 0, 1) + app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - email already exists", 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: Error - failed to create agent", rowNum)) + continue + } + + // Assign teams (if provided) + if len(teams) > 0 { + 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: Error - team assignment failed", rowNum)) + continue + } + } + + app.importer.UpdateCounts("agents", 0, 1, 0) + app.importer.AddLog("agents", fmt.Sprintf("Row %d: Created agent %s (%s)", rowNum, agent.FullName(), agent.Email.String)) + } + + // Final summary + status, _ := app.importer.GetStatus("agents") + app.importer.AddLog("agents", fmt.Sprintf("Import completed: %d of %d successful, %d failed", + status.Success, status.Total, status.Errors)) + + return nil +} + +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 "" +} + +func parseList(s string) []string { + s = strings.ReplaceAll(s, ";", ",") + parts := strings.Split(s, ",") + var result []string + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +func findInvalid(items []string, validMap map[string]bool) []string { + var invalid []string + for _, item := range items { + if !validMap[item] { + invalid = append(invalid, item) + } + } + return invalid +} diff --git a/cmd/handlers.go b/cmd/handlers.go index 59107802..d6008da4 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -126,6 +126,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)) diff --git a/cmd/init.go b/cmd/init.go index 1859b4b6..ba214c82 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -26,6 +26,7 @@ import ( "github.com/abhinavxd/libredesk/internal/conversation/status" "github.com/abhinavxd/libredesk/internal/csat" customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute" + "github.com/abhinavxd/libredesk/internal/importer" "github.com/abhinavxd/libredesk/internal/inbox" "github.com/abhinavxd/libredesk/internal/inbox/channel/email" imodels "github.com/abhinavxd/libredesk/internal/inbox/models" @@ -954,6 +955,14 @@ func initUserNotification(db *sqlx.DB, i18n *i18n.I18n) *notifier.UserNotificati return m } +// initImporter inits the importer manager. +func initImporter(i18n *i18n.I18n) *importer.Importer { + return importer.New(importer.Opts{ + Lo: initLogger("importer"), + I18n: i18n, + }) +} + // initNotifDispatcher initializes the notification dispatcher. func initNotifDispatcher(userNotification *notifier.UserNotificationManager, outbound *notifier.Service, wsHub *ws.Hub) *notifier.Dispatcher { return notifier.NewDispatcher(notifier.DispatcherOpts{ diff --git a/cmd/main.go b/cmd/main.go index 8e2267d5..53c083bb 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -34,6 +34,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" @@ -102,6 +103,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 @@ -256,6 +258,7 @@ func main() { conversation: conversation, automation: automation, businessHours: businessHours, + importer: initImporter(i18n), activityLog: initActivityLog(db, i18n), customAttribute: initCustomAttribute(db, i18n), authz: initAuthz(i18n), @@ -317,6 +320,8 @@ func main() { conversation.Close() colorlog.Red("Shutting down SLA...") sla.Close() + colorlog.Red("Shutting down importer...") + app.importer.Close() colorlog.Red("Shutting down database...") db.Close() colorlog.Red("Shutting down redis...") diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index c83f98ab..6a078f32 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -251,6 +251,13 @@ const setPassword = (data) => http.post('/api/v1/agents/set-password', data, { } }) const deleteUser = (id) => http.delete(`/api/v1/agents/${id}`) +const importAgents = (data) => + http.post('/api/v1/agents/import', data, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) +const getAgentImportStatus = () => http.get('/api/v1/agents/import/status') const createUser = (data) => http.post('/api/v1/agents', data, { headers: { @@ -483,6 +490,8 @@ const deleteAllNotifications = () => http.delete('/api/v1/notifications') export default { login, deleteUser, + importAgents, + getAgentImportStatus, resetPassword, setPassword, getTags, diff --git a/frontend/src/components/button/CopyButton.vue b/frontend/src/components/button/CopyButton.vue new file mode 100644 index 00000000..d9219d2a --- /dev/null +++ b/frontend/src/components/button/CopyButton.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/importer/Importer.vue b/frontend/src/components/importer/Importer.vue new file mode 100644 index 00000000..d4ecc0b1 --- /dev/null +++ b/frontend/src/components/importer/Importer.vue @@ -0,0 +1,260 @@ + + + diff --git a/frontend/src/features/admin/oidc/dataTableColumns.js b/frontend/src/features/admin/oidc/dataTableColumns.js index f0436796..e4ce082a 100644 --- a/frontend/src/features/admin/oidc/dataTableColumns.js +++ b/frontend/src/features/admin/oidc/dataTableColumns.js @@ -29,6 +29,15 @@ export const createColumns = (t) => [ return h('div', { class: 'text-center' }, enabled ? t('globals.messages.yes') : t('globals.messages.no')) } }, + { + accessorKey: 'created_at', + header: function () { + return h('div', { class: 'text-center' }, t('globals.terms.createdAt')) + }, + cell: function ({ row }) { + return h('div', { class: 'text-center' }, format(row.getValue('created_at'), 'PPpp')) + } + }, { accessorKey: 'updated_at', header: function () { diff --git a/frontend/src/views/admin/agents/AgentList.vue b/frontend/src/views/admin/agents/AgentList.vue index 8ed790a9..aa9991c6 100644 --- a/frontend/src/views/admin/agents/AgentList.vue +++ b/frontend/src/views/admin/agents/AgentList.vue @@ -1,7 +1,13 @@ diff --git a/i18n/en.json b/i18n/en.json index 4b3d9eca..b21b0160 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,7 @@ "globals.messages.upload": "Upload", "globals.messages.back": "Back", "globals.messages.close": "Close", + "globals.messages.import": "Import {name}", "globals.messages.apply": "Apply {name}", "globals.messages.reset": "Reset {name}", "globals.messages.lastNItems": "Last {n} {name} | Last {n} {name}", @@ -720,5 +723,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 new file mode 100644 index 00000000..6d5a9147 --- /dev/null +++ b/internal/importer/importer.go @@ -0,0 +1,178 @@ +package importer + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/knadh/go-i18n" + "github.com/zerodha/logf" +) + +// Job represents the status of an import job. +type Job 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"` +} + +// Importer manages background import jobs. +type Importer struct { + lo *logf.Logger + i18n *i18n.I18n + jobs map[string]*Job + mu sync.RWMutex + wg sync.WaitGroup + ctx context.Context + cancel context.CancelFunc +} + +// 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{ + 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 envelope.NewError(envelope.ConflictError, + i.i18n.T("importer.importAlreadyInProgress"), nil) + } + + status := &Job{ + Running: true, + Logs: []string{}, + StartedAt: time.Now(), + } + 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 +} + +// 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, envelope.NewError(envelope.NotFoundError, + i.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.import}"), nil) + } + + return status, nil +} + +// 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 + } + } +} + +// 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() + } + } +}