diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift index fa0db2f..e745355 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -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] { diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift index c9c6c6f..2130431 100644 --- a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift @@ -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 } diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index adafae2..7af9df1 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -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(_ path: String, body: Body) async throws -> Response { guard let token = try? tokenStore.token() else { throw OutlineAPIError.tokenUnavailable diff --git a/OutlineKit/Sources/OutlineKit/Models/NotificationEventType.swift b/OutlineKit/Sources/OutlineKit/Models/NotificationEventType.swift new file mode 100644 index 0000000..c226a1a --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/NotificationEventType.swift @@ -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" +} diff --git a/OutlineKit/Sources/OutlineKit/Models/OutlineUser.swift b/OutlineKit/Sources/OutlineKit/Models/OutlineUser.swift index af4a899..25466c7 100644 --- a/OutlineKit/Sources/OutlineKit/Models/OutlineUser.swift +++ b/OutlineKit/Sources/OutlineKit/Models/OutlineUser.swift @@ -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 } } diff --git a/OutlineKit/Sources/OutlineKit/Requests/NotificationSubscriptionRequest.swift b/OutlineKit/Sources/OutlineKit/Requests/NotificationSubscriptionRequest.swift new file mode 100644 index 0000000..cd1aace --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Requests/NotificationSubscriptionRequest.swift @@ -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 + } +} diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift index 06912c1..0b685a0 100644 --- a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -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 {} diff --git a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift index 8420f53..7b0ae69 100644 --- a/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift @@ -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 = """