zitare-native/Sources/Core/Submit/SubmissionQueue.swift
Till JS c6127a2d31 ζ-3.6: Drop-Notification-Banner für Submission-Conflicts
Auto-Flush hat 4xx-Errors (duplicate, validation_failed, unauthorized)
bisher stillschweigend gedroppt — User offline einreichen, im Web
denselben Text posten, Online gehen → die Native-Submission war weg
ohne Hinweis.

SubmissionQueue:
- struct DropRecord (textPreview, authorName, code, message, droppedAt)
- private(set) var dropNotifications: [DropRecord]
- tryFlush sammelt jetzt einen Pre-Delete-Snapshot in dropNotifications
- consumeDropNotifications() leert die Liste — UI ruft beim
  Banner-Quittieren auf

SubmitQuoteView:
- droppedBanner zeigt alle gedroppten Drafts mit Text-Preview +
  lokalisierter Error-Message
- "Quittieren"-Button leert nur die UI-State (Server-Drop ist final)
- harvestDropNotifications() läuft nach jedem flushPending

iOS + macOS BUILD SUCCEEDED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:49:17 +02:00

147 lines
5.4 KiB
Swift

import Foundation
import ManaCore
import SwiftData
/// Verwaltet die `PendingSubmission`-Queue. Hauptverantwortung: Drafts
/// persistieren bei Network-Failure, beim Foreground/Reconnect via
/// `tryFlush(api:)` einen Retry-Run starten.
///
/// **Retry-Strategie:** alle wartenden Drafts werden sequentiell gesendet.
/// Bei `400/409/401` (Server-Code) wird der Eintrag **gelöscht** das
/// sind permanente Fehler (validation_failed, duplicate, unauthorized),
/// die ein Retry nicht rettet. Bei Network-/5xx-Fehler bleibt der Draft
/// in der Queue mit `lastError` für Diagnose; `retryCount++`.
///
/// **Hartes Limit:** 50 Retries pro Eintrag, danach wird er als
/// "permanent failed" markiert (`retryCount > 50`) und nicht mehr
/// versucht User muss manuell löschen oder neu eintragen.
@MainActor
@Observable
final class SubmissionQueue {
let container: ModelContainer
private var inFlight: Bool = false
/// Sammelt Drop-Records vom letzten tryFlush eine UI-Schicht kann
/// sie als "diese N Submissions wurden permanent verworfen"-Banner
/// anzeigen, statt dass der User stumm verliert.
struct DropRecord: Identifiable, Sendable {
let id = UUID()
let textPreview: String
let authorName: String?
let code: String?
let message: String
let droppedAt: Date
}
/// Drop-Notifications der letzten Session UI liest und konsumiert
/// via `consumeDropNotifications()`.
private(set) var dropNotifications: [DropRecord] = []
init(container: ModelContainer) {
self.container = container
}
/// Leert die Liste Caller hat sie gerade angezeigt.
func consumeDropNotifications() -> [DropRecord] {
let snapshot = dropNotifications
dropNotifications.removeAll()
return snapshot
}
/// Hängt einen Draft an die Queue.
func deposit(_ draft: QuoteDraft, error: String? = nil) {
let ctx = ModelContext(container)
let entry = PendingSubmission(draft: draft)
entry.lastError = error
ctx.insert(entry)
try? ctx.save()
Log.app.info("Submission deposited (queue depth: \(self.queueDepth()))")
}
/// Anzahl der wartenden Submissions.
func queueDepth() -> Int {
let ctx = ModelContext(container)
let descriptor = FetchDescriptor<PendingSubmission>()
return (try? ctx.fetch(descriptor).count) ?? 0
}
/// Holt alle pending Drafts (für UI).
func loadAll() -> [PendingSubmission] {
let ctx = ModelContext(container)
let descriptor = FetchDescriptor<PendingSubmission>(
sortBy: [SortDescriptor(\.createdAt)]
)
return (try? ctx.fetch(descriptor)) ?? []
}
/// Löscht eine Submission manuell aus der Queue.
func delete(id: UUID) {
let ctx = ModelContext(container)
let descriptor = FetchDescriptor<PendingSubmission>(
predicate: #Predicate { $0.id == id }
)
if let row = try? ctx.fetch(descriptor).first {
ctx.delete(row)
try? ctx.save()
}
}
/// Versucht alle wartenden Submissions zu senden. Returns count of
/// erfolgreich gesendeter Items.
@discardableResult
func tryFlush(api: ZitareAPI) async -> Int {
guard !inFlight else { return 0 }
inFlight = true
defer { inFlight = false }
let ctx = ModelContext(container)
let descriptor = FetchDescriptor<PendingSubmission>(
predicate: #Predicate { $0.retryCount <= 50 },
sortBy: [SortDescriptor(\.createdAt)]
)
guard let entries = try? ctx.fetch(descriptor), !entries.isEmpty else {
return 0
}
Log.app.info("Flushing \(entries.count) pending submission(s)")
var sent = 0
for entry in entries {
let draft = entry.toDraft()
do {
_ = try await api.submitQuote(draft)
ctx.delete(entry)
sent += 1
} catch let error as ZitareAPIError where shouldDrop(error) {
Log.app.warning("Dropping pending submission (permanent error: \(error.code ?? "?", privacy: .public))")
entry.lastError = error.errorDescription
// Notify UI: dieser Draft wurde verworfen. Pre-Snapshot
// der Daten vor dem Delete machen.
dropNotifications.append(DropRecord(
textPreview: String(entry.text.prefix(120)),
authorName: entry.authorName,
code: error.code,
message: error.errorDescription ?? "Fehler",
droppedAt: Date()
))
ctx.delete(entry)
} catch let error as LocalizedError {
entry.lastError = error.errorDescription
entry.lastTriedAt = Date()
entry.retryCount += 1
} catch {
entry.lastError = String(describing: error)
entry.lastTriedAt = Date()
entry.retryCount += 1
}
}
try? ctx.save()
Log.app.info("Flush done — sent \(sent)/\(entries.count)")
return sent
}
/// 4xx-Errors sind permanent (validation, auth, duplicate) Drop.
/// 5xx und Transport-Failure Retry.
private func shouldDrop(_ error: ZitareAPIError) -> Bool {
error.status >= 400 && error.status < 500
}
}