bugger/Sources/Services/BugEditService.swift

148 lines
5.2 KiB
Swift

import Foundation
enum EditError: Error, LocalizedError {
case needsReauth
case notConfigured
var errorDescription: String? {
switch self {
case .needsReauth:
return "Missing write permission. Re-authorize Feishu to enable editing (and make sure you have edit rights on the Bitable)."
case .notConfigured:
return "Feishu table not configured. Open Settings."
}
}
}
/// Writes bug fields back to the Bitable. No optimistic local mutation — after a
/// successful write it triggers the normal fetch pipeline, which is the source
/// of truth (and drives change notifications).
@MainActor
@Observable
final class BugEditService {
static let shared = BugEditService()
private(set) var fields: [FieldItem] = []
private var fieldsLoadedFor: String?
private init() {}
// MARK: - Field metadata
/// Loads table field metadata once per appToken/tableId (status options,
/// field IDs for attachment downloads).
func ensureFieldsLoaded() async {
guard let config = AppStateService.shared.config, config.isConfigured else { return }
let key = "\(config.appToken)/\(config.tableId)"
guard fieldsLoadedFor != key else { return }
do {
let token = try await TokenManager.shared.getAccessToken()
fields = try await FeishuService().fetchFields(
appToken: config.appToken,
tableId: config.tableId,
accessToken: token
)
fieldsLoadedFor = key
} catch {
BuggerLog.error("BugEditService: fetchFields failed: \(error.localizedDescription)")
}
}
func fieldID(named name: String) -> String? {
fields.first { $0.fieldName == name }?.fieldId
}
var statusOptions: [SelectOption] {
guard let config = AppStateService.shared.config else { return [] }
return fields
.first { $0.fieldName == config.fieldMappings.statusField }?
.property?.options ?? []
}
// MARK: - Edits
func updateStatus(_ bug: Bug, to optionName: String) async throws {
guard let field = AppStateService.shared.config?.fieldMappings.statusField else {
throw EditError.notConfigured
}
try await update(bug, fields: [field: optionName])
}
func updateFixNotes(_ bug: Bug, text: String) async throws {
guard let field = AppStateService.shared.config?.fieldMappings.fixNotesField else {
throw EditError.notConfigured
}
try await update(bug, fields: [field: text])
}
/// Uploads each image (sequentially — the API rejects concurrent uploads)
/// and appends all new file tokens to the attachment field in one update.
func addScreenshots(_ bug: Bug, images: [(name: String, data: Data, mimeType: String)]) async throws {
guard let config = AppStateService.shared.config, config.isConfigured else {
throw EditError.notConfigured
}
let maxBytes = 20 * 1024 * 1024
if let oversized = images.first(where: { $0.data.count > maxBytes }) {
throw FeishuError.invalidConfiguration(
"\"\(oversized.name)\" exceeds the 20 MB upload limit."
)
}
let service = FeishuService()
let accessToken = try await TokenManager.shared.getAccessToken()
var newTokens: [String] = []
for image in images {
do {
let token = try await service.uploadMedia(
appToken: config.appToken,
fileName: image.name,
data: image.data,
mimeType: image.mimeType,
accessToken: accessToken
)
newTokens.append(token)
} catch {
throw Self.mapError(error)
}
}
// Attachment fields are replaced wholesale, so resend existing tokens.
let allTokens = (bug.screenshots.map(\.fileToken) + newTokens).map {
["file_token": $0]
}
try await update(bug, fields: [config.fieldMappings.screenshotsField: allTokens])
}
// MARK: - Helpers
private func update(_ bug: Bug, fields: [String: Any]) async throws {
guard let config = AppStateService.shared.config, config.isConfigured else {
throw EditError.notConfigured
}
let accessToken = try await TokenManager.shared.getAccessToken()
do {
try await FeishuService().updateRecord(
appToken: config.appToken,
tableId: config.tableId,
recordId: bug.id,
fields: fields,
accessToken: accessToken
)
} catch {
throw Self.mapError(error)
}
await PollerService.shared.fetchNow()
}
/// Permission failures mean either the token still has the old readonly
/// scope (re-auth needed) or the user lacks edit rights on the Bitable.
static func mapError(_ error: Error) -> Error {
if case let FeishuError.apiError(code, _) = error,
[1254302, 1254304, 1061004, 99991672, 99991679].contains(code) {
return EditError.needsReauth
}
return error
}
}