// Utils/Constants.swift // --------------------- // Central place for app-wide constants. import Foundation // MARK: - Server selection /// The two known JQC servers the inspector can connect to. nonisolated enum ServerOption: String, CaseIterable, Sendable { case primary = "https://jqc.ltservicesinc.com" case secondary = "https://jqc1.ltservicesinc.com" var displayName: String { switch self { case .primary: return "jqc (Primary)" case .secondary: return "jqc1 (Secondary)" } } } /// Runtime-mutable server selection backed by UserDefaults. /// Read `ServerConfig.current` anywhere you would have used `Constants.baseURL`. nonisolated enum ServerConfig { private static let defaultsKey = "com.jqc.selectedServer" /// The currently selected base URL. Reads UserDefaults on every call so /// actor-isolated callers (e.g. APIClient) always get the latest value /// without needing @MainActor access. nonisolated static var current: String { get { let raw = UserDefaults.standard.string(forKey: defaultsKey) ?? "" return ServerOption(rawValue: raw)?.rawValue ?? ServerOption.primary.rawValue } } /// Persist the chosen server. Call from @MainActor UI code only. @MainActor static func select(_ option: ServerOption) { UserDefaults.standard.set(option.rawValue, forKey: defaultsKey) } /// The current selection as a `ServerOption` (for UI binding). @MainActor static var selectedOption: ServerOption { let raw = UserDefaults.standard.string(forKey: defaultsKey) ?? "" return ServerOption(rawValue: raw) ?? .primary } /// Resolve a photo URL, preferring an absolute server-provided URL /// (presigned R2 on the s3 backend, absolute-static on local) and falling /// back to building one from the relative 'uploads/...' key for older /// servers that don't send the *_url fields. nonisolated static func mediaURL(absolute: String?, path: String) -> URL? { if let a = absolute, !a.isEmpty { return URL(string: a) } guard !path.isEmpty else { return nil } return URL(string: current + "/static/" + path) } } // MARK: - App-wide constants // Explicitly not @MainActor — these constants must be readable from // any actor context including APIClient and KeychainHelper. nonisolated enum Constants { nonisolated enum Keychain { static let accessToken = "com.jqc.accessToken" static let refreshToken = "com.jqc.refreshToken" static let userId = "com.jqc.userId" static let userRole = "com.jqc.userRole" static let username = "com.jqc.username" static let displayName = "com.jqc.displayName" static let deviceId = "com.jqc.deviceId" } static let tokenRefreshBufferMinutes: Double = 5 // MARK: - Roles // // The server's role strings, and the ONE definition of which of them the // app treats as an inspector. This mirrors `User.INSPECTOR_ROLES` / // `User.is_inspector` in the Flask app (see server rule 87). // // Why this exists: `external_inspector` (displayed as "Customer Inspector" // — an inspector employed by the customer) has exactly the same powers as // our own `inspector`, and the API scopes it identically. The views here // were hand-written as `role == "admin" || role == "director" || role == // "inspector"`, so every one of them silently locked customer inspectors // out of actions the SERVER was perfectly willing to accept — Update // Status, Handled By, Start Follow-up. The failure is invisible: no error, // the control simply isn't drawn. // // Add a role in ONE place here; never re-write the literals in a view. nonisolated enum Roles { static let admin = "admin" static let director = "director" static let projectManager = "project_manager" static let auditor = "auditor" static let inspector = "inspector" /// "Customer Inspector" — employed by the customer, same powers as /// `inspector`, scoped to their assigned contracts. static let externalInspector = "external_inspector" /// Both inspector roles. Test membership of this, never `== inspector`. static let inspectorRoles: Set = [inspector, externalInspector] /// May change an issue's status / handler, and start a follow-up. /// Matches what the API actually accepts for these actions; the server /// remains the authority and additionally enforces facility scope. /// /// Built from `inspectorRoles` (already a Set) rather than from an /// array literal: `[a, b, c].union(...)` does not compile, because the /// literal is typed as Array before `.union` is looked up, and Array /// has no such member — the annotation on the left does not reach back /// into the receiver. static let issueActors: Set = inspectorRoles.union([admin, director, projectManager]) static func isInspector(_ role: String) -> Bool { inspectorRoles.contains(role) } } }