mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-22 10:33:24 +00:00
Merge pull request #214 from csr4422/feat/bulk-agent-import
feat: add bulk agent import via CSV
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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...")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 text-zinc-400 hover:text-white hover:bg-zinc-700"
|
||||
@click="copy"
|
||||
>
|
||||
<Check v-if="copied" class="h-4 w-4 text-green-500" />
|
||||
<Copy v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Copy, Check } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
text: { type: String, required: true }
|
||||
})
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
const copy = async () => {
|
||||
await navigator.clipboard.writeText(props.text)
|
||||
copied.value = true
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 1500)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<div>
|
||||
<Button variant="secondary" @click="openDialog">
|
||||
{{ $t('globals.terms.import') }}
|
||||
</Button>
|
||||
|
||||
<Dialog v-model:open="showDialog">
|
||||
<DialogContent class="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{
|
||||
$t('globals.messages.import', {
|
||||
name: $t(entityKey, 2).toLowerCase()
|
||||
})
|
||||
}}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4 py-4">
|
||||
<div v-if="!loading && !status" class="space-y-4">
|
||||
<div
|
||||
@click="$refs.fileInput.click()"
|
||||
class="flex items-center h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<span class="flex-1 truncate" :class="!file && 'text-muted-foreground'">
|
||||
{{
|
||||
file
|
||||
? file.name
|
||||
: $t('globals.messages.select', { name: $t('globals.terms.csvFile') })
|
||||
}}
|
||||
</span>
|
||||
<Upload class="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
@change="onFileSelect"
|
||||
ref="fileInput"
|
||||
class="hidden"
|
||||
/>
|
||||
|
||||
<Alert>
|
||||
<AlertTitle>{{ $t('importer.requiredCSVFormat') }}</AlertTitle>
|
||||
<AlertDescription class="mt-2">
|
||||
<div class="bg-muted p-3 rounded text-xs font-mono overflow-x-auto leading-relaxed">
|
||||
<div>first_name,last_name,email,roles,teams</div>
|
||||
<div>John,Doe,john@example.com,Agent,Sales</div>
|
||||
<div>Jane,Smith,jane@example.com,Admin,Support</div>
|
||||
<div>Bob,Test,bob@example.com,"Agent,Admin",Support</div>
|
||||
</div>
|
||||
<p class="text-xs mt-2 text-muted-foreground">
|
||||
{{ $t('importer.caseSensitiveNote') }}
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<!-- Loading spinner -->
|
||||
<div v-if="loading" class="flex justify-center py-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<!-- Logs -->
|
||||
<div v-if="status?.logs?.some((l) => l && l.trim())" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium">{{ $t('globals.terms.log', 2) }}</p>
|
||||
<Card class="p-0 overflow-hidden">
|
||||
<div class="relative">
|
||||
<CopyButton :text="status.logs.join('\n')" class="absolute top-2 right-2 z-10" />
|
||||
<div
|
||||
class="bg-black text-white p-4 text-xs font-mono min-h-24 max-h-60 overflow-y-auto space-y-1 logs-scroll-container"
|
||||
>
|
||||
<div v-for="(log, idx) in status.logs.filter((l) => l && l.trim())" :key="idx">
|
||||
{{ log }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert v-if="error" variant="destructive">
|
||||
<AlertTitle>{{ $t('globals.terms.error') }}</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button v-if="complete" @click="resetAndClose">
|
||||
{{ $t('globals.messages.close') }}
|
||||
</Button>
|
||||
<template v-else>
|
||||
<Button variant="outline" @click="closeDialog" :disabled="loading || status?.running">
|
||||
{{ $t('globals.messages.cancel') }}
|
||||
</Button>
|
||||
<Button @click="startImport" :disabled="!file || loading || status?.running">
|
||||
{{ $t('globals.terms.import') }}
|
||||
</Button>
|
||||
</template>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter
|
||||
} from '@/components/ui/dialog'
|
||||
import { Upload } from 'lucide-vue-next'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import CopyButton from '@/components/button/CopyButton.vue'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emitter = useEmitter()
|
||||
|
||||
const props = defineProps({
|
||||
entityKey: { type: String, required: true },
|
||||
uploadFn: { type: Function, required: true },
|
||||
getStatusFn: { type: Function, required: true }
|
||||
})
|
||||
|
||||
const showDialog = ref(false)
|
||||
const file = ref(null)
|
||||
const loading = ref(false)
|
||||
const status = ref(null)
|
||||
const error = ref('')
|
||||
const pollInterval = ref(null)
|
||||
|
||||
const complete = computed(() => status.value && !status.value.running)
|
||||
|
||||
const emit = defineEmits(['import-complete'])
|
||||
|
||||
const onFileSelect = (e) => {
|
||||
file.value = e.target.files[0]
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
const openDialog = async () => {
|
||||
showDialog.value = true
|
||||
loading.value = true
|
||||
|
||||
// Check if import already running
|
||||
try {
|
||||
const res = await props.getStatusFn()
|
||||
if (res.data.data?.running) {
|
||||
startPolling(res.data.data)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// no existing import
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const startImport = async () => {
|
||||
if (!file.value) return
|
||||
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.value)
|
||||
try {
|
||||
await props.uploadFn(formData)
|
||||
startPolling()
|
||||
} catch (err) {
|
||||
error.value = handleHTTPError(err).message
|
||||
loading.value = false
|
||||
status.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await props.getStatusFn()
|
||||
status.value = res.data.data
|
||||
|
||||
// Auto-scroll logs to bottom
|
||||
scrollLogsToBottom()
|
||||
|
||||
if (!status.value.running) {
|
||||
stopPolling()
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.response?.status !== 404) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(err).message
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scrollLogsToBottom = () => {
|
||||
nextTick(() => {
|
||||
const logsContainer = document.querySelector('.logs-scroll-container')
|
||||
if (logsContainer) {
|
||||
logsContainer.scrollTop = logsContainer.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const startPolling = async (initialStatus = null) => {
|
||||
if (initialStatus) {
|
||||
status.value = initialStatus
|
||||
} else {
|
||||
await fetchStatus()
|
||||
}
|
||||
loading.value = false
|
||||
pollInterval.value = setInterval(fetchStatus, 1000)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollInterval.value) {
|
||||
clearInterval(pollInterval.value)
|
||||
pollInterval.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
showDialog.value = false
|
||||
}
|
||||
|
||||
const resetAndClose = () => {
|
||||
stopPolling()
|
||||
resetState()
|
||||
showDialog.value = false
|
||||
emit('import-complete')
|
||||
}
|
||||
|
||||
const resetState = () => {
|
||||
file.value = null
|
||||
loading.value = false
|
||||
status.value = null
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
})
|
||||
|
||||
// Reset state when dialog is closed
|
||||
watch(showDialog, (open) => {
|
||||
if (!open) {
|
||||
stopPolling()
|
||||
resetState()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -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 () {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<template>
|
||||
<Spinner v-if="isLoading" />
|
||||
<div :class="{ 'transition-opacity duration-300 opacity-50': isLoading }">
|
||||
<div class="flex justify-end mb-5">
|
||||
<div class="flex justify-end mb-5 gap-2">
|
||||
<Importer
|
||||
entity-key="globals.terms.agent"
|
||||
:upload-fn="api.importAgents"
|
||||
:get-status-fn="api.getAgentImportStatus"
|
||||
@import-complete="getData"
|
||||
/>
|
||||
<router-link :to="{ name: 'new-agent' }">
|
||||
<Button>{{
|
||||
$t('globals.messages.new', {
|
||||
@@ -27,6 +33,8 @@ import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Importer from '@/components/importer/Importer.vue'
|
||||
import api from '@/api'
|
||||
|
||||
const isLoading = ref(false)
|
||||
const usersStore = useUsersStore()
|
||||
@@ -59,4 +67,4 @@ const getData = async () => {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
@@ -5,7 +5,7 @@
|
||||
</template>
|
||||
|
||||
<template #help>
|
||||
<p>Manage your support agents, their roles and permissions and teams.</p>
|
||||
<p>Manage support agents, roles, permissions and teams.</p>
|
||||
</template>
|
||||
</AdminPageWithHelp>
|
||||
</template>
|
||||
|
||||
+9
-1
@@ -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)"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user