mana-swift-core/Sources/ManaCore/Feedback/FeedbackClient.swift
Till JS 8cb6fdc945 feat(feedback): FeedbackImage + images im Submit (v2.2.0)
FeedbackSubmission.images (base64, ohne data:-Praefix, 1:1 zum Web- und
Android-Vertrag); FeedbackClient-Payload sendet images:[{mime,b64}].
Additiv. Test deckt Bild-Payload ab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:56:08 +02:00

130 lines
4.9 KiB
Swift

import Foundation
/// Framework-agnostischer Client für den zentralen Feedback-Eingang der
/// Wunsch-App. Das native Pendant zu `submitFeedback()` aus
/// `@mana/shared-feedback` — **selber Vertrag, selber Endpoint**, kein
/// Backend-Change (siehe `mana/docs/FEEDBACK_NATIVE_PLAN.md`).
///
/// Pro App eine Instanz mit fixem `appId`. Der `tokenProvider` liefert den
/// mana-auth-Access-Token zum Sende-Zeitpunkt (Attribution + Aura). Gibt er
/// `nil`/leer zurück, läuft die Einreichung anonym → Vorstand-Moderation.
///
/// ```swift
/// // eingeloggte App:
/// FeedbackClient(appId: "memoro") { try? await authClient.freshAccessToken() }
/// // anonyme App:
/// FeedbackClient(appId: "manameme")
/// ```
public struct FeedbackClient: Sendable {
/// Zentraler Eingang der Wunsch-App.
public static let defaultEndpoint = URL(string: "https://wunsch.mana.how/api/feedback")!
private let appId: String
private let endpoint: URL
private let session: URLSession
private let tokenProvider: @Sendable () async -> String?
public init(
appId: String,
endpoint: URL = FeedbackClient.defaultEndpoint,
session: URLSession = .shared,
tokenProvider: @escaping @Sendable () async -> String? = { nil }
) {
self.appId = appId
self.endpoint = endpoint
self.session = session
self.tokenProvider = tokenProvider
}
/// Reicht Feedback ein. Wirft ``FeedbackError`` bei Netz- oder
/// Server-Fehler.
@discardableResult
public func submit(_ submission: FeedbackSubmission) async throws -> FeedbackResult {
let token = await tokenProvider()
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token, !token.isEmpty {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
request.httpBody = try JSONSerialization.data(withJSONObject: payload(for: submission))
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch {
throw FeedbackError.network(error.localizedDescription)
}
guard let http = response as? HTTPURLResponse else {
throw FeedbackError.network("Keine HTTP-Antwort vom Server.")
}
guard (200..<300).contains(http.statusCode) else {
let message = (try? JSONDecoder().decode([String: String].self, from: data))?["error"]
throw FeedbackError.server(status: http.statusCode, message: message)
}
if let result = try? JSONDecoder().decode(FeedbackResult.self, from: data) {
return result
}
// Erfolg ohne parsebaren Body — als ok behandeln.
return FeedbackResult(id: nil, status: "ok", kind: submission.kind.rawValue, message: nil)
}
// MARK: - Payload (sichtbar für Tests)
/// Baut den JSON-Body. Spiegelt `SubmitFeedbackInput` aus
/// `@mana/shared-feedback` inkl. Honeypot `_hp`.
func payload(for submission: FeedbackSubmission) -> [String: Any] {
var body: [String: Any] = [
"kind": submission.kind.rawValue,
"appId": appId,
"title": submission.title,
"description": submission.description,
"platform": FeedbackPlatform.current.rawValue,
"appVersion": Self.appVersion,
"context": Self.autoContext(),
"_hp": "",
]
if let email = submission.contactEmail, !email.isEmpty {
body["contactEmail"] = email
}
if let name = submission.guestName, !name.isEmpty {
body["guestName"] = name
}
if !submission.images.isEmpty {
body["images"] = submission.images.map { ["mime": $0.mime, "b64": $0.b64] }
}
return body
}
/// `"<CFBundleShortVersionString> (<CFBundleVersion>)"`.
static var appVersion: String {
let info = Bundle.main.infoDictionary
let short = info?["CFBundleShortVersionString"] as? String ?? "?"
let build = info?["CFBundleVersion"] as? String ?? "?"
return "\(short) (\(build))"
}
/// Technischer Kontext — Vorstand-only im Backend.
static func autoContext() -> [String: String] {
[
"locale": Locale.current.identifier,
"osVersion": ProcessInfo.processInfo.operatingSystemVersionString,
"device": deviceModel(),
]
}
/// Hardware-Identifier (z.B. `iPhone15,2`, `Mac14,2`) — ohne UIKit,
/// damit der Kit auf iOS und macOS gleich baut.
static func deviceModel() -> String {
var systemInfo = utsname()
uname(&systemInfo)
return withUnsafeBytes(of: &systemInfo.machine) { rawBuffer in
let bytes = rawBuffer.prefix { $0 != 0 }
return String(decoding: bytes, as: UTF8.self)
}
}
}