Files
JQC_iOS_App/JanitorialQC/Utils/Constants.swift
T

126 lines
5.2 KiB
Swift

// 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<String> = [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<String> =
inspectorRoles.union([admin, director, projectManager])
static func isInspector(_ role: String) -> Bool {
inspectorRoles.contains(role)
}
}
}