mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-11 13:28:57 +00:00
handle panics in importer, translate messages and fix bugs
This commit is contained in:
+31
-34
@@ -17,13 +17,13 @@ func handleImportAgents(r *fastglue.Request) error {
|
||||
|
||||
file, err := r.RequestCtx.FormFile("file")
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "No file provided", nil, envelope.InputError)
|
||||
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, "Failed to read file", nil, envelope.GeneralError)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil, envelope.GeneralError)
|
||||
}
|
||||
defer fileContent.Close()
|
||||
|
||||
@@ -32,11 +32,11 @@ func handleImportAgents(r *fastglue.Request) error {
|
||||
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)
|
||||
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, "CSV must contain headers and at least one data row", nil, envelope.InputError)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("importer.csvMustContainHeadersAndData"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
err = app.importer.Submit("agents", func() error {
|
||||
@@ -44,23 +44,19 @@ func handleImportAgents(r *fastglue.Request) error {
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusConflict, err.Error(), nil, envelope.GeneralError)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusConflict, app.i18n.T("importer.importAlreadyInProgress"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(map[string]string{
|
||||
"message": "Import started",
|
||||
})
|
||||
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 r.SendErrorEnvelope(fasthttp.StatusNotFound, err.Error(), nil, envelope.NotFoundError)
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(status)
|
||||
}
|
||||
|
||||
@@ -72,7 +68,7 @@ func processAgentImport(app *App, records [][]string) error {
|
||||
}
|
||||
|
||||
// Validate required columns
|
||||
required := []string{"first_name", "last_name", "email", "roles", "teams"}
|
||||
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)
|
||||
@@ -107,7 +103,7 @@ func processAgentImport(app *App, records [][]string) error {
|
||||
|
||||
// Process each row
|
||||
for i, record := range records[1:] {
|
||||
rowNum := i + 2
|
||||
rowNum := i + 1
|
||||
|
||||
// Parse fields
|
||||
firstName := getField(record, headerMap, "first_name")
|
||||
@@ -117,7 +113,7 @@ func processAgentImport(app *App, records [][]string) error {
|
||||
teamsStr := getField(record, headerMap, "teams")
|
||||
|
||||
// Validate required fields
|
||||
if firstName == "" || lastName == "" || email == "" || rolesStr == "" || teamsStr == "" {
|
||||
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
|
||||
@@ -145,18 +141,21 @@ func processAgentImport(app *App, records [][]string) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse and validate teams
|
||||
// Parse and validate teams (optional)
|
||||
teams := parseList(teamsStr)
|
||||
if len(teams) == 0 {
|
||||
app.importer.UpdateCounts("agents", 0, 0, 1)
|
||||
app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - at least one team required", rowNum))
|
||||
continue
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
invalidTeams := findInvalid(teams, validTeams)
|
||||
if len(invalidTeams) > 0 {
|
||||
// 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 - invalid team(s): %s", rowNum, strings.Join(invalidTeams, ", ")))
|
||||
app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - email already exists", rowNum))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -164,19 +163,17 @@ func processAgentImport(app *App, records [][]string) error {
|
||||
agent, err := app.user.CreateAgent(firstName, lastName, email, roles)
|
||||
if err != nil {
|
||||
app.importer.UpdateCounts("agents", 0, 0, 1)
|
||||
if strings.Contains(strings.ToLower(err.Error()), "email") && strings.Contains(strings.ToLower(err.Error()), "exists") {
|
||||
app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - email already exists", rowNum))
|
||||
} else {
|
||||
app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - failed to create agent", rowNum))
|
||||
}
|
||||
app.importer.AddLog("agents", fmt.Sprintf("Row %d: Error - failed to create agent", rowNum))
|
||||
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: Error - team assignment failed", 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)
|
||||
@@ -185,8 +182,8 @@ func processAgentImport(app *App, records [][]string) error {
|
||||
|
||||
// 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))
|
||||
app.importer.AddLog("agents", fmt.Sprintf("Import completed: %d of %d successful, %d failed",
|
||||
status.Success, status.Total, status.Errors))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
+3
-1
@@ -258,7 +258,7 @@ func main() {
|
||||
conversation: conversation,
|
||||
automation: automation,
|
||||
businessHours: businessHours,
|
||||
importer: importer.NewImporter(),
|
||||
importer: initImporter(i18n),
|
||||
activityLog: initActivityLog(db, i18n),
|
||||
customAttribute: initCustomAttribute(db, i18n),
|
||||
authz: initAuthz(i18n),
|
||||
@@ -320,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,267 @@
|
||||
<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.selectFile', { 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 = () => {
|
||||
if (
|
||||
(loading.value || status.value?.running) &&
|
||||
!confirm(t('globals.messages.inProgressConfirmClose', { action: t('globals.terms.import') }))
|
||||
)
|
||||
return
|
||||
stopPolling()
|
||||
resetState()
|
||||
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>
|
||||
@@ -1,297 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<Button variant="secondary" @click="showDialog = true">
|
||||
Import Agents
|
||||
</Button>
|
||||
|
||||
<Dialog v-model:open="showDialog">
|
||||
<DialogContent class="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Agents</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4 py-4">
|
||||
<!-- File Upload Section -->
|
||||
<div v-if="!importing && !complete" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Select CSV file</label>
|
||||
<div
|
||||
@click="$refs.fileInput.click()"
|
||||
class="flex 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 : 'Choose a CSV file...' }}
|
||||
</span>
|
||||
<svg
|
||||
class="h-5 w-5 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round" ``
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
@change="onFileSelect"
|
||||
ref="fileInput"
|
||||
class="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AlertTitle>Required CSV format</AlertTitle>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<p class="text-xs mb-2">Example CSV:</p>
|
||||
<div class="bg-muted p-2 rounded text-xs font-mono overflow-x-auto">
|
||||
<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">
|
||||
Roles and teams must match database values exactly (case-sensitive)
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button @click="startImport"
|
||||
:disabled="!file"
|
||||
class="w-full"
|
||||
>
|
||||
Start Import
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Progress Section -->
|
||||
<div v-if="status" class="space-y-4">
|
||||
<div v-if="importing" class="flex items-center gap-2">
|
||||
<Spinner class="h-4 w-4" />
|
||||
<span class="text-sm">Importing agents...</span>
|
||||
</div>
|
||||
<Alert
|
||||
v-if="complete"
|
||||
class="bg-green-50 dark:bg-green-950 border-green-200"
|
||||
>
|
||||
<AlertTitle class="text-green-600">Success!</AlertTitle>
|
||||
<AlertDescription class="text-green-600">
|
||||
Import completed: {{ status.success }} successful,
|
||||
{{ status.errors }} failed out of {{ status.total }} total
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-2">Import logs</p>
|
||||
<Card class="p-3">
|
||||
<div class="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="absolute top-2 right-2 h-7 w-7 text-muted-foreground hover:text-foreground z-10"
|
||||
@click="copyLogs"
|
||||
>
|
||||
<Check v-if="copied" class="h-4 w-4 text-green-500" />
|
||||
<Copy v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
<div
|
||||
class="bg-black text-white p-3 pt-8 rounded-md text-xs font-mono max-h-60 overflow-y-auto space-y-1 logs-scroll-container"
|
||||
>
|
||||
<div v-for="(log, idx) in status.logs" :key="idx">
|
||||
{{ log }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert v-if="error" variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button v-if="complete" @click="resetAndClose">
|
||||
Done
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="closeDialog"
|
||||
:disabled="importing"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter
|
||||
} from '@/components/ui/dialog'
|
||||
import axios from 'axios'
|
||||
import { Copy, Check } from "lucide-vue-next"
|
||||
|
||||
const showDialog = ref(false)
|
||||
const file = ref(null)
|
||||
const importing = ref(false)
|
||||
const complete = ref(false)
|
||||
const copied = ref(false)
|
||||
const status = ref(null)
|
||||
const error = ref('')
|
||||
const pollInterval = ref(null)
|
||||
|
||||
const emit = defineEmits(['import-complete'])
|
||||
|
||||
const getCSRFToken = () => {
|
||||
const name = 'csrf_token='
|
||||
const cookies = document.cookie.split(';')
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
let c = cookies[i].trim()
|
||||
if (c.indexOf(name) === 0) {
|
||||
return c.substring(name.length, c.length)
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const onFileSelect = (e) => {
|
||||
file.value = e.target.files[0]
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
const startImport = async () => {
|
||||
if (!file.value) return
|
||||
|
||||
error.value = ''
|
||||
importing.value = true
|
||||
|
||||
// Initialize empty status to show logs area immediately
|
||||
status.value = {
|
||||
running: true,
|
||||
logs: ['Uploading CSV file...'],
|
||||
total: 0,
|
||||
success: 0,
|
||||
errors: 0
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.value)
|
||||
|
||||
try {
|
||||
await axios.post('/api/v1/agents/import', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
'X-CSRFTOKEN': getCSRFToken()
|
||||
}
|
||||
})
|
||||
|
||||
// Update log after successful upload
|
||||
status.value.logs.push('CSV uploaded successfully, starting import...')
|
||||
startPolling()
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || err.message || 'Upload failed'
|
||||
importing.value = false
|
||||
status.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/v1/agents/import/status')
|
||||
status.value = res.data.data
|
||||
|
||||
// Auto-scroll logs to bottom
|
||||
scrollLogsToBottom()
|
||||
|
||||
if (!status.value.running) {
|
||||
stopPolling()
|
||||
importing.value = false
|
||||
complete.value = true
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.response?.status !== 404) {
|
||||
console.error('Poll error:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scrollLogsToBottom = () => {
|
||||
// Use nextTick to ensure DOM is updated before scrolling
|
||||
import('vue').then(({ nextTick }) => {
|
||||
nextTick(() => {
|
||||
const logsContainer = document.querySelector('.logs-scroll-container')
|
||||
if (logsContainer) {
|
||||
logsContainer.scrollTop = logsContainer.scrollHeight
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
pollInterval.value = setInterval(fetchStatus, 1000)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollInterval.value) {
|
||||
clearInterval(pollInterval.value)
|
||||
pollInterval.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const closeDialog = () => {
|
||||
if (importing.value && !confirm('Import in progress. Close?')) return
|
||||
stopPolling()
|
||||
resetState()
|
||||
showDialog.value = false
|
||||
}
|
||||
|
||||
const resetAndClose = () => {
|
||||
stopPolling()
|
||||
resetState()
|
||||
showDialog.value = false
|
||||
emit('import-complete')
|
||||
}
|
||||
|
||||
const resetState = () => {
|
||||
file.value = null
|
||||
importing.value = false
|
||||
complete.value = false
|
||||
status.value = null
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
})
|
||||
const copyLogs = async () => {
|
||||
if (!status.value?.logs?.length) return
|
||||
|
||||
await navigator.clipboard.writeText(
|
||||
status.value.logs.join("\n")
|
||||
)
|
||||
|
||||
copied.value = true
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 1500)
|
||||
}
|
||||
</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 () {
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
<Spinner v-if="isLoading" />
|
||||
<div :class="{ 'transition-opacity duration-300 opacity-50': isLoading }">
|
||||
<div class="flex justify-end mb-5 gap-2">
|
||||
<Importer @import-complete="getData" />
|
||||
<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', {
|
||||
@@ -28,7 +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/ui/importer/Importer.vue'
|
||||
import Importer from '@/components/importer/Importer.vue'
|
||||
import api from '@/api'
|
||||
|
||||
const isLoading = ref(false)
|
||||
const usersStore = useUsersStore()
|
||||
|
||||
@@ -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>
|
||||
|
||||
+12
-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,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)"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user