import Foundation struct VelocityLeadDTO: Decodable, Identifiable { let id: String let name: String let phone: String? let source: String let qualification: String let score: Int let kanbanStatus: String let budget: String let unitInterest: String let createdAt: String? let updatedAt: String? enum CodingKeys: String, CodingKey { case id case name case phone case source case qualification case score case kanbanStatus = "kanban_status" case budget case unitInterest = "unit_interest" case createdAt = "created_at" case updatedAt = "updated_at" } } struct VelocityCommunicationEventDTO: Decodable, Identifiable { let eventId: String let leadId: String let channel: String let direction: String let provider: String? let captureMode: String let consentState: String let timestamp: String let durationSeconds: Int? let summary: String? let recordingRef: String? let createdAt: String var id: String { eventId } enum CodingKeys: String, CodingKey { case eventId = "event_id" case leadId = "lead_id" case channel case direction case provider case captureMode = "capture_mode" case consentState = "consent_state" case timestamp case durationSeconds = "duration_seconds" case summary case recordingRef = "recording_ref" case createdAt = "created_at" } } struct VelocityCalendarEventDTO: Decodable, Identifiable { let calendarEventId: String let leadId: String? let title: String let description: String? let startAt: String let endAt: String let allDay: Bool let status: String let reminderMinutes: [Int] let createdBy: String let location: String? let createdAt: String var id: String { calendarEventId } enum CodingKeys: String, CodingKey { case calendarEventId = "calendar_event_id" case leadId = "lead_id" case title case description case startAt = "start_at" case endAt = "end_at" case allDay = "all_day" case status case reminderMinutes = "reminder_minutes" case createdBy = "created_by" case location case createdAt = "created_at" } } struct VelocityAlertSnapshotDTO: Decodable { let pendingInsights: Int let upcomingCalendarEvents24h: Int let pendingTranscriptions: Int let generatedAt: String enum CodingKeys: String, CodingKey { case pendingInsights = "pending_insights" case upcomingCalendarEvents24h = "upcoming_calendar_events_24h" case pendingTranscriptions = "pending_transcriptions" case generatedAt = "generated_at" } } enum VelocityAPIError: LocalizedError { case notConfigured(String) case invalidResponse case api(String) var errorDescription: String? { switch self { case .notConfigured(let message): return message case .invalidResponse: return "Velocity backend returned an invalid response." case .api(let message): return message } } } actor VelocityAPIClient { static let shared = VelocityAPIClient() private struct LoginBody: Encodable { let email: String let password: String } private struct LoginResponse: Decodable { let accessToken: String enum CodingKeys: String, CodingKey { case accessToken = "access_token" } } private struct LeadsEnvelope: Decodable { let data: [VelocityLeadDTO] } private struct EventsEnvelope: Decodable { let events: [VelocityCommunicationEventDTO] } private struct CalendarEnvelope: Decodable { let events: [VelocityCalendarEventDTO] } private let decoder = JSONDecoder() private var cachedToken: String? func fetchLeads() async throws -> [VelocityLeadDTO] { let request = try await authorizedRequest(path: "/api/leads") let response: LeadsEnvelope = try await perform(request) return response.data } func fetchEvents(for leadId: String, limit: Int = 5) async throws -> [VelocityCommunicationEventDTO] { let query = URLQueryItem(name: "lead_id", value: leadId) let limitItem = URLQueryItem(name: "limit", value: String(limit)) let request = try await authorizedRequest(path: "/api/mobile-edge/events", queryItems: [query, limitItem]) let response: EventsEnvelope = try await perform(request) return response.events } func fetchCalendarEvents(limit: Int = 50) async throws -> [VelocityCalendarEventDTO] { let request = try await authorizedRequest( path: "/api/mobile-edge/calendar", queryItems: [URLQueryItem(name: "limit", value: String(limit))] ) let response: CalendarEnvelope = try await perform(request) return response.events } func fetchAlerts() async throws -> VelocityAlertSnapshotDTO { let request = try await authorizedRequest(path: "/api/mobile-edge/alerts") return try await perform(request) } private func authorizedRequest(path: String, queryItems: [URLQueryItem] = []) async throws -> URLRequest { guard let url = buildURL(path: path, queryItems: queryItems) else { throw VelocityAPIError.notConfigured("Velocity backend base URL is invalid.") } let token = try await getToken() var request = URLRequest(url: url) request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 30 return request } private func buildURL(path: String, queryItems: [URLQueryItem]) -> URL? { guard var components = URLComponents(string: AppConfig.baseURL) else { return nil } components.path = path if !queryItems.isEmpty { components.queryItems = queryItems } return components.url } private func getToken() async throws -> String { if let token = AppConfig.apiBearerToken { return token } if let token = cachedToken { return token } guard let email = AppConfig.apiEmail, let password = AppConfig.apiPassword else { throw VelocityAPIError.notConfigured( "Set API_BEARER_TOKEN or API_EMAIL/API_PASSWORD in the app configuration to use live Velocity data." ) } guard let loginURL = buildURL(path: "/api/auth/login", queryItems: []) else { throw VelocityAPIError.notConfigured("Velocity backend base URL is invalid.") } var request = URLRequest(url: loginURL) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") request.httpBody = try JSONEncoder().encode(LoginBody(email: email, password: password)) request.timeoutInterval = 30 let response: LoginResponse = try await perform(request) cachedToken = response.accessToken return response.accessToken } private func perform(_ request: URLRequest) async throws -> T { let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse else { throw VelocityAPIError.invalidResponse } guard 200..<300 ~= http.statusCode else { if let apiError = try? decoder.decode(APIErrorPayload.self, from: data), let detail = apiError.detail { throw VelocityAPIError.api(detail) } throw VelocityAPIError.api("Velocity request failed with HTTP \(http.statusCode).") } do { return try decoder.decode(T.self, from: data) } catch { throw VelocityAPIError.invalidResponse } } } private struct APIErrorPayload: Decodable { let detail: String? }