Merge pull request #214 from csr4422/feat/bulk-agent-import

feat: add bulk agent import via CSV
This commit is contained in:
Abhinav Raut
2026-01-09 03:34:39 +05:30
committed by GitHub
12 changed files with 741 additions and 4 deletions
+218
View File
@@ -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
}
+2
View File
@@ -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))
+9
View File
@@ -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{
+5
View File
@@ -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...")
+9
View File
@@ -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 () {
+10 -2
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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)"
}
+178
View File
@@ -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()
}
}
}