feat(data): list_folders table plus lists.folder_id

Sidebar folders get their own table rather than a flag on lists, so the
places that enumerate lists (MCP tools, move-to-list, queue picker, repo
import) need no folder filtering. ON DELETE SET NULL keeps a folder's
lists alive when the folder goes.
This commit is contained in:
mika kuns
2026-09-10 08:29:56 +02:00
parent f231f08a27
commit 13e860f06b
9 changed files with 1334 additions and 0 deletions
@@ -68,6 +68,58 @@ public sealed class ListRepository
await _context.SaveChangesAsync(ct);
}
// ---- Sidebar folders (list_folders) ----
// Folders live here rather than in their own repository because they exist only as a grouping
// of these lists — every caller that touches one already holds a ListRepository.
public async Task<List<ListFolderEntity>> GetFoldersAsync(CancellationToken ct = default)
{
return await _context.ListFolders
.OrderBy(f => f.SortOrder).ThenBy(f => f.CreatedAt)
.ToListAsync(ct);
}
public async Task AddFolderAsync(ListFolderEntity folder, CancellationToken ct = default)
{
folder.SortOrder = await _context.ListFolders.CountAsync(ct);
_context.ListFolders.Add(folder);
await _context.SaveChangesAsync(ct);
}
/// <summary>Patches just the two editable fields — a whole-entity Update would need
/// <c>CreatedAt</c>/<c>SortOrder</c>/<c>IsCollapsed</c> carried along and silently reset
/// whichever the caller forgot (see the SetConfigAsync note in CLAUDE.md).</summary>
public async Task RenameFolderAsync(string folderId, string name, string? icon, CancellationToken ct = default)
{
await _context.ListFolders
.Where(f => f.Id == folderId)
.ExecuteUpdateAsync(s => s
.SetProperty(f => f.Name, name)
.SetProperty(f => f.Icon, icon), ct);
}
/// <summary>Deletes the folder only — its lists survive and fall back to the top level via
/// the ON DELETE SET NULL on <c>lists.folder_id</c>.</summary>
public async Task DeleteFolderAsync(string folderId, CancellationToken ct = default)
{
await _context.ListFolders.Where(f => f.Id == folderId).ExecuteDeleteAsync(ct);
}
public async Task SetFolderCollapsedAsync(string folderId, bool collapsed, CancellationToken ct = default)
{
await _context.ListFolders
.Where(f => f.Id == folderId)
.ExecuteUpdateAsync(s => s.SetProperty(f => f.IsCollapsed, collapsed), ct);
}
/// <summary>Moves a list into <paramref name="folderId"/>, or back to the top level when null.</summary>
public async Task SetListFolderAsync(string listId, string? folderId, CancellationToken ct = default)
{
await _context.Lists
.Where(l => l.Id == listId)
.ExecuteUpdateAsync(s => s.SetProperty(l => l.FolderId, folderId), ct);
}
public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default)
{
return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct);