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 } }