feat: enhance notification handling with improved data structure and API integration
This commit is contained in:
@@ -30,6 +30,11 @@ struct APIErrorResponse: Decodable {
|
||||
let errors: [String: [String]]?
|
||||
}
|
||||
|
||||
enum APIService {
|
||||
case sso
|
||||
case notification
|
||||
}
|
||||
|
||||
enum Endpoint {
|
||||
// Auth
|
||||
case login(email: String, password: String)
|
||||
@@ -45,11 +50,28 @@ enum Endpoint {
|
||||
case logoutAll
|
||||
case changePassword(current: String, new: String)
|
||||
// Notifications
|
||||
case getNotifications(page: Int, perPage: Int)
|
||||
case getNotifications(limit: Int, offset: Int, unreadOnly: Bool, scope: String?)
|
||||
case markAsRead(id: UUID)
|
||||
case markAllAsRead(scope: String?)
|
||||
// Devices
|
||||
case registerDevice(token: String)
|
||||
case unregisterDevice(token: String)
|
||||
case listDevices
|
||||
case registerDevice(token: String, platform: String)
|
||||
case unregisterDevice(id: UUID)
|
||||
// Preferences
|
||||
case getPreferences
|
||||
case upsertPreference(channel: String, enabled: Bool, config: [String: String]?)
|
||||
|
||||
var service: APIService {
|
||||
switch self {
|
||||
case .login, .register, .verifyEmail, .resendCode, .refresh, .logout,
|
||||
.getMe, .getSessions, .deleteSession, .logoutAll, .changePassword:
|
||||
return .sso
|
||||
case .getNotifications, .markAsRead, .markAllAsRead,
|
||||
.listDevices, .registerDevice, .unregisterDevice,
|
||||
.getPreferences, .upsertPreference:
|
||||
return .notification
|
||||
}
|
||||
}
|
||||
|
||||
var path: String {
|
||||
switch self {
|
||||
@@ -66,17 +88,25 @@ enum Endpoint {
|
||||
case .changePassword: return "/users/me/change-password"
|
||||
case .getNotifications: return "/notifications"
|
||||
case .markAsRead(let id): return "/notifications/\(id.uuidString)/read"
|
||||
case .registerDevice: return "/devices/register"
|
||||
case .unregisterDevice: return "/devices/unregister"
|
||||
case .markAllAsRead: return "/notifications/read-all"
|
||||
case .listDevices: return "/devices"
|
||||
case .registerDevice: return "/devices"
|
||||
case .unregisterDevice(let id): return "/devices/\(id.uuidString)"
|
||||
case .getPreferences: return "/preferences"
|
||||
case .upsertPreference: return "/preferences"
|
||||
}
|
||||
}
|
||||
|
||||
var method: String {
|
||||
switch self {
|
||||
case .getMe, .getSessions, .getNotifications: return "GET"
|
||||
case .deleteSession: return "DELETE"
|
||||
case .markAsRead: return "PATCH"
|
||||
default: return "POST"
|
||||
case .getMe, .getSessions, .getNotifications, .listDevices, .getPreferences:
|
||||
return "GET"
|
||||
case .deleteSession, .unregisterDevice:
|
||||
return "DELETE"
|
||||
case .upsertPreference:
|
||||
return "PUT"
|
||||
default:
|
||||
return "POST"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,12 +135,20 @@ enum Endpoint {
|
||||
return ["refresh_token": token]
|
||||
case .changePassword(let current, let new):
|
||||
return ["current_password": current, "new_password": new]
|
||||
case .registerDevice(let token):
|
||||
return ["token": token, "platform": "ios"]
|
||||
case .unregisterDevice(let token):
|
||||
return ["token": token]
|
||||
case .getNotifications(let page, let perPage):
|
||||
return ["page": page, "per_page": perPage]
|
||||
case .registerDevice(let token, let platform):
|
||||
return ["token": token, "platform": platform]
|
||||
case .getNotifications(let limit, let offset, let unreadOnly, let scope):
|
||||
var params: [String: Any] = ["limit": limit, "offset": offset]
|
||||
if unreadOnly { params["unread_only"] = true }
|
||||
if let scope { params["scope"] = scope }
|
||||
return params
|
||||
case .markAllAsRead(let scope):
|
||||
if let scope { return ["scope": scope] }
|
||||
return nil
|
||||
case .upsertPreference(let channel, let enabled, let config):
|
||||
var params: [String: Any] = ["channel": channel, "enabled": enabled]
|
||||
if let config { params["config"] = config }
|
||||
return params
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -120,25 +158,35 @@ enum Endpoint {
|
||||
actor HTTPClient {
|
||||
static let shared = HTTPClient()
|
||||
|
||||
private let baseURL: String
|
||||
private let ssoBaseURL: String
|
||||
private let notificationBaseURL: String
|
||||
private let keychain = KeychainService.shared
|
||||
// Single in-flight refresh task; concurrent 401s await this rather than racing.
|
||||
private var refreshTask: Task<Void, Error>?
|
||||
|
||||
private init() {
|
||||
#if DEBUG
|
||||
baseURL = "http://localhost:8081"
|
||||
ssoBaseURL = "http://localhost:8081"
|
||||
notificationBaseURL = "http://localhost:8092"
|
||||
#else
|
||||
baseURL = "https://api.chemodan.example/sso"
|
||||
ssoBaseURL = "https://id.robonen.ru"
|
||||
notificationBaseURL = "https://notify.robonen.ru"
|
||||
#endif
|
||||
}
|
||||
|
||||
private func baseURL(for service: APIService) -> String {
|
||||
switch service {
|
||||
case .sso: return ssoBaseURL
|
||||
case .notification: return notificationBaseURL
|
||||
}
|
||||
}
|
||||
|
||||
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
|
||||
try await performRequest(endpoint, retryOnUnauthorized: endpoint.requiresAuth)
|
||||
}
|
||||
|
||||
private func performRequest<T: Decodable>(_ endpoint: Endpoint, retryOnUnauthorized: Bool) async throws -> T {
|
||||
guard let url = URL(string: baseURL + endpoint.path) else {
|
||||
guard let url = URL(string: baseURL(for: endpoint.service) + endpoint.path) else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,46 @@ actor NotificationsAPIService {
|
||||
|
||||
private init() {}
|
||||
|
||||
func getNotifications(page: Int = 1, perPage: Int = 20) async throws -> NotificationsPage {
|
||||
try await client.request(.getNotifications(page: page, perPage: perPage))
|
||||
// MARK: - Notifications
|
||||
|
||||
func getNotifications(limit: Int = 50, offset: Int = 0, unreadOnly: Bool = false, scope: String? = nil) async throws -> NotificationsPage {
|
||||
try await client.request(.getNotifications(limit: limit, offset: offset, unreadOnly: unreadOnly, scope: scope))
|
||||
}
|
||||
|
||||
func markAsRead(id: UUID) async throws {
|
||||
let _: AppNotification = try await client.request(.markAsRead(id: id))
|
||||
let _: EmptyResponse = try await client.request(.markAsRead(id: id))
|
||||
}
|
||||
|
||||
func markAllAsRead(scope: String? = nil) async throws {
|
||||
let _: EmptyResponse = try await client.request(.markAllAsRead(scope: scope))
|
||||
}
|
||||
|
||||
// MARK: - Devices
|
||||
|
||||
func listDevices() async throws -> [DeviceToken] {
|
||||
try await client.request(.listDevices)
|
||||
}
|
||||
|
||||
func registerDevice(token: String, platform: String = "ios") async throws -> DeviceToken {
|
||||
try await client.request(.registerDevice(token: token, platform: platform))
|
||||
}
|
||||
|
||||
func unregisterDevice(id: UUID) async throws {
|
||||
let _: EmptyResponse = try await client.request(.unregisterDevice(id: id))
|
||||
}
|
||||
|
||||
// MARK: - Preferences
|
||||
|
||||
func getPreferences() async throws -> [NotificationPreference] {
|
||||
try await client.request(.getPreferences)
|
||||
}
|
||||
|
||||
func upsertPreference(channel: String, enabled: Bool, config: [String: String]? = nil) async throws {
|
||||
let _: EmptyResponse = try await client.request(.upsertPreference(channel: channel, enabled: enabled, config: config))
|
||||
}
|
||||
|
||||
// MARK: - SSO (User Management)
|
||||
|
||||
func getSessions() async throws -> [SessionResponse] {
|
||||
try await client.request(.getSessions)
|
||||
}
|
||||
|
||||
@@ -18,78 +18,127 @@ enum PreviewData {
|
||||
|
||||
static let mockNotifications: [AppNotification] = {
|
||||
let now = Date()
|
||||
let mockUserId = UUID(uuidString: "00000000-0000-0000-0000-000000000001")!
|
||||
return [
|
||||
AppNotification(
|
||||
id: UUID(uuidString: "10000000-0000-0000-0000-000000000001")!,
|
||||
topic: "Fire Alert",
|
||||
userId: mockUserId,
|
||||
scopeId: nil,
|
||||
channel: .apns,
|
||||
contentType: .plain,
|
||||
templateId: nil,
|
||||
subject: "Пожарная тревога",
|
||||
body: "Обнаружено задымление на 12 этаже, корпус 9. Необходима немедленная эвакуация персонала.",
|
||||
source: "Fire Alert",
|
||||
metadata: ["Здание": "Корпус 9", "Этаж": "12", "Комната": "1A", "Датчик": "SM-4021"],
|
||||
status: .delivered,
|
||||
channel: .push,
|
||||
status: .sent,
|
||||
error: nil,
|
||||
attempts: 1,
|
||||
maxAttempts: 3,
|
||||
nextRetryAt: nil,
|
||||
sentAt: now.addingTimeInterval(-120),
|
||||
readAt: nil,
|
||||
createdAt: now.addingTimeInterval(-120),
|
||||
updatedAt: now.addingTimeInterval(-120)
|
||||
createdAt: now.addingTimeInterval(-120)
|
||||
),
|
||||
AppNotification(
|
||||
id: UUID(uuidString: "10000000-0000-0000-0000-000000000002")!,
|
||||
topic: "Security Alert",
|
||||
userId: mockUserId,
|
||||
scopeId: nil,
|
||||
channel: .apns,
|
||||
contentType: .plain,
|
||||
templateId: nil,
|
||||
subject: "Нарушение периметра",
|
||||
body: "Зафиксировано несанкционированное проникновение через вход B2. Охрана уведомлена.",
|
||||
source: "Security Alert",
|
||||
metadata: ["Зона": "B2", "Камера": "CAM-17"],
|
||||
status: .delivered,
|
||||
channel: .push,
|
||||
status: .sent,
|
||||
error: nil,
|
||||
attempts: 1,
|
||||
maxAttempts: 3,
|
||||
nextRetryAt: nil,
|
||||
sentAt: now.addingTimeInterval(-300),
|
||||
readAt: nil,
|
||||
createdAt: now.addingTimeInterval(-300),
|
||||
updatedAt: now.addingTimeInterval(-300)
|
||||
createdAt: now.addingTimeInterval(-300)
|
||||
),
|
||||
AppNotification(
|
||||
id: UUID(uuidString: "10000000-0000-0000-0000-000000000003")!,
|
||||
topic: "Fire Alert",
|
||||
userId: mockUserId,
|
||||
scopeId: nil,
|
||||
channel: .apns,
|
||||
contentType: .plain,
|
||||
templateId: nil,
|
||||
subject: "Пожарная тревога",
|
||||
body: "Сработала пожарная сигнализация в серверной. Автоматическая система пожаротушения активирована.",
|
||||
source: "Fire Alert",
|
||||
metadata: ["Здание": "Корпус 9", "Этаж": "12", "Комната": "1A"],
|
||||
status: .read,
|
||||
channel: .push,
|
||||
error: nil,
|
||||
attempts: 1,
|
||||
maxAttempts: 3,
|
||||
nextRetryAt: nil,
|
||||
sentAt: now.addingTimeInterval(-7200),
|
||||
readAt: now.addingTimeInterval(-3600),
|
||||
createdAt: now.addingTimeInterval(-7200),
|
||||
updatedAt: now.addingTimeInterval(-3600)
|
||||
createdAt: now.addingTimeInterval(-7200)
|
||||
),
|
||||
AppNotification(
|
||||
id: UUID(uuidString: "10000000-0000-0000-0000-000000000004")!,
|
||||
topic: "Medical Emergency",
|
||||
userId: mockUserId,
|
||||
scopeId: nil,
|
||||
channel: .apns,
|
||||
contentType: .plain,
|
||||
templateId: nil,
|
||||
subject: "Медицинская помощь",
|
||||
body: "Запрос экстренной медицинской помощи на 3 этаже, кабинет 312. Бригада скорой помощи вызвана.",
|
||||
source: "Medical Emergency",
|
||||
metadata: ["Здание": "Корпус 9", "Этаж": "3", "Комната": "312"],
|
||||
status: .read,
|
||||
channel: .push,
|
||||
error: nil,
|
||||
attempts: 1,
|
||||
maxAttempts: 3,
|
||||
nextRetryAt: nil,
|
||||
sentAt: now.addingTimeInterval(-7200),
|
||||
readAt: now.addingTimeInterval(-5400),
|
||||
createdAt: now.addingTimeInterval(-7200),
|
||||
updatedAt: now.addingTimeInterval(-5400)
|
||||
createdAt: now.addingTimeInterval(-7200)
|
||||
),
|
||||
AppNotification(
|
||||
id: UUID(uuidString: "10000000-0000-0000-0000-000000000005")!,
|
||||
topic: "Water Leak",
|
||||
userId: mockUserId,
|
||||
scopeId: nil,
|
||||
channel: .inApp,
|
||||
contentType: .plain,
|
||||
templateId: nil,
|
||||
subject: "Затопление",
|
||||
body: "Обнаружена утечка воды в подвальном помещении. Аварийная служба на месте.",
|
||||
source: "Water Leak",
|
||||
metadata: ["Здание": "Корпус 3", "Этаж": "B1"],
|
||||
status: .read,
|
||||
channel: .inApp,
|
||||
error: nil,
|
||||
attempts: 1,
|
||||
maxAttempts: 3,
|
||||
nextRetryAt: nil,
|
||||
sentAt: now.addingTimeInterval(-90000),
|
||||
readAt: now.addingTimeInterval(-86400),
|
||||
createdAt: now.addingTimeInterval(-90000),
|
||||
updatedAt: now.addingTimeInterval(-86400)
|
||||
createdAt: now.addingTimeInterval(-90000)
|
||||
),
|
||||
AppNotification(
|
||||
id: UUID(uuidString: "10000000-0000-0000-0000-000000000006")!,
|
||||
topic: "Security Alert",
|
||||
userId: mockUserId,
|
||||
scopeId: nil,
|
||||
channel: .inApp,
|
||||
contentType: .plain,
|
||||
templateId: nil,
|
||||
subject: "Тестирование системы",
|
||||
body: "Плановое тестирование системы оповещения. Действий не требуется.",
|
||||
source: "Security Alert",
|
||||
metadata: nil,
|
||||
status: .read,
|
||||
channel: .inApp,
|
||||
error: nil,
|
||||
attempts: 1,
|
||||
maxAttempts: 3,
|
||||
nextRetryAt: nil,
|
||||
sentAt: now.addingTimeInterval(-180000),
|
||||
readAt: now.addingTimeInterval(-172800),
|
||||
createdAt: now.addingTimeInterval(-180000),
|
||||
updatedAt: now.addingTimeInterval(-172800)
|
||||
createdAt: now.addingTimeInterval(-180000)
|
||||
),
|
||||
]
|
||||
}()
|
||||
|
||||
@@ -32,7 +32,7 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen
|
||||
let token = tokenData.map { String(format: "%02.2hhx", $0) }.joined()
|
||||
deviceToken = token
|
||||
Task {
|
||||
try? await HTTPClient.shared.request(.registerDevice(token: token)) as EmptyResponse
|
||||
try? await HTTPClient.shared.request(.registerDevice(token: token, platform: "ios")) as DeviceToken
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user