Files

1245 lines
42 KiB
C#

using Lagerbestand.Data;
using Lagerbestand.Models;
using Microsoft.EntityFrameworkCore;
namespace Lagerbestand.Services;
public class KanbanService : IKanbanService
{
private readonly AppDbContext _context;
private readonly ILogger<KanbanService> _logger;
private readonly IFileStorageService _fileStorageService;
public KanbanService(AppDbContext context, ILogger<KanbanService> logger, IFileStorageService fileStorageService)
{
_context = context;
_logger = logger;
_fileStorageService = fileStorageService;
}
#region Columns
public async Task<List<KanbanColumn>> GetColumnsAsync()
{
return await _context.KanbanColumns
.Where(c => c.IsActive)
.OrderBy(c => c.SortOrder)
.ToListAsync();
}
public async Task<KanbanColumn?> GetColumnByIdAsync(Guid id)
{
return await _context.KanbanColumns.FindAsync(id);
}
public async Task<KanbanColumn> CreateColumnAsync(KanbanColumn column)
{
column.Id = Guid.NewGuid();
column.CreatedAt = DateTime.UtcNow;
column.UpdatedAt = DateTime.UtcNow;
var maxOrder = await _context.KanbanColumns.MaxAsync(c => (int?)c.SortOrder) ?? -1;
column.SortOrder = maxOrder + 1;
_context.KanbanColumns.Add(column);
await _context.SaveChangesAsync();
return column;
}
public async Task<KanbanColumn> UpdateColumnAsync(KanbanColumn column)
{
var existing = await _context.KanbanColumns.FindAsync(column.Id)
?? throw new InvalidOperationException("Spalte nicht gefunden.");
existing.Name = column.Name;
existing.Color = column.Color;
existing.Icon = column.Icon;
existing.IsDefault = column.IsDefault;
existing.IsCompletedColumn = column.IsCompletedColumn;
existing.UpdatedAt = DateTime.UtcNow;
// If this is set as default, unset others
if (column.IsDefault)
{
await _context.KanbanColumns
.Where(c => c.Id != column.Id && c.IsDefault)
.ExecuteUpdateAsync(s => s.SetProperty(c => c.IsDefault, false));
}
await _context.SaveChangesAsync();
return existing;
}
public async Task DeleteColumnAsync(Guid id)
{
var column = await _context.KanbanColumns.FindAsync(id)
?? throw new InvalidOperationException("Spalte nicht gefunden.");
// Check if there are tasks in this column
var hasActiveTasks = await _context.KanbanTasks
.AnyAsync(t => t.ColumnId == id && !t.IsArchived);
if (hasActiveTasks)
{
throw new InvalidOperationException("Spalte enthält noch aktive Aufgaben.");
}
column.IsActive = false;
column.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
public async Task ReorderColumnsAsync(List<Guid> columnIds)
{
for (int i = 0; i < columnIds.Count; i++)
{
await _context.KanbanColumns
.Where(c => c.Id == columnIds[i])
.ExecuteUpdateAsync(s => s.SetProperty(c => c.SortOrder, i));
}
}
#endregion
#region Labels
public async Task<List<KanbanLabel>> GetLabelsAsync()
{
return await _context.KanbanLabels
.Where(l => l.IsActive)
.OrderBy(l => l.SortOrder)
.ToListAsync();
}
public async Task<KanbanLabel?> GetLabelByIdAsync(Guid id)
{
return await _context.KanbanLabels.FindAsync(id);
}
public async Task<KanbanLabel> CreateLabelAsync(KanbanLabel label)
{
label.Id = Guid.NewGuid();
label.CreatedAt = DateTime.UtcNow;
var maxOrder = await _context.KanbanLabels.MaxAsync(l => (int?)l.SortOrder) ?? -1;
label.SortOrder = maxOrder + 1;
_context.KanbanLabels.Add(label);
await _context.SaveChangesAsync();
return label;
}
public async Task<KanbanLabel> UpdateLabelAsync(KanbanLabel label)
{
var existing = await _context.KanbanLabels.FindAsync(label.Id)
?? throw new InvalidOperationException("Label nicht gefunden.");
existing.Name = label.Name;
existing.Color = label.Color;
existing.Description = label.Description;
await _context.SaveChangesAsync();
return existing;
}
public async Task DeleteLabelAsync(Guid id)
{
var label = await _context.KanbanLabels.FindAsync(id)
?? throw new InvalidOperationException("Label nicht gefunden.");
label.IsActive = false;
await _context.SaveChangesAsync();
}
#endregion
#region Tasks
public async Task<List<KanbanTask>> GetTasksAsync(bool includeArchived = false)
{
var query = _context.KanbanTasks
.AsSplitQuery() // Prevents cartesian explosion with multiple includes
.Include(t => t.Column)
.Include(t => t.TaskLabels).ThenInclude(tl => tl.Label)
.Include(t => t.Assignees).ThenInclude(a => a.User)
.Include(t => t.TaskCustomers).ThenInclude(tc => tc.Customer)
.Include(t => t.Todos)
.AsQueryable();
if (!includeArchived)
{
query = query.Where(t => !t.IsArchived);
}
return await query
.OrderBy(t => t.Column!.SortOrder)
.ThenBy(t => t.SortOrder)
.ToListAsync();
}
public async Task<List<KanbanTask>> GetTasksByColumnAsync(Guid columnId)
{
return await _context.KanbanTasks
.AsSplitQuery()
.Include(t => t.TaskLabels).ThenInclude(tl => tl.Label)
.Include(t => t.Assignees).ThenInclude(a => a.User)
.Include(t => t.TaskCustomers).ThenInclude(tc => tc.Customer)
.Include(t => t.Todos)
.Where(t => t.ColumnId == columnId && !t.IsArchived)
.OrderBy(t => t.SortOrder)
.ToListAsync();
}
public async Task<KanbanTask?> GetTaskByIdAsync(Guid id)
{
return await _context.KanbanTasks
.AsSplitQuery() // Prevents cartesian explosion with multiple includes
.Include(t => t.Column)
.Include(t => t.CreatedByUser)
.Include(t => t.TaskLabels).ThenInclude(tl => tl.Label)
.Include(t => t.Assignees).ThenInclude(a => a.User)
.Include(t => t.TaskCustomers).ThenInclude(tc => tc.Customer)
.ThenInclude(c => c.Vehicles)
.Include(t => t.TaskCustomers).ThenInclude(tc => tc.Vehicle)
.Include(t => t.TaskTireStacks).ThenInclude(ts => ts.TireStack)
.ThenInclude(s => s!.Vehicle)
.Include(t => t.Todos.OrderBy(todo => todo.SortOrder))
.Include(t => t.Comments.OrderByDescending(c => c.CreatedAt))
.ThenInclude(c => c.User)
.Include(t => t.Notes.OrderBy(n => n.SortOrder))
.Include(t => t.Attachments.OrderByDescending(a => a.CreatedAt))
.ThenInclude(a => a.UploadedByUser)
.FirstOrDefaultAsync(t => t.Id == id);
}
public async Task<KanbanTask> CreateTaskAsync(KanbanTask task, Guid? userId = null)
{
task.Id = Guid.NewGuid();
task.CreatedAt = DateTime.UtcNow;
task.UpdatedAt = DateTime.UtcNow;
task.CreatedByUserId = userId;
// If no column specified, use default
if (task.ColumnId == Guid.Empty)
{
var defaultColumn = await _context.KanbanColumns
.FirstOrDefaultAsync(c => c.IsDefault && c.IsActive)
?? await _context.KanbanColumns.FirstOrDefaultAsync(c => c.IsActive);
if (defaultColumn == null)
throw new InvalidOperationException("Keine Kanban-Spalte vorhanden.");
task.ColumnId = defaultColumn.Id;
}
// Set sort order to end of column
var maxOrder = await _context.KanbanTasks
.Where(t => t.ColumnId == task.ColumnId && !t.IsArchived)
.MaxAsync(t => (int?)t.SortOrder) ?? -1;
task.SortOrder = maxOrder + 1;
_context.KanbanTasks.Add(task);
await _context.SaveChangesAsync();
// Add activity
await AddActivityAsync(task.Id, "Aufgabe erstellt", userId);
return task;
}
public async Task<KanbanTask> CreateTaskFromTemplateAsync(Guid templateId, string? title = null, Guid? customerId = null, Guid? userId = null)
{
var template = await _context.KanbanTaskTemplates
.Include(t => t.Todos)
.Include(t => t.TemplateLabels)
.FirstOrDefaultAsync(t => t.Id == templateId && t.IsActive)
?? throw new InvalidOperationException("Vorlage nicht gefunden.");
// Load customer for placeholder resolution
Customer? customer = null;
if (customerId.HasValue)
{
customer = await _context.Customers
.Include(c => c.Vehicles.Where(v => v.IsActive))
.FirstOrDefaultAsync(c => c.Id == customerId.Value);
}
var resolvedTitle = string.IsNullOrEmpty(title) ? template.TitlePattern : title;
var resolvedDescription = template.DefaultDescription;
if (customer != null)
{
resolvedTitle = ResolvePlaceholders(resolvedTitle, customer);
resolvedDescription = ResolvePlaceholders(resolvedDescription, customer);
}
var task = new KanbanTask
{
Title = resolvedTitle,
Description = resolvedDescription,
ColumnId = template.DefaultColumnId ?? Guid.Empty,
Priority = template.DefaultPriority,
EstimatedMinutes = template.DefaultEstimatedMinutes
};
var createdTask = await CreateTaskAsync(task, userId);
// Add todos from template (with placeholder resolution)
foreach (var templateTodo in template.Todos.OrderBy(t => t.SortOrder))
{
var todoText = customer != null ? ResolvePlaceholders(templateTodo.Text, customer) : templateTodo.Text;
await AddTodoAsync(createdTask.Id, todoText);
}
// Add labels from template
var labelIds = template.TemplateLabels.Select(tl => tl.LabelId).ToList();
if (labelIds.Any())
{
await SetTaskLabelsAsync(createdTask.Id, labelIds);
}
// Add customer if provided
if (customerId.HasValue)
{
await AddCustomerToTaskAsync(createdTask.Id, customerId.Value);
}
return await GetTaskByIdAsync(createdTask.Id) ?? createdTask;
}
private static string ResolvePlaceholders(string? text, Customer customer)
{
if (string.IsNullOrEmpty(text)) return text ?? string.Empty;
var vehicle = customer.Vehicles
.OrderByDescending(v => v.IsPrimary)
.ThenBy(v => v.CreatedAt)
.FirstOrDefault();
var result = text;
result = result.Replace("{Kunde}", customer.FullName);
result = result.Replace("{Vorname}", customer.FirstName ?? "");
result = result.Replace("{Nachname}", customer.LastName);
result = result.Replace("{Firma}", customer.Company ?? "");
result = result.Replace("{Kundennummer}", customer.CustomerNumber);
result = result.Replace("{Telefon}", customer.Phone ?? "");
result = result.Replace("{Email}", customer.Email ?? "");
result = result.Replace("{Fahrzeug}", vehicle?.DisplayName ?? "");
result = result.Replace("{Reifengröße}", vehicle?.TireSize ?? "");
result = result.Replace("{Marke}", vehicle?.Make ?? "");
result = result.Replace("{Modell}", vehicle?.Model ?? "");
result = result.Replace("{Datum}", DateTime.Now.ToString("dd.MM.yyyy"));
result = result.Replace("{Jahr}", DateTime.Now.Year.ToString());
return result;
}
public async Task<KanbanTask> UpdateTaskAsync(KanbanTask task, Guid? userId = null)
{
var existing = await _context.KanbanTasks.FindAsync(task.Id)
?? throw new InvalidOperationException("Aufgabe nicht gefunden.");
var changes = new List<string>();
if (existing.Title != task.Title)
changes.Add($"Titel geändert zu '{task.Title}'");
if (existing.Priority != task.Priority)
changes.Add($"Priorität geändert zu {task.PriorityText}");
if (existing.DueDate != task.DueDate)
changes.Add(task.DueDate.HasValue
? $"Fälligkeitsdatum gesetzt auf {task.DueDate.Value:dd.MM.yyyy}"
: "Fälligkeitsdatum entfernt");
existing.Title = task.Title;
existing.Description = task.Description;
existing.DueDate = task.DueDate;
existing.Priority = task.Priority;
existing.EstimatedMinutes = task.EstimatedMinutes;
existing.TireSizeNote = task.TireSizeNote;
existing.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
// Log activity
if (changes.Any())
{
await AddActivityAsync(task.Id, string.Join(", ", changes), userId);
}
return existing;
}
public async Task MoveTaskAsync(Guid taskId, Guid newColumnId, int newSortOrder, Guid? userId = null)
{
var task = await _context.KanbanTasks
.Include(t => t.Column)
.FirstOrDefaultAsync(t => t.Id == taskId)
?? throw new InvalidOperationException("Aufgabe nicht gefunden.");
var newColumn = await _context.KanbanColumns.FindAsync(newColumnId)
?? throw new InvalidOperationException("Spalte nicht gefunden.");
var oldColumnName = task.Column?.Name ?? "Unbekannt";
var columnChanged = task.ColumnId != newColumnId;
task.ColumnId = newColumnId;
task.SortOrder = newSortOrder;
task.UpdatedAt = DateTime.UtcNow;
// Track completion
if (newColumn.IsCompletedColumn && !task.CompletedAt.HasValue)
{
task.CompletedAt = DateTime.UtcNow;
}
else if (!newColumn.IsCompletedColumn && task.CompletedAt.HasValue)
{
task.CompletedAt = null;
}
// Reorder other tasks in the new column
var tasksInColumn = await _context.KanbanTasks
.Where(t => t.ColumnId == newColumnId && t.Id != taskId && !t.IsArchived)
.OrderBy(t => t.SortOrder)
.ToListAsync();
int order = 0;
foreach (var t in tasksInColumn)
{
if (order == newSortOrder) order++;
t.SortOrder = order++;
}
await _context.SaveChangesAsync();
if (columnChanged)
{
await AddActivityAsync(taskId, $"Verschoben von '{oldColumnName}' nach '{newColumn.Name}'", userId);
}
}
public async Task DeleteTaskAsync(Guid id)
{
var task = await _context.KanbanTasks.FindAsync(id)
?? throw new InvalidOperationException("Aufgabe nicht gefunden.");
task.IsArchived = true;
task.ArchivedAt = DateTime.UtcNow;
task.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
public async Task ArchiveTaskAsync(Guid id)
{
var task = await _context.KanbanTasks.FindAsync(id)
?? throw new InvalidOperationException("Aufgabe nicht gefunden.");
task.IsArchived = true;
task.ArchivedAt = DateTime.UtcNow;
task.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
public async Task UnarchiveTaskAsync(Guid id)
{
var task = await _context.KanbanTasks
.Include(t => t.Column)
.FirstOrDefaultAsync(t => t.Id == id)
?? throw new InvalidOperationException("Aufgabe nicht gefunden.");
task.IsArchived = false;
task.ArchivedAt = null;
task.UpdatedAt = DateTime.UtcNow;
// If the task was in a completed column, keep it there; otherwise move to default
if (task.Column == null || !task.Column.IsActive)
{
var defaultColumn = await _context.KanbanColumns
.FirstOrDefaultAsync(c => c.IsDefault && c.IsActive)
?? await _context.KanbanColumns.FirstOrDefaultAsync(c => c.IsActive);
if (defaultColumn != null)
task.ColumnId = defaultColumn.Id;
}
await _context.SaveChangesAsync();
await AddActivityAsync(id, "Aufgabe wiederhergestellt", null);
}
public async Task<List<KanbanTask>> GetRecentlyArchivedTasksAsync(int days = 7)
{
var cutoffDate = DateTime.UtcNow.AddDays(-days);
return await _context.KanbanTasks
.AsSplitQuery()
.Include(t => t.Column)
.Include(t => t.TaskLabels).ThenInclude(tl => tl.Label)
.Include(t => t.Assignees).ThenInclude(a => a.User)
.Include(t => t.TaskCustomers).ThenInclude(tc => tc.Customer)
.Include(t => t.Todos)
.Where(t => t.IsArchived && t.ArchivedAt.HasValue && t.ArchivedAt.Value >= cutoffDate)
.OrderByDescending(t => t.ArchivedAt)
.ToListAsync();
}
public async Task ArchiveOldCompletedTasksAsync(int daysAfterCompletion = 30)
{
var cutoffDate = DateTime.UtcNow.AddDays(-daysAfterCompletion);
await _context.KanbanTasks
.Where(t => t.CompletedAt.HasValue
&& t.CompletedAt.Value < cutoffDate
&& !t.IsArchived)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.IsArchived, true)
.SetProperty(t => t.ArchivedAt, DateTime.UtcNow)
.SetProperty(t => t.UpdatedAt, DateTime.UtcNow));
_logger.LogInformation("Alte erledigte Aufgaben archiviert (älter als {Days} Tage).", daysAfterCompletion);
}
#endregion
#region Task Todos
public async Task<KanbanTaskTodo> AddTodoAsync(Guid taskId, string text)
{
var maxOrder = await _context.KanbanTaskTodos
.Where(t => t.TaskId == taskId)
.MaxAsync(t => (int?)t.SortOrder) ?? -1;
var todo = new KanbanTaskTodo
{
Id = Guid.NewGuid(),
TaskId = taskId,
Text = text,
SortOrder = maxOrder + 1,
CreatedAt = DateTime.UtcNow
};
_context.KanbanTaskTodos.Add(todo);
await _context.SaveChangesAsync();
return todo;
}
public async Task<KanbanTaskTodo> UpdateTodoAsync(KanbanTaskTodo todo)
{
var existing = await _context.KanbanTaskTodos.FindAsync(todo.Id)
?? throw new InvalidOperationException("Todo nicht gefunden.");
existing.Text = todo.Text;
await _context.SaveChangesAsync();
return existing;
}
public async Task ToggleTodoAsync(Guid todoId)
{
var todo = await _context.KanbanTaskTodos.FindAsync(todoId)
?? throw new InvalidOperationException("Todo nicht gefunden.");
todo.IsCompleted = !todo.IsCompleted;
todo.CompletedAt = todo.IsCompleted ? DateTime.UtcNow : null;
await _context.SaveChangesAsync();
}
public async Task DeleteTodoAsync(Guid todoId)
{
var todo = await _context.KanbanTaskTodos.FindAsync(todoId);
if (todo != null)
{
_context.KanbanTaskTodos.Remove(todo);
await _context.SaveChangesAsync();
}
}
public async Task ReorderTodosAsync(Guid taskId, List<Guid> todoIds)
{
for (int i = 0; i < todoIds.Count; i++)
{
await _context.KanbanTaskTodos
.Where(t => t.Id == todoIds[i] && t.TaskId == taskId)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.SortOrder, i));
}
}
#endregion
#region Comments/Chat
public async Task<List<KanbanTaskComment>> GetCommentsAsync(Guid taskId)
{
return await _context.KanbanTaskComments
.Include(c => c.User)
.Where(c => c.TaskId == taskId)
.OrderByDescending(c => c.CreatedAt)
.ToListAsync();
}
public async Task<KanbanTaskComment> AddCommentAsync(Guid taskId, string content, Guid? userId)
{
var comment = new KanbanTaskComment
{
Id = Guid.NewGuid(),
TaskId = taskId,
UserId = userId,
Content = content,
CommentType = KanbanCommentType.Chat,
CreatedAt = DateTime.UtcNow
};
_context.KanbanTaskComments.Add(comment);
await _context.SaveChangesAsync();
return await _context.KanbanTaskComments
.Include(c => c.User)
.FirstAsync(c => c.Id == comment.Id);
}
public async Task AddActivityAsync(Guid taskId, string activity, Guid? userId = null)
{
var comment = new KanbanTaskComment
{
Id = Guid.NewGuid(),
TaskId = taskId,
UserId = userId,
Content = activity,
CommentType = KanbanCommentType.Activity,
CreatedAt = DateTime.UtcNow
};
_context.KanbanTaskComments.Add(comment);
await _context.SaveChangesAsync();
}
#endregion
#region Notes
public async Task<List<KanbanTaskNote>> GetNotesAsync(Guid taskId)
{
return await _context.KanbanTaskNotes
.Include(n => n.LastEditedByUser)
.Where(n => n.TaskId == taskId)
.OrderBy(n => n.SortOrder)
.ToListAsync();
}
public async Task<KanbanTaskNote> AddNoteAsync(Guid taskId, string title, string content)
{
var maxOrder = await _context.KanbanTaskNotes
.Where(n => n.TaskId == taskId)
.MaxAsync(n => (int?)n.SortOrder) ?? -1;
var note = new KanbanTaskNote
{
Id = Guid.NewGuid(),
TaskId = taskId,
Title = title,
Content = content,
SortOrder = maxOrder + 1,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_context.KanbanTaskNotes.Add(note);
await _context.SaveChangesAsync();
return note;
}
public async Task<KanbanTaskNote> UpdateNoteAsync(KanbanTaskNote note, Guid? userId = null)
{
var existing = await _context.KanbanTaskNotes.FindAsync(note.Id)
?? throw new InvalidOperationException("Notiz nicht gefunden.");
existing.Title = note.Title;
existing.Content = note.Content;
existing.LastEditedByUserId = userId;
existing.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return existing;
}
public async Task DeleteNoteAsync(Guid noteId)
{
var note = await _context.KanbanTaskNotes.FindAsync(noteId);
if (note != null)
{
_context.KanbanTaskNotes.Remove(note);
await _context.SaveChangesAsync();
}
}
#endregion
#region Attachments
public async Task<List<KanbanTaskAttachment>> GetAttachmentsAsync(Guid taskId)
{
return await _context.KanbanTaskAttachments
.Include(a => a.UploadedByUser)
.Where(a => a.TaskId == taskId)
.OrderByDescending(a => a.CreatedAt)
.ToListAsync();
}
public async Task<KanbanTaskAttachment?> GetAttachmentByIdAsync(Guid id)
{
return await _context.KanbanTaskAttachments.FindAsync(id);
}
public async Task<KanbanTaskAttachment> AddAttachmentAsync(Guid taskId, string fileName, string contentType, byte[] content, Guid? userId)
{
if (content.Length > KanbanTaskAttachment.MaxFileSize)
{
throw new InvalidOperationException($"Datei ist zu groß. Maximum: {KanbanTaskAttachment.MaxFileSize / (1024 * 1024)} MB");
}
string? filePath = null;
var storageSettings = await _fileStorageService.GetSettingsAsync();
// Try to save to file storage if configured
if (storageSettings.IsEnabled && storageSettings.IsPathAccessible())
{
try
{
filePath = await _fileStorageService.SaveFileAsync(content, fileName, "kanban-attachments");
_logger.LogInformation("Attachment saved to file storage: {FilePath}", filePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save attachment to file storage, falling back to database");
filePath = null;
}
}
var attachment = new KanbanTaskAttachment
{
Id = Guid.NewGuid(),
TaskId = taskId,
FileName = fileName,
ContentType = contentType,
FileSize = content.Length,
FilePath = filePath,
#pragma warning disable CS0618 // Type or member is obsolete
FileContent = filePath == null ? content : null, // Only store in DB if file storage failed
#pragma warning restore CS0618
UploadedByUserId = userId,
CreatedAt = DateTime.UtcNow
};
_context.KanbanTaskAttachments.Add(attachment);
try
{
await _context.SaveChangesAsync();
}
catch
{
// Clean up orphaned file if DB save failed
if (filePath != null)
{
try { await _fileStorageService.DeleteFileAsync(filePath); }
catch (Exception cleanupEx) { _logger.LogWarning(cleanupEx, "Failed to clean up orphaned attachment file: {FilePath}", filePath); }
}
throw;
}
await AddActivityAsync(taskId, $"Datei '{fileName}' hinzugefügt", userId);
return attachment;
}
public async Task<byte[]?> GetAttachmentContentAsync(Guid attachmentId)
{
var attachment = await _context.KanbanTaskAttachments.FindAsync(attachmentId);
if (attachment == null)
return null;
// Try file storage first
if (!string.IsNullOrEmpty(attachment.FilePath))
{
var content = await _fileStorageService.ReadFileAsync(attachment.FilePath);
if (content != null)
return content;
}
// Fall back to database content (legacy)
#pragma warning disable CS0618
return attachment.FileContent;
#pragma warning restore CS0618
}
public async Task DeleteAttachmentAsync(Guid attachmentId)
{
var attachment = await _context.KanbanTaskAttachments.FindAsync(attachmentId);
if (attachment != null)
{
var taskId = attachment.TaskId;
var fileName = attachment.FileName;
// Delete from file storage if applicable
if (!string.IsNullOrEmpty(attachment.FilePath))
{
try
{
await _fileStorageService.DeleteFileAsync(attachment.FilePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete attachment file: {FilePath}", attachment.FilePath);
}
}
_context.KanbanTaskAttachments.Remove(attachment);
await _context.SaveChangesAsync();
await AddActivityAsync(taskId, $"Datei '{fileName}' entfernt");
}
}
#endregion
#region Task Labels
public async Task AddLabelToTaskAsync(Guid taskId, Guid labelId)
{
var exists = await _context.KanbanTaskLabels
.AnyAsync(tl => tl.TaskId == taskId && tl.LabelId == labelId);
if (!exists)
{
_context.KanbanTaskLabels.Add(new KanbanTaskLabel
{
TaskId = taskId,
LabelId = labelId
});
await _context.SaveChangesAsync();
}
}
public async Task RemoveLabelFromTaskAsync(Guid taskId, Guid labelId)
{
await _context.KanbanTaskLabels
.Where(tl => tl.TaskId == taskId && tl.LabelId == labelId)
.ExecuteDeleteAsync();
}
public async Task SetTaskLabelsAsync(Guid taskId, List<Guid> labelIds)
{
// Remove existing at DB level
await _context.KanbanTaskLabels
.Where(tl => tl.TaskId == taskId)
.ExecuteDeleteAsync();
// Detach any locally tracked entities to avoid duplicate key conflicts
var trackedLabels = _context.ChangeTracker.Entries<KanbanTaskLabel>()
.Where(e => e.Entity.TaskId == taskId)
.ToList();
foreach (var entry in trackedLabels)
entry.State = Microsoft.EntityFrameworkCore.EntityState.Detached;
// Add new
foreach (var labelId in labelIds)
{
_context.KanbanTaskLabels.Add(new KanbanTaskLabel
{
TaskId = taskId,
LabelId = labelId
});
}
await _context.SaveChangesAsync();
}
#endregion
#region Task Assignees
public async Task AddAssigneeAsync(Guid taskId, Guid userId)
{
var exists = await _context.KanbanTaskAssignees
.AnyAsync(ta => ta.TaskId == taskId && ta.UserId == userId);
if (!exists)
{
_context.KanbanTaskAssignees.Add(new KanbanTaskAssignee
{
TaskId = taskId,
UserId = userId,
AssignedAt = DateTime.UtcNow
});
await _context.SaveChangesAsync();
}
}
public async Task RemoveAssigneeAsync(Guid taskId, Guid userId)
{
await _context.KanbanTaskAssignees
.Where(ta => ta.TaskId == taskId && ta.UserId == userId)
.ExecuteDeleteAsync();
}
public async Task SetAssigneesAsync(Guid taskId, List<Guid> userIds, Guid? changedByUserId = null)
{
var currentAssignees = await _context.KanbanTaskAssignees
.Where(ta => ta.TaskId == taskId)
.Select(ta => ta.UserId)
.ToListAsync();
var added = userIds.Except(currentAssignees).ToList();
var removed = currentAssignees.Except(userIds).ToList();
// Remove old at DB level
await _context.KanbanTaskAssignees
.Where(ta => ta.TaskId == taskId)
.ExecuteDeleteAsync();
// Detach any locally tracked entities to avoid duplicate key conflicts
var trackedAssignees = _context.ChangeTracker.Entries<KanbanTaskAssignee>()
.Where(e => e.Entity.TaskId == taskId)
.ToList();
foreach (var entry in trackedAssignees)
entry.State = Microsoft.EntityFrameworkCore.EntityState.Detached;
// Add new
foreach (var userId in userIds)
{
_context.KanbanTaskAssignees.Add(new KanbanTaskAssignee
{
TaskId = taskId,
UserId = userId,
AssignedAt = DateTime.UtcNow
});
}
await _context.SaveChangesAsync();
// Log activity
if (added.Any() || removed.Any())
{
var users = await _context.Users
.Where(u => added.Contains(u.Id) || removed.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.DisplayName ?? u.Username);
var activities = new List<string>();
if (added.Any())
{
var names = added.Select(id => users.GetValueOrDefault(id, "Unbekannt"));
activities.Add($"Zugewiesen: {string.Join(", ", names)}");
}
if (removed.Any())
{
var names = removed.Select(id => users.GetValueOrDefault(id, "Unbekannt"));
activities.Add($"Entfernt: {string.Join(", ", names)}");
}
await AddActivityAsync(taskId, string.Join("; ", activities), changedByUserId);
}
}
#endregion
#region Task Customers
public async Task AddCustomerToTaskAsync(Guid taskId, Guid customerId)
{
var exists = await _context.KanbanTaskCustomers
.AnyAsync(tc => tc.TaskId == taskId && tc.CustomerId == customerId);
if (!exists)
{
_context.KanbanTaskCustomers.Add(new KanbanTaskCustomer
{
TaskId = taskId,
CustomerId = customerId
});
await _context.SaveChangesAsync();
var customer = await _context.Customers.FindAsync(customerId);
if (customer != null)
{
await AddActivityAsync(taskId, $"Kunde '{customer.FullName}' verknüpft");
}
}
}
public async Task RemoveCustomerFromTaskAsync(Guid taskId, Guid customerId)
{
var customer = await _context.Customers.FindAsync(customerId);
await _context.KanbanTaskCustomers
.Where(tc => tc.TaskId == taskId && tc.CustomerId == customerId)
.ExecuteDeleteAsync();
// Also remove tire stacks for this customer
await _context.KanbanTaskTireStacks
.Where(ts => ts.TaskId == taskId && ts.TireStack!.CustomerId == customerId)
.ExecuteDeleteAsync();
if (customer != null)
{
await AddActivityAsync(taskId, $"Kunde '{customer.FullName}' entfernt");
}
}
public async Task SetTaskCustomersAsync(Guid taskId, Dictionary<Guid, Guid?> customerVehicleMap)
{
await _context.KanbanTaskCustomers
.Where(tc => tc.TaskId == taskId)
.ExecuteDeleteAsync();
// Detach any locally tracked entities to avoid duplicate key conflicts
var trackedCustomers = _context.ChangeTracker.Entries<KanbanTaskCustomer>()
.Where(e => e.Entity.TaskId == taskId)
.ToList();
foreach (var entry in trackedCustomers)
entry.State = Microsoft.EntityFrameworkCore.EntityState.Detached;
foreach (var (customerId, vehicleId) in customerVehicleMap)
{
_context.KanbanTaskCustomers.Add(new KanbanTaskCustomer
{
TaskId = taskId,
CustomerId = customerId,
VehicleId = vehicleId
});
}
await _context.SaveChangesAsync();
}
#endregion
#region Task TireStacks
public async Task AddTireStackToTaskAsync(Guid taskId, Guid tireStackId)
{
var exists = await _context.KanbanTaskTireStacks
.AnyAsync(ts => ts.TaskId == taskId && ts.TireStackId == tireStackId);
if (!exists)
{
_context.KanbanTaskTireStacks.Add(new KanbanTaskTireStack
{
TaskId = taskId,
TireStackId = tireStackId
});
await _context.SaveChangesAsync();
}
}
public async Task RemoveTireStackFromTaskAsync(Guid taskId, Guid tireStackId)
{
await _context.KanbanTaskTireStacks
.Where(ts => ts.TaskId == taskId && ts.TireStackId == tireStackId)
.ExecuteDeleteAsync();
}
public async Task SetTaskTireStacksAsync(Guid taskId, List<Guid> tireStackIds)
{
await _context.KanbanTaskTireStacks
.Where(ts => ts.TaskId == taskId)
.ExecuteDeleteAsync();
// Detach any locally tracked entities to avoid duplicate key conflicts
var trackedTireStacks = _context.ChangeTracker.Entries<KanbanTaskTireStack>()
.Where(e => e.Entity.TaskId == taskId)
.ToList();
foreach (var entry in trackedTireStacks)
entry.State = Microsoft.EntityFrameworkCore.EntityState.Detached;
foreach (var tireStackId in tireStackIds)
{
_context.KanbanTaskTireStacks.Add(new KanbanTaskTireStack
{
TaskId = taskId,
TireStackId = tireStackId
});
}
await _context.SaveChangesAsync();
}
#endregion
#region Templates
public async Task<List<KanbanTaskTemplate>> GetTemplatesAsync()
{
return await _context.KanbanTaskTemplates
.AsSplitQuery() // Prevents cartesian explosion with multiple includes
.Include(t => t.DefaultColumn)
.Include(t => t.Todos.OrderBy(todo => todo.SortOrder))
.Include(t => t.TemplateLabels).ThenInclude(tl => tl.Label)
.Where(t => t.IsActive)
.OrderBy(t => t.SortOrder)
.ToListAsync();
}
public async Task<KanbanTaskTemplate?> GetTemplateByIdAsync(Guid id)
{
return await _context.KanbanTaskTemplates
.AsSplitQuery()
.Include(t => t.DefaultColumn)
.Include(t => t.Todos.OrderBy(todo => todo.SortOrder))
.Include(t => t.TemplateLabels).ThenInclude(tl => tl.Label)
.FirstOrDefaultAsync(t => t.Id == id && t.IsActive);
}
public async Task<KanbanTaskTemplate> CreateTemplateAsync(KanbanTaskTemplate template)
{
template.Id = Guid.NewGuid();
template.IsActive = true;
template.CreatedAt = DateTime.UtcNow;
template.UpdatedAt = DateTime.UtcNow;
var maxOrder = await _context.KanbanTaskTemplates.MaxAsync(t => (int?)t.SortOrder) ?? -1;
template.SortOrder = maxOrder + 1;
_context.KanbanTaskTemplates.Add(template);
await _context.SaveChangesAsync();
return template;
}
public async Task<KanbanTaskTemplate> UpdateTemplateAsync(KanbanTaskTemplate template)
{
var existing = await _context.KanbanTaskTemplates.FindAsync(template.Id)
?? throw new InvalidOperationException("Vorlage nicht gefunden.");
existing.Name = template.Name;
existing.Description = template.Description;
existing.TitlePattern = template.TitlePattern;
existing.DefaultDescription = template.DefaultDescription;
existing.DefaultColumnId = template.DefaultColumnId;
existing.DefaultPriority = template.DefaultPriority;
existing.DefaultEstimatedMinutes = template.DefaultEstimatedMinutes;
existing.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return existing;
}
public async Task DeleteTemplateAsync(Guid id)
{
var template = await _context.KanbanTaskTemplates.FindAsync(id)
?? throw new InvalidOperationException("Vorlage nicht gefunden.");
template.IsActive = false;
template.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
public async Task<KanbanTaskTemplateTodo> AddTemplateTodoAsync(Guid templateId, string text)
{
var maxOrder = await _context.KanbanTaskTemplateTodos
.Where(t => t.TemplateId == templateId)
.MaxAsync(t => (int?)t.SortOrder) ?? -1;
var todo = new KanbanTaskTemplateTodo
{
Id = Guid.NewGuid(),
TemplateId = templateId,
Text = text,
SortOrder = maxOrder + 1
};
_context.KanbanTaskTemplateTodos.Add(todo);
await _context.SaveChangesAsync();
return todo;
}
public async Task DeleteTemplateTodoAsync(Guid todoId)
{
var todo = await _context.KanbanTaskTemplateTodos.FindAsync(todoId);
if (todo != null)
{
_context.KanbanTaskTemplateTodos.Remove(todo);
await _context.SaveChangesAsync();
}
}
public async Task SyncTemplateTodosAsync(Guid templateId, List<KanbanTaskTemplateTodo> todos)
{
// Remove all existing todos
await _context.KanbanTaskTemplateTodos
.Where(t => t.TemplateId == templateId)
.ExecuteDeleteAsync();
// Add new todos
for (int i = 0; i < todos.Count; i++)
{
_context.KanbanTaskTemplateTodos.Add(new KanbanTaskTemplateTodo
{
Id = Guid.NewGuid(),
TemplateId = templateId,
Text = todos[i].Text,
SortOrder = i
});
}
await _context.SaveChangesAsync();
}
public async Task SetTemplateLabelsAsync(Guid templateId, List<Guid> labelIds)
{
await _context.KanbanTaskTemplateLabels
.Where(tl => tl.TemplateId == templateId)
.ExecuteDeleteAsync();
foreach (var labelId in labelIds)
{
_context.KanbanTaskTemplateLabels.Add(new KanbanTaskTemplateLabel
{
TemplateId = templateId,
LabelId = labelId
});
}
await _context.SaveChangesAsync();
}
#endregion
#region Statistics
public async Task<KanbanStatistics> GetStatisticsAsync()
{
var today = DateTime.UtcNow.Date;
var weekStart = today.AddDays(-(int)today.DayOfWeek);
// Get completed column IDs to exclude from "open" counts
var completedColumnIds = await _context.KanbanColumns
.Where(c => c.IsCompletedColumn && c.IsActive)
.Select(c => c.Id)
.ToListAsync();
// Use database-side counting for better performance
var baseQuery = _context.KanbanTasks.Where(t => !t.IsArchived);
var openTasksQuery = baseQuery.Where(t => !completedColumnIds.Contains(t.ColumnId));
var totalOpenTasks = await openTasksQuery.CountAsync();
var tasksDueToday = await openTasksQuery.CountAsync(t => t.DueDate.HasValue && t.DueDate.Value.Date == today);
var overdueTasks = await openTasksQuery.CountAsync(t => t.DueDate.HasValue && t.DueDate.Value.Date < today);
var completedThisWeek = await baseQuery.CountAsync(t => t.CompletedAt.HasValue && t.CompletedAt.Value >= weekStart);
// Get tasks per column with a single grouped query
var tasksPerColumn = await baseQuery
.GroupBy(t => t.ColumnId)
.Select(g => new { ColumnId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.ColumnId, x => x.Count);
return new KanbanStatistics
{
TotalTasks = totalOpenTasks,
TasksDueToday = tasksDueToday,
OverdueTasks = overdueTasks,
CompletedThisWeek = completedThisWeek,
TasksPerColumn = tasksPerColumn
};
}
#endregion
}