179 lines
7.4 KiB
Swift
179 lines
7.4 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: - Session scope
|
|
|
|
/// The (server, user) pair the local database is currently scoped to.
|
|
///
|
|
/// Almost every local id is meaningful only within one such pair. `serverId`
|
|
/// values differ between jqc and jqc1; facility/template ids and inspector
|
|
/// facility scope differ per user. So when either half changes, the cached data
|
|
/// is not merely stale — it is *wrong*, and silently belongs to someone else.
|
|
///
|
|
/// Two concrete failures this exists to close:
|
|
/// • A different inspector signing in on the same iPad inherited the previous
|
|
/// one's issues. `pullAssignedIssues`' reconciliation only deletes rows with
|
|
/// `inspectionLocalId == ""`, so device-authored synced issues survived
|
|
/// indefinitely and were simply visible to whoever logged in next.
|
|
/// • A server switch cleared `LocalIssue` alone, leaving `LocalInspection`
|
|
/// rows carrying facility/template ids from the other server, ready to be
|
|
/// submitted against it.
|
|
///
|
|
/// Deliberately in UserDefaults, NOT the Keychain: `KeychainHelper.clearAll()`
|
|
/// runs on logout, and this marker has to OUTLIVE a logout to be able to notice
|
|
/// that the next login is a different person. It is not a secret.
|
|
nonisolated enum SessionScope {
|
|
|
|
struct Scope: Equatable, Sendable {
|
|
let server: String
|
|
let userId: Int
|
|
}
|
|
|
|
private static let userKey = "com.jqc.sessionScope.userId"
|
|
private static let serverKey = "com.jqc.sessionScope.server"
|
|
|
|
/// The recorded scope, or nil when none has ever been written.
|
|
static var stored: Scope? {
|
|
let d = UserDefaults.standard
|
|
guard let server = d.string(forKey: serverKey),
|
|
d.object(forKey: userKey) != nil
|
|
else { return nil }
|
|
return Scope(server: server, userId: d.integer(forKey: userKey))
|
|
}
|
|
|
|
static func record(userId: Int, server: String = ServerConfig.current) {
|
|
UserDefaults.standard.set(userId, forKey: userKey)
|
|
UserDefaults.standard.set(server, forKey: serverKey)
|
|
}
|
|
|
|
/// Forget the scope entirely — used on a server switch, where the next
|
|
/// login is guaranteed to be against a different data set.
|
|
static func clear() {
|
|
UserDefaults.standard.removeObject(forKey: userKey)
|
|
UserDefaults.standard.removeObject(forKey: serverKey)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|