fix(worker): fix Bandel ticket sync and reactivate the integration

The status PATCH needs statusName alongside statusId or the API 400s,
so every write-back had been silently failing. The board's flat
assigneeName is null on every live ticket; the real assignee list is
ticketAssignees[]. Import now also picks up In Bearbeitung tickets,
not just Offen ones.

Both issues are live-verified against the real API, so the feature
flag flips back on.
This commit is contained in:
mika kuns
2026-09-02 09:59:06 +02:00
parent f640580463
commit 30d0611b8e
10 changed files with 185 additions and 64 deletions
@@ -49,10 +49,19 @@ public sealed class BandelTicketClient
=> await SendAsync<List<TicketBoardItem>>(
HttpMethod.Get, $"/api/Board/project/{projectId}", null, ct);
/// <summary>
/// ⚠️ Die Route verlangt <c>statusName</c> zusätzlich zu <c>statusId</c>, sonst 400
/// ("The StatusName field is required."). Der Wert wird nicht gegen die Id validiert —
/// gesetzt wird immer nach <c>statusId</c> — er darf aber nicht fehlen oder leer sein.
/// Live geprüft am 2026-09-01.
/// </summary>
public async Task SetStatusAsync(int ticketId, int statusId, CancellationToken ct)
=> await SendAsync<JsonElement>(
HttpMethod.Patch, $"/api/Ticket/{ticketId}/status",
new StringContent($"{{\"statusId\":{statusId}}}", Encoding.UTF8, "application/json"), ct);
new StringContent(
$"{{\"statusId\":{statusId},\"statusName\":\"{TicketStatusMap.NameOf(statusId)}\"}}",
Encoding.UTF8, "application/json"),
ct);
private async Task<T> SendAsync<T>(HttpMethod method, string path, HttpContent? body, CancellationToken ct)
{
+10
View File
@@ -16,6 +16,11 @@ public sealed record TicketIdentity(
[property: JsonPropertyName("scopes")] IReadOnlyList<string> Scopes);
/// Ein Board-Item (Ausschnitt aus GetTicketSummaryDto — nur was der Import braucht).
/// ⚠️ <see cref="AssigneeId"/>/<see cref="AssigneeName"/> sind live durchgehend null (geprüft über
/// alle 63 Tickets der Abteilung Entwicklung, 2026-09-01). Die echten Zuweisungen stehen in
/// <see cref="TicketAssignees"/>; nur dessen <c>assigneeName</c> hat dasselbe Format wie
/// <see cref="TicketIdentity.UserName"/> (<c>mika.kuns</c>). <c>personInChargeName</c> wäre die
/// falsche Quelle — dort steht mal der Login, mal der Anzeigename ("Mika Kuns").
public sealed record TicketBoardItem(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("title")] string Title,
@@ -23,6 +28,11 @@ public sealed record TicketBoardItem(
[property: JsonPropertyName("statusId")] int StatusId,
[property: JsonPropertyName("statusName")] string? StatusName,
[property: JsonPropertyName("assigneeID")] int? AssigneeId,
[property: JsonPropertyName("assigneeName")] string? AssigneeName,
[property: JsonPropertyName("ticketAssignees")] IReadOnlyList<TicketAssignee>? TicketAssignees = null);
public sealed record TicketAssignee(
[property: JsonPropertyName("assigneeId")] int AssigneeId,
[property: JsonPropertyName("assigneeName")] string? AssigneeName);
/// GET /api/Navigation/sidebar — Abteilung mit ihren Projekten.
@@ -16,7 +16,9 @@ public sealed record TicketImportResult(int Examined, int Created);
/// </summary>
public sealed class TicketImportService
{
private const int StatusOffen = 1;
// Offen UND In Bearbeitung: ein Ticket, an dem schon gearbeitet wird, ist genauso ein
// Arbeitsauftrag wie ein neues. Fertig/Archiviert bleiben draußen.
private static readonly int[] ImportableStatuses = [1, TicketStatusMap.InBearbeitung];
private readonly TicketClientFactory _clients;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
@@ -48,8 +50,8 @@ public sealed class TicketImportService
var board = await client.GetProjectBoardAsync(projectId, ct);
var mine = board
.Where(t => t.StatusId == StatusOffen)
.Where(t => string.Equals(t.AssigneeName, identity.UserName, StringComparison.OrdinalIgnoreCase))
.Where(t => ImportableStatuses.Contains(t.StatusId))
.Where(t => IsAssignedTo(t, identity.UserName))
.ToList();
var known = await ctx.Tasks.AsNoTracking()
@@ -87,6 +89,16 @@ public sealed class TicketImportService
return new TicketImportResult(board.Count, created);
}
/// <summary>
/// Zuweisung ausschließlich über <c>ticketAssignees</c> — das flache <c>assigneeName</c> ist
/// live auf jedem Ticket null (siehe <see cref="TicketBoardItem"/>). Ein Ticket kann mehrere
/// Zuweisungen haben; eine davon muss der Token-Inhaber sein.
/// </summary>
private static bool IsAssignedTo(TicketBoardItem ticket, string userName)
=> ticket.TicketAssignees?.Any(
a => string.Equals(a.AssigneeName, userName, StringComparison.OrdinalIgnoreCase))
?? false;
private static string BuildDescription(TicketBoardItem ticket)
{
var body = string.IsNullOrWhiteSpace(ticket.Description) ? "" : ticket.Description.Trim() + "\n\n";
@@ -13,6 +13,20 @@ public static class TicketStatusMap
private const string BandelPrefix = "bandel:";
/// <summary>
/// Namen wie von <c>GET /api/ticketsystem/statuses</c> geliefert. Braucht der PATCH-Body,
/// der <c>statusName</c> als required-Feld verlangt (siehe
/// <see cref="BandelTicketClient.SetStatusAsync"/>). Nie leer — ein leerer Wert wäre ein 400.
/// </summary>
public static string NameOf(int statusId) => statusId switch
{
1 => "Offen",
InBearbeitung => "InBearbeitung",
Fertig => "Fertig",
4 => "Archiviert",
_ => "Keine",
};
/// <summary>
/// Zielstatus im Ticketsystem, oder null wenn dieser Task-Status nichts melden soll.
/// "Offen" (1) wird nie geschrieben: das ist der Eingangszustand. Failed/Cancelled lassen