feat(outlinekit): add notification subscription plumbing
NotificationEventType (wire keys confirmed live), OutlineUser.notification Settings dictionary, subscribeToNotifications/unsubscribeFromNotifications (users.notificationsSubscribe/Unsubscribe, nil eventType = all — confirmed against the "All notifications" master toggle live too).
This commit is contained in:
@@ -367,6 +367,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
||||
try await live.deleteAccount()
|
||||
}
|
||||
|
||||
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||
try await live.subscribeToNotifications(eventType: eventType)
|
||||
}
|
||||
|
||||
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||
try await live.unsubscribeFromNotifications(eventType: eventType)
|
||||
}
|
||||
|
||||
// MARK: - Sync management (Settings surface)
|
||||
|
||||
public func pendingOperations() async -> [PendingOperationSummary] {
|
||||
|
||||
@@ -91,4 +91,7 @@ public protocol OutlineAPIClient: Sendable {
|
||||
/// confirmation code param confirmed live, matches every other simple
|
||||
/// no-body delete in this API.
|
||||
func deleteAccount() async throws
|
||||
/// `nil` targets every notification event. See `NotificationEventType`.
|
||||
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
|
||||
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser
|
||||
}
|
||||
|
||||
@@ -302,6 +302,14 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
try await postForSuccess("users.delete", body: EmptyParams())
|
||||
}
|
||||
|
||||
public func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||
try await post("users.notificationsSubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
|
||||
}
|
||||
|
||||
public func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser {
|
||||
try await post("users.notificationsUnsubscribe", body: NotificationSubscriptionRequest(eventType: eventType?.rawValue))
|
||||
}
|
||||
|
||||
private func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
|
||||
guard let token = try? tokenStore.token() else {
|
||||
throw OutlineAPIError.tokenUnavailable
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
/// The subset of Outline's notification event types this app exposes a
|
||||
/// toggle for. Wire values confirmed live (captured `users.update`
|
||||
/// responses showing the full `notificationSettings` dictionary after
|
||||
/// toggling each one in Outline's own web app). The server tracks more
|
||||
/// event types than this app has UI for (`revisions.create`,
|
||||
/// `emails.onboarding`, `emails.features` were also seen live) — those are
|
||||
/// left alone since `users.notificationsSubscribe`/`Unsubscribe` are
|
||||
/// per-event, not a whole-object replace like `preferences`, so there's no
|
||||
/// clobbering risk in only covering a subset.
|
||||
public enum NotificationEventType: String, CaseIterable, Sendable {
|
||||
case documentPublish = "documents.publish"
|
||||
case documentUpdate = "documents.update"
|
||||
case commentCreate = "comments.create"
|
||||
case commentMentioned = "comments.mentioned"
|
||||
case documentMentioned = "documents.mentioned"
|
||||
case commentGroupMentioned = "comments.group_mentioned"
|
||||
case documentGroupMentioned = "documents.group_mentioned"
|
||||
case commentResolve = "comments.resolve"
|
||||
case reactionCreate = "reactions.create"
|
||||
case collectionCreate = "collections.create"
|
||||
case emailsInviteAccepted = "emails.invite_accepted"
|
||||
case documentAddUser = "documents.add_user"
|
||||
case collectionAddUser = "collections.add_user"
|
||||
case emailsExportCompleted = "emails.export_completed"
|
||||
case accessRequestCreate = "access_requests.create"
|
||||
}
|
||||
@@ -8,6 +8,10 @@ public struct OutlineUser: Codable, Identifiable, Hashable, Sendable {
|
||||
public let role: String?
|
||||
public let language: String?
|
||||
public let preferences: OutlineUserPreferences?
|
||||
/// Keyed by `NotificationEventType`'s raw values, plus event types this
|
||||
/// app has no UI for — kept as a flexible dictionary rather than a
|
||||
/// fixed struct for that reason. `true` means subscribed.
|
||||
public let notificationSettings: [String: Bool]?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
@@ -16,7 +20,8 @@ public struct OutlineUser: Codable, Identifiable, Hashable, Sendable {
|
||||
avatarUrl: String? = nil,
|
||||
role: String? = nil,
|
||||
language: String? = nil,
|
||||
preferences: OutlineUserPreferences? = nil
|
||||
preferences: OutlineUserPreferences? = nil,
|
||||
notificationSettings: [String: Bool]? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
@@ -25,5 +30,6 @@ public struct OutlineUser: Codable, Identifiable, Hashable, Sendable {
|
||||
self.role = role
|
||||
self.language = language
|
||||
self.preferences = preferences
|
||||
self.notificationSettings = notificationSettings
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
/// `users.notificationsSubscribe` / `users.notificationsUnsubscribe`. A
|
||||
/// `nil` `eventType` targets every notification event at once — confirmed
|
||||
/// live via Outline's own "All notifications" master toggle, which sends
|
||||
/// no `eventType` at all.
|
||||
public struct NotificationSubscriptionRequest: Encodable, Sendable {
|
||||
public let eventType: String?
|
||||
|
||||
public init(eventType: String? = nil) {
|
||||
self.eventType = eventType
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,8 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
|
||||
func updateUserLanguage(_ request: UpdateUserLanguageRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func updateUserPreferences(_ request: UpdateUserPreferencesRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func deleteAccount() async throws { throw NotStubbed() }
|
||||
func subscribeToNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func unsubscribeFromNotifications(eventType: NotificationEventType?) async throws -> OutlineUser { throw NotStubbed() }
|
||||
}
|
||||
|
||||
private struct StubTransportError: Error {}
|
||||
|
||||
@@ -1178,6 +1178,57 @@ final class LiveOutlineAPIClientTests: XCTestCase {
|
||||
XCTAssertNil(object?["smartText"])
|
||||
}
|
||||
|
||||
func testSubscribeToNotificationsSendsEventTypeAndDecodesResult() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "user-1",
|
||||
"name": "Jane Doe",
|
||||
"notificationSettings": { "documents.publish": true }
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
let user = try await client.subscribeToNotifications(eventType: .documentPublish)
|
||||
|
||||
XCTAssertEqual(user.notificationSettings?["documents.publish"], true)
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsSubscribe")
|
||||
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||
XCTAssertEqual(sentBody?["eventType"] as? String, "documents.publish")
|
||||
}
|
||||
|
||||
func testUnsubscribeFromNotificationsWithNilEventTypeTargetsAll() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
{
|
||||
"data": {
|
||||
"id": "user-1",
|
||||
"name": "Jane Doe",
|
||||
"notificationSettings": { "documents.publish": false }
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let client = LiveOutlineAPIClient(
|
||||
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
|
||||
tokenStore: StaticTokenStore(),
|
||||
httpClient: httpClient
|
||||
)
|
||||
|
||||
_ = try await client.unsubscribeFromNotifications(eventType: nil)
|
||||
|
||||
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsUnsubscribe")
|
||||
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
|
||||
XCTAssertNil(sentBody?["eventType"])
|
||||
}
|
||||
|
||||
func testDeleteAccountSendsRequest() async throws {
|
||||
let httpClient = MockHTTPClient()
|
||||
httpClient.responseData = """
|
||||
|
||||
Reference in New Issue
Block a user