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>
130 lines
4.9 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|