diff --git a/.env.local.example b/.env.local.example index 9ea953262..7d92c7ae2 100644 --- a/.env.local.example +++ b/.env.local.example @@ -86,6 +86,13 @@ LANGFUSE_HOST = https://cloud.langfuse.com # Set to `true` to get error messages rendered in the /chats UI AI_DEBUG_MODE = +# Apple Push Notification service (AI insight notifications) +# APNS_PRIVATE_KEY_BASE64 is the base64-encoded contents of the Apple .p8 key. +APNS_KEY_ID= +APNS_TEAM_ID= +APNS_BUNDLE_ID= +APNS_PRIVATE_KEY_BASE64= + # ============================================================================= # SSL/TLS Configuration for Self-Signed Certificates # ============================================================================= diff --git a/Gemfile b/Gemfile index 4f6053ec5..652f91920 100644 --- a/Gemfile +++ b/Gemfile @@ -59,6 +59,7 @@ gem "image_processing", ">= 1.2" gem "ostruct" gem "bcrypt", "~> 3.1" gem "jwt" +gem "apnotic", "~> 1.8" gem "jbuilder" gem "countries" diff --git a/Gemfile.lock b/Gemfile.lock index 3921af578..f360997d8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -92,6 +92,10 @@ GEM cgi connection_pool standardwebhooks + apnotic (1.8.0) + base64 + connection_pool (>= 2, < 4) + net-http2 (>= 0.18.3, < 2) ast (2.4.3) attr_required (1.0.2) auth-sanitizer (0.2.3) @@ -302,6 +306,7 @@ GEM turbo-rails (>= 1.2) htmlbeautifier (1.4.3) htmlentities (4.3.4) + http-2 (1.2.1) httparty (0.24.0) csv mini_mime (>= 1.0.0) @@ -422,6 +427,8 @@ GEM mutex_m (0.3.0) net-http (0.9.1) uri (>= 0.11.1) + net-http2 (0.19.0) + http-2 (>= 1.0) net-imap (0.6.4.1) date net-protocol @@ -869,6 +876,7 @@ DEPENDENCIES activerecord-import after_commit_everywhere (~> 1.0) anthropic (~> 1.0) + apnotic (~> 1.8) aws-sdk-s3 (~> 1.208.0) bcrypt (~> 3.1) benchmark-ips diff --git a/Project.json b/Project.json new file mode 100644 index 000000000..2d3f128aa --- /dev/null +++ b/Project.json @@ -0,0 +1,46 @@ +{ + "name": "Sure", + "options": { + "deploymentTarget": { + "iOS": "18.0" + } + }, + "settings": { + "base": { + "SWIFT_VERSION": "6.0" + } + }, + "targets": { + "Sure": { + "type": "application", + "platform": "iOS", + "sources": [ + "bitrig/App" + ], + "settings": { + "base": { + "PRODUCT_BUNDLE_IDENTIFIER": "app.bitrig.new.81790e0e362141ce8bc1315a7a361749", + "TARGETED_DEVICE_FAMILY": "1,2", + "ASSETCATALOG_COMPILER_APPICON_NAME": "AppIcon", + "ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME": "AccentColor" + } + }, + "info": { + "path": "bitrig/App/Info.plist", + "properties": { + "CFBundleDisplayName": "Sure", + "UILaunchScreen": {}, + "UIBackgroundModes": [ + "remote-notification" + ] + } + }, + "entitlements": { + "path": "bitrig/App/Sure.entitlements", + "properties": { + "aps-environment": "development" + } + } + } + } +} diff --git a/app/controllers/api/v1/insights_controller.rb b/app/controllers/api/v1/insights_controller.rb new file mode 100644 index 000000000..e7099f9a3 --- /dev/null +++ b/app/controllers/api/v1/insights_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class Api::V1::InsightsController < Api::V1::BaseController + before_action :ensure_read_scope + before_action :require_preview_features_for_api + + def index + insights = current_resource_owner.family.insights.visible.ordered + + render json: { + insights: insights.map { |insight| serialize(insight) } + } + end + + private + def ensure_read_scope + authorize_scope!(:read) + end + + def require_preview_features_for_api + return if current_resource_owner.preview_features_enabled? + + render_json( + { error: "feature_disabled", message: "Preview features are not enabled for this user" }, + status: :forbidden + ) + end + + def serialize(insight) + { + id: insight.id, + type: insight.insight_type, + title: insight.title, + body: insight.body, + priority: insight.priority, + status: insight.status, + generated_at: insight.generated_at&.iso8601 + } + end +end diff --git a/app/controllers/api/v1/push_subscriptions_controller.rb b/app/controllers/api/v1/push_subscriptions_controller.rb new file mode 100644 index 000000000..5f07ecde1 --- /dev/null +++ b/app/controllers/api/v1/push_subscriptions_controller.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class Api::V1::PushSubscriptionsController < Api::V1::BaseController + before_action :ensure_write_scope + + def create + token = subscription_params[:token].to_s.downcase + subscription = current_resource_owner.push_subscriptions.find_or_initialize_by(token: token) + subscription.assign_attributes( + environment: subscription_params[:environment], + platform: subscription_params[:platform], + last_registered_at: Time.current + ) + subscription.save! + + render json: serialize(subscription), status: :created + rescue ActiveRecord::RecordInvalid => e + render json: { error: "validation_error", message: e.record.errors.full_messages.to_sentence }, + status: :unprocessable_entity + rescue ActiveRecord::RecordNotUnique + subscription = current_resource_owner.push_subscriptions.find_by(token: token) + if subscription + subscription.update!( + environment: subscription_params[:environment], + platform: subscription_params[:platform], + last_registered_at: Time.current + ) + render json: serialize(subscription), status: :created + else + render json: { error: "validation_error", message: "Device token is already registered" }, + status: :unprocessable_entity + end + end + + def destroy + current_resource_owner.push_subscriptions.find(params[:id]).destroy! + head :no_content + end + + private + def ensure_write_scope + authorize_scope!(:write) + end + + def subscription_params + params.permit(:token, :environment, :platform) + end + + def serialize(subscription) + { + id: subscription.id, + environment: subscription.environment, + platform: subscription.platform, + last_registered_at: subscription.last_registered_at.iso8601 + } + end +end diff --git a/app/jobs/deliver_insight_notification_job.rb b/app/jobs/deliver_insight_notification_job.rb new file mode 100644 index 000000000..225b49411 --- /dev/null +++ b/app/jobs/deliver_insight_notification_job.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class DeliverInsightNotificationJob < ApplicationJob + queue_as :scheduled + + retry_on StandardError, wait: :polynomially_longer, attempts: 5 + discard_on ActiveRecord::RecordNotFound + + def self.enqueue_for(insight) + return unless Apns::Client.configured? + + insight.family.users.includes(:push_subscriptions).find_each do |user| + next unless user.preview_features_enabled? + + user.push_subscriptions.recent.find_each do |subscription| + perform_later(insight_id: insight.id, push_subscription_id: subscription.id) + end + end + end + + def perform(insight_id:, push_subscription_id:) + insight = Insight.find(insight_id) + subscription = PushSubscription.find(push_subscription_id) + return unless subscription.user.family_id == insight.family_id + + response = I18n.with_locale(insight.family.locale) do + Apns::Client.new(environment: subscription.environment).deliver( + token: subscription.token, + title: I18n.t("insights.notification.title"), + body: I18n.t("insights.notification.body"), + insight_id: insight.id + ) + end + return if response.ok? + + if invalid_token_response?(response) + subscription.destroy! + return + end + + raise "APNs rejected notification with status #{response.status}: #{response.body.inspect}" + rescue => e + DebugLogEntry.capture( + category: "insights", + level: "error", + message: "Failed to deliver insight notification: #{e.class}: #{e.message}", + source: "DeliverInsightNotificationJob", + family: insight&.family, + metadata: { insight_id: insight_id, push_subscription_id: push_subscription_id } + ) + raise + end + + private + def invalid_token_response?(response) + response.status == "410" || + (response.status == "400" && response.body.is_a?(Hash) && response.body["reason"] == "BadDeviceToken") + end +end diff --git a/app/jobs/generate_insights_job.rb b/app/jobs/generate_insights_job.rb index 311071950..c9a36f681 100644 --- a/app/jobs/generate_insights_job.rb +++ b/app/jobs/generate_insights_job.rb @@ -37,11 +37,12 @@ class GenerateInsightsJob < ApplicationJob # broadcast below too, not just the generation. return unless family.preview_features_enabled? - with_advisory_lock(family_id) do + notifiable_insights = with_advisory_lock(family_id) do I18n.with_locale(family.locale) do result = Insight::GeneratorRegistry.new(family).generate_all - upsert_insights(family, result.insights) + created_or_resurfaced = upsert_insights(family, result.insights) expire_stale_insights(family, result) + created_or_resurfaced end end @@ -49,6 +50,7 @@ class GenerateInsightsJob < ApplicationJob # current state, so a subscribed /insights page (waiting on its manual # refresh) always gets its list and button restored. broadcast_feed(family) + Array(notifiable_insights).each { |insight| DeliverInsightNotificationJob.enqueue_for(insight) } end def broadcast_feed(family) @@ -84,7 +86,7 @@ class GenerateInsightsJob < ApplicationJob def upsert_insights(family, generated_insights) writer = Insight::BodyWriter.new(family) - generated_insights.each do |generated| + generated_insights.filter_map do |generated| metadata = normalize_json(generated.metadata) facts = normalize_json(generated.facts) existing = family.insights.find_by(dedup_key: generated.dedup_key) @@ -120,11 +122,13 @@ class GenerateInsightsJob < ApplicationJob read_at: nil, dismissed_at: nil ) + existing elsif existing.expired? # The condition cleared earlier and has now returned with the same # numbers. Expiry was the system's doing, not the user's, so the # insight resurfaces; the body is still accurate, so no rewrite. existing.update!(status: "active", facts: facts, generated_at: Time.current, read_at: nil) + existing else # Same signal, same numbers: don't rewrite the body (avoids an LLM # call) and don't undo the user's read/dismissed state. Facts still @@ -132,10 +136,11 @@ class GenerateInsightsJob < ApplicationJob # keeping them current is exactly why they're not part of the # material-change comparison. existing.update!(facts: facts, generated_at: Time.current) + nil end rescue ActiveRecord::RecordNotUnique # A concurrent run created the same dedup_key first; it owns this row. - next + nil rescue => e DebugLogEntry.capture( category: "insights", @@ -145,6 +150,7 @@ class GenerateInsightsJob < ApplicationJob family: family, metadata: { dedup_key: generated.dedup_key, insight_type: generated.insight_type } ) + nil end end diff --git a/app/models/push_subscription.rb b/app/models/push_subscription.rb new file mode 100644 index 000000000..bd5c91c7e --- /dev/null +++ b/app/models/push_subscription.rb @@ -0,0 +1,14 @@ +class PushSubscription < ApplicationRecord + belongs_to :user + + enum :environment, { sandbox: "sandbox", production: "production" }, validate: true + + normalizes :token, with: ->(token) { token.downcase } + + validates :token, presence: true, uniqueness: { case_sensitive: false }, + format: { with: /\A[0-9a-f]{64,200}\z/i } + validates :platform, inclusion: { in: %w[ios] } + validates :last_registered_at, presence: true + + scope :recent, -> { where("last_registered_at > ?", 90.days.ago) } +end diff --git a/app/models/user.rb b/app/models/user.rb index b87d7c3e4..d1a994f0d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -25,6 +25,7 @@ class User < ApplicationRecord has_many :sessions, dependent: :destroy has_many :chats, dependent: :destroy has_many :api_keys, dependent: :destroy + has_many :push_subscriptions, dependent: :destroy has_many :webauthn_credentials, dependent: :destroy has_many :mobile_devices, dependent: :destroy has_many :invitations, foreign_key: :inviter_id, dependent: :destroy diff --git a/app/services/apns/client.rb b/app/services/apns/client.rb new file mode 100644 index 000000000..0b2d93f8f --- /dev/null +++ b/app/services/apns/client.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "apnotic" +require "base64" +require "stringio" + +module Apns + class Client + REQUIRED_ENV_KEYS = %w[APNS_KEY_ID APNS_TEAM_ID APNS_BUNDLE_ID APNS_PRIVATE_KEY_BASE64].freeze + + def self.configured? + REQUIRED_ENV_KEYS.all? { |key| ENV[key].present? } + end + + def initialize(environment:) + @environment = environment.to_sym + end + + def deliver(token:, title:, body:, insight_id:) + raise "APNs credentials are not configured" unless self.class.configured? + + connection = build_connection + notification = Apnotic::Notification.new(token) + notification.alert = { title: title, body: body } + notification.sound = "default" + notification.topic = ENV.fetch("APNS_BUNDLE_ID") + notification.push_type = "alert" + notification.apns_collapse_id = "insight-#{insight_id}" + notification.custom_payload = { insight_id: insight_id, destination: "insights" } + + connection.push(notification).tap do |response| + raise "APNs request timed out" unless response + end + ensure + connection&.close + end + + private + def build_connection + options = { + auth_method: :token, + cert_path: StringIO.new(Base64.strict_decode64(ENV.fetch("APNS_PRIVATE_KEY_BASE64"))), + key_id: ENV.fetch("APNS_KEY_ID"), + team_id: ENV.fetch("APNS_TEAM_ID") + } + + @environment == :sandbox ? Apnotic::Connection.development(options) : Apnotic::Connection.new(options) + end + end +end diff --git a/bitrig/App/APIClient.swift b/bitrig/App/APIClient.swift new file mode 100644 index 000000000..c9da6b754 --- /dev/null +++ b/bitrig/App/APIClient.swift @@ -0,0 +1,109 @@ +import Foundation + +actor SureAPIClient { + private var baseURL: URL + private var apiKey: String + private var session: URLSession + private var decoder: JSONDecoder + + init(baseURL: URL, apiKey: String) { + self.baseURL = baseURL + self.apiKey = apiKey + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 30 + configuration.waitsForConnectivity = true + session = URLSession(configuration: configuration) + decoder = JSONDecoder() + } + + func update(baseURL: URL, apiKey: String) { + self.baseURL = baseURL + self.apiKey = apiKey + } + + func get(_ path: String, as type: T.Type = T.self) async throws -> T { + try await request(path: path, method: "GET", body: Optional.none, as: type) + } + + func post( + _ path: String, + body: Body, + as type: Response.Type = Response.self + ) async throws -> Response { + try await request(path: path, method: "POST", body: body, as: type) + } + + func postWithoutResponse(_ path: String, body: Body) async throws { + let _: EmptyResponse = try await request(path: path, method: "POST", body: body, as: EmptyResponse.self) + } + + func delete(_ path: String) async throws { + let _: EmptyResponse = try await request( + path: path, + method: "DELETE", + body: Optional.none, + as: EmptyResponse.self + ) + } + + private func request( + path: String, + method: String, + body: Body?, + as type: Response.Type + ) async throws -> Response { + let cleanedPath = path.hasPrefix("/") ? String(path.dropFirst()) : path + guard let url = URL(string: cleanedPath, relativeTo: baseURL)?.absoluteURL else { + throw SureAPIError.invalidURL + } + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(apiKey, forHTTPHeaderField: "X-Api-Key") + if let body { + request.httpBody = try JSONEncoder().encode(body) + } + + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw SureAPIError.invalidResponse + } + guard 200..<300 ~= httpResponse.statusCode else { + let serverError = try? decoder.decode(ServerError.self, from: data) + throw SureAPIError.server(status: httpResponse.statusCode, message: serverError?.message ?? serverError?.error) + } + if Response.self == EmptyResponse.self, data.isEmpty { + return EmptyResponse() as! Response + } + do { + return try decoder.decode(Response.self, from: data) + } catch { + throw SureAPIError.decoding(error.localizedDescription) + } + } +} + +struct EmptyResponse: Codable, Sendable {} + +struct ServerError: Codable, Sendable { + var error: String? + var message: String? +} + +enum SureAPIError: LocalizedError, Equatable { + case invalidURL + case invalidResponse + case server(status: Int, message: String?) + case decoding(String) + + var errorDescription: String? { + switch self { + case .invalidURL: "The server address is invalid." + case .invalidResponse: "The server returned an invalid response." + case let .server(status, message): + message ?? "The server returned an error (\(status))." + case let .decoding(message): "Sure returned data this app could not read: \(message)" + } + } +} diff --git a/bitrig/App/Assets.xcassets/AccentColor.colorset/Contents.json b/bitrig/App/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..1059c7c1f --- /dev/null +++ b/bitrig/App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.850", + "green" : "0.250", + "red" : "0.290" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/bitrig/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/bitrig/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..b3f44eb29 --- /dev/null +++ b/bitrig/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "Icon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/bitrig/App/Assets.xcassets/AppIcon.appiconset/Icon.png b/bitrig/App/Assets.xcassets/AppIcon.appiconset/Icon.png new file mode 100644 index 000000000..6c64ce526 Binary files /dev/null and b/bitrig/App/Assets.xcassets/AppIcon.appiconset/Icon.png differ diff --git a/bitrig/App/Assets.xcassets/Contents.json b/bitrig/App/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/bitrig/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/bitrig/App/Info.plist b/bitrig/App/Info.plist new file mode 100644 index 000000000..0c67376eb --- /dev/null +++ b/bitrig/App/Info.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/bitrig/App/KeychainStore.swift b/bitrig/App/KeychainStore.swift new file mode 100644 index 000000000..d3cdab223 --- /dev/null +++ b/bitrig/App/KeychainStore.swift @@ -0,0 +1,49 @@ +import Foundation +import Security + +enum KeychainStore { + private static var service: String { "am.sure.native" } + + static func saveAPIKey(_ value: String) throws { + guard let data = value.data(using: .utf8) else { return } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: "api-key" + ] + SecItemDelete(query as CFDictionary) + var attributes = query + attributes[kSecValueData as String] = data + attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let status = SecItemAdd(attributes as CFDictionary, nil) + guard status == errSecSuccess else { throw KeychainError(status: status) } + } + + static func readAPIKey() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: "api-key", + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + static func deleteAPIKey() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: "api-key" + ] + SecItemDelete(query as CFDictionary) + } +} + +struct KeychainError: LocalizedError { + var status: OSStatus + var errorDescription: String? { "Could not securely save the API key (\(status))." } +} diff --git a/bitrig/App/Models.swift b/bitrig/App/Models.swift new file mode 100644 index 000000000..010c7675f --- /dev/null +++ b/bitrig/App/Models.swift @@ -0,0 +1,184 @@ +import Foundation + +struct MoneyValue: Codable, Sendable { + var amount: String + var currency: String + + var decimalAmount: Decimal { + Decimal(string: amount) ?? 0 + } + + var formatted: String { + decimalAmount.formatted(.currency(code: currency).precision(.fractionLength(0...2))) + } +} + +struct BalanceSheet: Codable, Sendable { + var currency: String + var netWorth: MoneyValue + var assets: MoneyValue + var liabilities: MoneyValue + + enum CodingKeys: String, CodingKey { + case currency + case netWorth = "net_worth" + case assets + case liabilities + } +} + +struct SureAccount: Codable, Identifiable, Sendable { + var id: String + var name: String + var balance: String + var balanceCents: Int + var cashBalance: String + var cashBalanceCents: Int + var currency: String + var classification: String + var accountType: String? + var subtype: String? + var status: String + var institutionName: String? + + enum CodingKeys: String, CodingKey { + case id, name, balance, currency, classification, subtype, status + case balanceCents = "balance_cents" + case cashBalance = "cash_balance" + case cashBalanceCents = "cash_balance_cents" + case accountType = "account_type" + case institutionName = "institution_name" + } + + var isAsset: Bool { classification == "asset" } +} + +struct AccountCollection: Codable, Sendable { + var accounts: [SureAccount] + var pagination: SurePagination? +} + +struct SureBudget: Codable, Identifiable, Sendable { + var id: String + var name: String + var currency: String + var current: Bool + var budgetedSpending: String? + var allocatedSpending: String + var startDate: String + var endDate: String + + enum CodingKeys: String, CodingKey { + case id, name, currency, current + case budgetedSpending = "budgeted_spending" + case allocatedSpending = "allocated_spending" + case startDate = "start_date" + case endDate = "end_date" + } +} + +struct BudgetCollection: Codable, Sendable { + var budgets: [SureBudget] + var pagination: SurePagination? +} + +struct SureInsight: Codable, Identifiable, Sendable, Equatable { + var id: String + var type: String + var title: String + var body: String + var priority: String + var status: String + var generatedAt: String? + + enum CodingKeys: String, CodingKey { + case id, type, title, body, priority, status + case generatedAt = "generated_at" + } + + var symbol: String { + switch type { + case "cash_flow_warning", "budget_at_risk": "exclamationmark.triangle.fill" + case "net_worth_milestone", "budget_on_track": "sparkles" + case "subscription_audit": "repeat" + case "idle_cash": "banknote.fill" + default: "chart.line.uptrend.xyaxis" + } + } +} + +struct InsightCollection: Codable, Sendable { + var insights: [SureInsight] +} + +struct SureChat: Codable, Identifiable, Sendable, Hashable { + var id: String + var title: String + var error: String? + var createdAt: String + var updatedAt: String + var lastMessageAt: String? + var messageCount: Int? + + enum CodingKeys: String, CodingKey { + case id, title, error + case createdAt = "created_at" + case updatedAt = "updated_at" + case lastMessageAt = "last_message_at" + case messageCount = "message_count" + } +} + +struct ChatCollection: Codable, Sendable { + var chats: [SureChat] + var pagination: SurePagination? +} + +struct SurePagination: Codable, Sendable { + var page: Int + var perPage: Int + var totalCount: Int + var totalPages: Int + + enum CodingKeys: String, CodingKey { + case page + case perPage = "per_page" + case totalCount = "total_count" + case totalPages = "total_pages" + } +} + +struct SureMessage: Codable, Identifiable, Sendable, Equatable { + var id: String + var type: String + var role: String + var content: String + var createdAt: String + + enum CodingKeys: String, CodingKey { + case id, type, role, content + case createdAt = "created_at" + } + + var isUser: Bool { role == "user" || type == "user_message" } +} + +struct ChatDetail: Codable, Sendable { + var id: String + var title: String + var error: String? + var createdAt: String + var updatedAt: String + var messages: [SureMessage] + var pagination: SurePagination? + + enum CodingKeys: String, CodingKey { + case id, title, error, messages, pagination + case createdAt = "created_at" + case updatedAt = "updated_at" + } + + var chat: SureChat { + SureChat(id: id, title: title, error: error, createdAt: createdAt, updatedAt: updatedAt) + } +} diff --git a/bitrig/App/Sure.entitlements b/bitrig/App/Sure.entitlements new file mode 100644 index 000000000..0c67376eb --- /dev/null +++ b/bitrig/App/Sure.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/bitrig/App/SureAccountsView.swift b/bitrig/App/SureAccountsView.swift new file mode 100644 index 000000000..fc04f24cc --- /dev/null +++ b/bitrig/App/SureAccountsView.swift @@ -0,0 +1,74 @@ +import SwiftUI + +struct SureAccountsView: View { + @Environment(SureStore.self) private var store + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(store.accounts) { account in + SureAccountRow(account: account) + .padding(.horizontal) + } + if store.accounts.isEmpty && !store.isLoading { + ContentUnavailableView( + "No accounts", + systemImage: "building.columns", + description: Text("Add or link an account in Sure, then pull to refresh.") + ) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical) + } + .refreshable { await store.refreshAll() } + .navigationTitle("Accounts") + } + } +} + +struct SureAccountRow: View { + var account: SureAccount + + var body: some View { + HStack(spacing: 14) { + Image(systemName: symbol) + .font(.title3) + .foregroundStyle(.tint) + .frame(width: 42, height: 42) + .background(Color.sureIndigo.opacity(0.1), in: .circle) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 3) { + Text(account.name) + .font(.headline) + Text(account.institutionName ?? accountTypeLabel) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Text(account.balance) + .font(.headline.monospacedDigit()) + } + .padding(14) + .background(.background.secondary, in: .rect(cornerRadius: 16)) + .accessibilityElement(children: .combine) + } + + private var symbol: String { + switch account.accountType { + case "investment": "chart.line.uptrend.xyaxis" + case "credit_card": "creditcard.fill" + case "property": "house.fill" + case "vehicle": "car.fill" + case "loan": "doc.text.fill" + default: account.isAsset ? "banknote.fill" : "arrow.down.right" + } + } + + private var accountTypeLabel: String { + (account.subtype ?? account.accountType ?? account.classification) + .replacingOccurrences(of: "_", with: " ") + .capitalized + } +} diff --git a/bitrig/App/SureApp.swift b/bitrig/App/SureApp.swift new file mode 100644 index 000000000..06cc5e266 --- /dev/null +++ b/bitrig/App/SureApp.swift @@ -0,0 +1,75 @@ +import SwiftUI +import UserNotifications + +@main +struct SureApp: App { + @UIApplicationDelegateAdaptor(SureAppDelegate.self) private var appDelegate + @State private var store = SureStore() + + var body: some Scene { + WindowGroup { + SureRootView() + .environment(store) + .tint(Color.sureIndigo) + .task { + await store.restoreSession() + } + .onReceive(NotificationCenter.default.publisher(for: .sureDeviceTokenChanged)) { notification in + guard let token = notification.object as? String else { return } + Task { await store.registerPushToken(token) } + } + } + } +} + +final class SureAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + UNUserNotificationCenter.current().delegate = self + Task { + let settings = await UNUserNotificationCenter.current().notificationSettings() + if settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional { + await MainActor.run { application.registerForRemoteNotifications() } + } + } + return true + } + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + let token = deviceToken.map { String(format: "%02x", $0) }.joined() + UserDefaults.standard.set(token, forKey: "sure.apnsDeviceToken") + NotificationCenter.default.post(name: .sureDeviceTokenChanged, object: token) + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound, .badge]) + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + NotificationCenter.default.post(name: .sureOpenInsights, object: nil) + completionHandler() + } +} + +extension Notification.Name { + static var sureDeviceTokenChanged: Notification.Name { Notification.Name("sureDeviceTokenChanged") } + static var sureOpenInsights: Notification.Name { Notification.Name("sureOpenInsights") } +} + +extension Color { + static var sureIndigo: Color { Color(red: 0.29, green: 0.25, blue: 0.85) } + static var sureMint: Color { Color(red: 0.22, green: 0.73, blue: 0.58) } +} diff --git a/bitrig/App/SureAssistantView.swift b/bitrig/App/SureAssistantView.swift new file mode 100644 index 000000000..64ba48e71 --- /dev/null +++ b/bitrig/App/SureAssistantView.swift @@ -0,0 +1,170 @@ +import SwiftUI + +struct SureAssistantView: View { + @Environment(SureStore.self) private var store + @State private var draft = "" + @FocusState private var composerFocused: Bool + + var body: some View { + NavigationStack { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 14) { + if store.messages.isEmpty { + VStack(spacing: 16) { + Image(systemName: "sparkles") + .font(.system(size: 46)) + .foregroundStyle(.tint) + Text("Ask about your money") + .font(.title2.bold()) + Text("Sure’s Assistant can analyze your live accounts, spending, budgets, investments, and insights.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + VStack(spacing: 10) { + suggestion("What should I pay attention to this month?") + suggestion("How diversified are my investments?") + suggestion("Find easy ways I could cut costs") + } + } + .frame(maxWidth: 560) + .padding(.horizontal, 24) + .padding(.top, 52) + } else { + ForEach(store.messages) { message in + SureMessageBubble(message: message) + .id(message.id) + } + if store.isAssistantThinking { + HStack(spacing: 8) { + ProgressView() + Text("Thinking…") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .id("thinking") + } + } + } + .frame(maxWidth: .infinity) + .padding(.vertical) + } + .onChange(of: store.messages.count) { + if let id = store.messages.last?.id { + withAnimation(.smooth) { proxy.scrollTo(id, anchor: .bottom) } + } + } + .onChange(of: store.isAssistantThinking) { + if store.isAssistantThinking { + withAnimation(.smooth) { proxy.scrollTo("thinking", anchor: .bottom) } + } + } + } + .safeAreaInset(edge: .bottom) { + composer + } + .navigationTitle(store.selectedChat?.title ?? "Assistant") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Picker("Conversation", selection: chatSelection) { + Text("New conversation").tag(Optional.none) + ForEach(store.chats) { chat in + Text(chat.title).tag(Optional(chat.id)) + } + } + .pickerStyle(.menu) + .labelsHidden() + + Button("New conversation", systemImage: "square.and.pencil") { + store.newChat() + } + .labelStyle(.iconOnly) + } + } + } + } + + private var composer: some View { + HStack(alignment: .bottom, spacing: 10) { + TextField("Message Sure Assistant", text: $draft, axis: .vertical) + .lineLimit(1...5) + .padding(.horizontal, 14) + .padding(.vertical, 11) + .background(.background.secondary, in: .rect(cornerRadius: 18)) + .focused($composerFocused) + .submitLabel(.send) + .onSubmit(send) + Button(action: send) { + Image(systemName: "arrow.up") + .font(.headline) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + .background(Color.sureIndigo, in: .circle) + } + .accessibilityLabel("Send message") + .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || store.isAssistantThinking) + } + .padding(.horizontal) + .padding(.vertical, 10) + .background(.bar) + } + + private var chatSelection: Binding { + Binding { + store.selectedChat?.id + } set: { id in + guard let id, let chat = store.chats.first(where: { $0.id == id }) else { + store.newChat() + return + } + Task { await store.loadChat(chat) } + } + } + + private func suggestion(_ text: String) -> some View { + Button { + draft = text + send() + } label: { + HStack { + Text(text) + .multilineTextAlignment(.leading) + Spacer() + Image(systemName: "arrow.up.right") + } + .padding(14) + .background(Color.sureIndigo.opacity(0.08), in: .rect(cornerRadius: 14)) + } + .buttonStyle(.plain) + } + + private func send() { + let message = draft + guard !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + draft = "" + Task { await store.sendMessage(message) } + } +} + +struct SureMessageBubble: View { + var message: SureMessage + + var body: some View { + HStack { + if message.isUser { Spacer(minLength: 44) } + Text(LocalizedStringKey(message.content)) + .textSelection(.enabled) + .padding(14) + .foregroundStyle(message.isUser ? Color.white : Color.primary) + .background( + message.isUser ? AnyShapeStyle(Color.sureIndigo) : AnyShapeStyle(.background.secondary), + in: .rect(cornerRadius: 18) + ) + if !message.isUser { Spacer(minLength: 44) } + } + .padding(.horizontal) + .accessibilityElement(children: .combine) + .accessibilityLabel(message.isUser ? "You: \(message.content)" : "Sure Assistant: \(message.content)") + } +} diff --git a/bitrig/App/SureBudgetsView.swift b/bitrig/App/SureBudgetsView.swift new file mode 100644 index 000000000..53aad34ca --- /dev/null +++ b/bitrig/App/SureBudgetsView.swift @@ -0,0 +1,52 @@ +import SwiftUI + +struct SureBudgetsView: View { + @Environment(SureStore.self) private var store + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(spacing: 14) { + ForEach(store.budgets) { budget in + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(budget.name) + .font(.headline) + Text("\(budget.startDate) – \(budget.endDate)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if budget.current { + Text("CURRENT") + .font(.caption2.bold()) + .foregroundStyle(Color.sureMint) + } + } + Divider() + LabeledContent("Allocated", value: budget.allocatedSpending) + if let planned = budget.budgetedSpending { + LabeledContent("Planned", value: planned) + } + } + .padding(18) + .background(.background.secondary, in: .rect(cornerRadius: 18)) + .accessibilityElement(children: .combine) + } + if store.budgets.isEmpty && !store.isLoading { + ContentUnavailableView( + "No budgets yet", + systemImage: "chart.pie", + description: Text("Create a budget in Sure to track it here.") + ) + } + } + .frame(maxWidth: .infinity) + .padding() + } + .refreshable { await store.refreshAll() } + .navigationTitle("Budgets") + } + } +} diff --git a/bitrig/App/SureMainTabView.swift b/bitrig/App/SureMainTabView.swift new file mode 100644 index 000000000..8b550bbfd --- /dev/null +++ b/bitrig/App/SureMainTabView.swift @@ -0,0 +1,29 @@ +import SwiftUI + +struct SureMainTabView: View { + @Environment(SureStore.self) private var store + + var body: some View { + @Bindable var store = store + TabView(selection: $store.selectedTab) { + SureOverviewView() + .tabItem { Label("Overview", systemImage: "square.grid.2x2.fill") } + .tag(SureTab.overview) + + SureAccountsView() + .tabItem { Label("Accounts", systemImage: "building.columns.fill") } + .tag(SureTab.accounts) + + SureBudgetsView() + .tabItem { Label("Budgets", systemImage: "chart.pie.fill") } + .tag(SureTab.budgets) + + SureAssistantView() + .tabItem { Label("Assistant", systemImage: "sparkles") } + .tag(SureTab.assistant) + } + .onReceive(NotificationCenter.default.publisher(for: .sureOpenInsights)) { _ in + store.selectedTab = .overview + } + } +} diff --git a/bitrig/App/SureOverviewView.swift b/bitrig/App/SureOverviewView.swift new file mode 100644 index 000000000..ad7796e94 --- /dev/null +++ b/bitrig/App/SureOverviewView.swift @@ -0,0 +1,137 @@ +import SwiftUI + +struct SureOverviewView: View { + @Environment(SureStore.self) private var store + @State private var showingSettings = false + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(alignment: .leading, spacing: 22) { + if !store.insights.isEmpty { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("AI insights", systemImage: "sparkles") + .font(.title2.bold()) + Spacer() + Text("LIVE") + .font(.caption2.bold()) + .foregroundStyle(Color.sureMint) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.sureMint.opacity(0.12), in: .capsule) + } + Text("Proactive signals from your latest Sure data") + .font(.subheadline) + .foregroundStyle(.secondary) + + ForEach(store.insights) { insight in + SureInsightCard(insight: insight) + } + } + } + + if let balance = store.balanceSheet { + VStack(alignment: .leading, spacing: 18) { + Text("Net worth") + .font(.headline) + .foregroundStyle(.secondary) + Text(balance.netWorth.formatted) + .font(.system(.largeTitle, design: .rounded, weight: .bold)) + .contentTransition(.numericText()) + + HStack(spacing: 12) { + SureMetricCard(title: "Assets", value: balance.assets.formatted, color: Color.sureMint) + SureMetricCard(title: "Liabilities", value: balance.liabilities.formatted, color: .orange) + } + } + .padding(20) + .background(.background.secondary, in: .rect(cornerRadius: 22)) + } + + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Accounts") + .font(.title2.bold()) + Spacer() + Button("See all") { store.selectedTab = .accounts } + } + ForEach(store.accounts.prefix(4)) { account in + SureAccountRow(account: account) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .refreshable { await store.refreshAll() } + .navigationTitle("Overview") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Settings", systemImage: "gearshape") { showingSettings = true } + .labelStyle(.iconOnly) + } + } + .sheet(isPresented: $showingSettings) { + SureSettingsView() + } + .overlay { + if store.isLoading && store.balanceSheet == nil { + ProgressView("Loading your finances…") + } + } + } + } +} + +struct SureInsightCard: View { + var insight: SureInsight + + var body: some View { + HStack(alignment: .top, spacing: 14) { + Image(systemName: insight.symbol) + .font(.title3) + .foregroundStyle(insight.priority == "high" ? Color.orange : Color.sureIndigo) + .frame(width: 36, height: 36) + .background( + (insight.priority == "high" ? Color.orange : Color.sureIndigo).opacity(0.12), + in: .circle + ) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 5) { + Text(insight.title) + .font(.headline) + Text(insight.body) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(Color.sureIndigo.opacity(0.055), in: .rect(cornerRadius: 18)) + .accessibilityElement(children: .combine) + } +} + +struct SureMetricCard: View { + var title: String + var value: String + var color: Color + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(color.opacity(0.1), in: .rect(cornerRadius: 14)) + .accessibilityElement(children: .combine) + } +} diff --git a/bitrig/App/SureRootView.swift b/bitrig/App/SureRootView.swift new file mode 100644 index 000000000..697154a1d --- /dev/null +++ b/bitrig/App/SureRootView.swift @@ -0,0 +1,16 @@ +import SwiftUI + +struct SureRootView: View { + @Environment(SureStore.self) private var store + + var body: some View { + Group { + if store.isConfigured { + SureMainTabView() + } else { + SureSetupView() + } + } + .animation(.smooth, value: store.isConfigured) + } +} diff --git a/bitrig/App/SureSettingsView.swift b/bitrig/App/SureSettingsView.swift new file mode 100644 index 000000000..48c627245 --- /dev/null +++ b/bitrig/App/SureSettingsView.swift @@ -0,0 +1,96 @@ +import SwiftUI +import UserNotifications + +struct SureSettingsView: View { + @Environment(SureStore.self) private var store + @Environment(\.dismiss) private var dismiss + @AppStorage("sure.insightNotifications") private var insightNotifications = false + @State private var notificationStatus = "" + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 8) { + Text("Connection") + .font(.headline) + Label(store.baseURLString, systemImage: "lock.fill") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + .background(.background.secondary, in: .rect(cornerRadius: 18)) + + VStack(alignment: .leading, spacing: 12) { + Toggle("AI insight notifications", systemImage: "bell.badge.fill", isOn: $insightNotifications) + .onChange(of: insightNotifications) { + Task { await updateNotificationPreference() } + } + Text("Get notified when Sure finds a new proactive financial insight. You stay in control in iOS Settings.") + .font(.subheadline) + .foregroundStyle(.secondary) + if !notificationStatus.isEmpty { + Text(notificationStatus) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(18) + .background(.background.secondary, in: .rect(cornerRadius: 18)) + + Button(role: .destructive) { + Task { + await store.disconnect() + dismiss() + } + } label: { + Label("Disconnect this device", systemImage: "rectangle.portrait.and.arrow.right") + .frame(maxWidth: .infinity) + .padding(14) + .background(Color.red.opacity(0.09), in: .rect(cornerRadius: 14)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + .task { await readNotificationStatus() } + } + } + + private func updateNotificationPreference() async { + if insightNotifications { + do { + let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) + insightNotifications = granted + if granted { + await MainActor.run { UIApplication.shared.registerForRemoteNotifications() } + notificationStatus = "Notifications are enabled." + } else { + notificationStatus = "Notifications are off in iOS Settings." + } + } catch { + insightNotifications = false + notificationStatus = error.localizedDescription + } + } else { + await store.unregisterPushToken() + notificationStatus = "Notifications are disabled for this device." + } + } + + private func readNotificationStatus() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + if settings.authorizationStatus == .denied { + insightNotifications = false + notificationStatus = "Notifications are off in iOS Settings." + } + } +} diff --git a/bitrig/App/SureSetupView.swift b/bitrig/App/SureSetupView.swift new file mode 100644 index 000000000..87dc3fbe8 --- /dev/null +++ b/bitrig/App/SureSetupView.swift @@ -0,0 +1,91 @@ +import SwiftUI + +struct SureSetupView: View { + @Environment(SureStore.self) private var store + @State private var server = "https://demo.sure.am" + @State private var apiKey = "" + @FocusState private var focusedField: SetupField? + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 10) { + Image(systemName: "circle.hexagongrid.fill") + .font(.system(size: 52)) + .foregroundStyle(.tint) + .accessibilityHidden(true) + Text("Your finances, made clear") + .font(.largeTitle.bold()) + Text("Connect securely to Sure with an API key. Your key stays in this device’s Keychain.") + .font(.title3) + .foregroundStyle(.secondary) + } + + VStack(spacing: 16) { + TextField("Sure server", text: $server) + .textContentType(.URL) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focusedField, equals: .server) + .accessibilityLabel("Sure server address") + SecureField("Read/write API key", text: $apiKey) + .textContentType(.password) + .focused($focusedField, equals: .apiKey) + .accessibilityLabel("Sure read/write API key") + } + .padding(18) + .background(.background.secondary, in: .rect(cornerRadius: 20)) + + VStack(alignment: .leading, spacing: 10) { + Label("Connect to the demo", systemImage: "person.badge.key.fill") + .font(.headline) + Text("Sign in through the Sure demo, then create a read/write key in Settings → API keys. Paste that key above.") + .font(.subheadline) + .foregroundStyle(.secondary) + Link("Open Sure demo", destination: URL(string: "https://demo.sure.am")!) + .font(.subheadline.weight(.semibold)) + } + .padding(16) + .background(Color.sureIndigo.opacity(0.09), in: .rect(cornerRadius: 16)) + + if let errorMessage = store.errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle.fill") + .font(.subheadline) + .foregroundStyle(.red) + .accessibilityAddTraits(.isStaticText) + } + + Button { + focusedField = nil + Task { await store.connect(baseURL: server, apiKey: apiKey) } + } label: { + HStack { + if store.isLoading { + ProgressView() + .tint(.white) + } + Text(store.isLoading ? "Connecting…" : "Connect to Sure") + .fontWeight(.semibold) + Spacer() + Image(systemName: "arrow.right") + } + .foregroundStyle(.white) + .padding() + .background(Color.sureIndigo, in: .rect(cornerRadius: 16)) + } + .disabled(apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || store.isLoading) + } + .frame(maxWidth: 620, alignment: .leading) + .padding(24) + } + .navigationTitle("Sure") + } + } +} + +private enum SetupField { + case server + case apiKey +} diff --git a/bitrig/App/SureStore.swift b/bitrig/App/SureStore.swift new file mode 100644 index 000000000..accf67c68 --- /dev/null +++ b/bitrig/App/SureStore.swift @@ -0,0 +1,475 @@ +import Foundation +import Observation +import UserNotifications +import UIKit + +@MainActor +@Observable +final class SureStore { + var isConfigured = false + var isLoading = false + var errorMessage: String? + var balanceSheet: BalanceSheet? + var accounts: [SureAccount] = [] + var budgets: [SureBudget] = [] + var insights: [SureInsight] = [] + var chats: [SureChat] = [] + var selectedChat: SureChat? + var messages: [SureMessage] = [] + var isAssistantThinking = false + var selectedTab = SureTab.overview + + private(set) var baseURLString = "https://demo.sure.am" + private var client = SureAPIClient(baseURL: URL(string: "https://demo.sure.am")!, apiKey: "") + private var sessionGeneration = 0 + private var chatGeneration = 0 + + func restoreSession() async { + let generation = sessionGeneration + baseURLString = UserDefaults.standard.string(forKey: "sure.baseURL") ?? "https://demo.sure.am" + guard let key = KeychainStore.readAPIKey(), !key.isEmpty, + let url = normalizedURL(baseURLString) else { return } + await client.update(baseURL: url, apiKey: key) + guard sessionGeneration == generation else { return } + do { + let balance: BalanceSheet = try await client.get("api/v1/balance_sheet") + guard sessionGeneration == generation else { return } + balanceSheet = balance + isConfigured = true + await refreshAll() + } catch { + guard sessionGeneration == generation else { return } + errorMessage = "Your saved connection needs attention. \(error.localizedDescription)" + } + } + + func connect(baseURL: String, apiKey: String) async -> Bool { + guard let url = normalizedURL(baseURL) else { + errorMessage = "Enter a valid HTTPS server address." + return false + } + sessionGeneration &+= 1 + chatGeneration &+= 1 + let generation = sessionGeneration + isLoading = true + errorMessage = nil + await client.update(baseURL: url, apiKey: apiKey.trimmingCharacters(in: .whitespacesAndNewlines)) + guard sessionGeneration == generation else { return false } + do { + let balance: BalanceSheet = try await client.get("api/v1/balance_sheet") + guard sessionGeneration == generation else { return false } + balanceSheet = balance + try KeychainStore.saveAPIKey(apiKey.trimmingCharacters(in: .whitespacesAndNewlines)) + baseURLString = url.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + UserDefaults.standard.set(baseURLString, forKey: "sure.baseURL") + isConfigured = true + isLoading = false + await refreshAll() + return sessionGeneration == generation + } catch { + guard sessionGeneration == generation else { return false } + errorMessage = error.localizedDescription + isLoading = false + return false + } + } + + func disconnect() async { + sessionGeneration &+= 1 + chatGeneration &+= 1 + await unregisterPushToken() + KeychainStore.deleteAPIKey() + UserDefaults.standard.removeObject(forKey: "sure.pushSubscriptionID") + UserDefaults.standard.set(false, forKey: "sure.insightNotifications") + isConfigured = false + isLoading = false + isAssistantThinking = false + errorMessage = nil + balanceSheet = nil + accounts = [] + budgets = [] + insights = [] + chats = [] + selectedChat = nil + messages = [] + } + + func refreshAll() async { + let generation = sessionGeneration + isLoading = true + errorMessage = nil + async let balanceRequest: BalanceSheet = client.get("api/v1/balance_sheet") + async let accountsRequest = loadAllPages( + path: "api/v1/accounts", + collection: AccountCollection.self, + items: \.accounts + ) + async let budgetsRequest = loadAllPages( + path: "api/v1/budgets", + collection: BudgetCollection.self, + items: \.budgets + ) + do { + let (balance, loadedAccounts, loadedBudgets) = try await ( + balanceRequest, + accountsRequest, + budgetsRequest + ) + guard sessionGeneration == generation else { return } + balanceSheet = balance + accounts = loadedAccounts + budgets = loadedBudgets + } catch { + guard sessionGeneration == generation else { return } + errorMessage = error.localizedDescription + isLoading = false + return + } + + do { + let loadedChats = try await loadAllPages( + path: "api/v1/chats", + collection: ChatCollection.self, + items: \.chats + ) + guard sessionGeneration == generation else { return } + chats = loadedChats + } catch let error as SureAPIError { + guard sessionGeneration == generation else { return } + if case .server(status: 403, message: _) = error { + chats = [] + } else { + errorMessage = error.localizedDescription + } + } catch { + guard sessionGeneration == generation else { return } + errorMessage = error.localizedDescription + } + + await loadInsights() + guard sessionGeneration == generation else { return } + if UserDefaults.standard.bool(forKey: "sure.insightNotifications") { + if let token = UserDefaults.standard.string(forKey: "sure.apnsDeviceToken") { + await registerPushToken(token) + } + } else { + await unregisterPushToken() + } + guard sessionGeneration == generation else { return } + isLoading = false + } + + func loadInsights() async { + let generation = sessionGeneration + do { + let collection: InsightCollection = try await client.get("api/v1/insights") + guard sessionGeneration == generation else { return } + insights = collection.insights + } catch let error as SureAPIError { + guard sessionGeneration == generation else { return } + if case .server(status: 404, message: _) = error { + insights = makeLocalInsights() + } else if case .server(status: 403, message: _) = error { + insights = [] + } else { + errorMessage = error.localizedDescription + } + } catch { + guard sessionGeneration == generation else { return } + errorMessage = error.localizedDescription + } + } + + func loadChat(_ chat: SureChat) async { + chatGeneration &+= 1 + let generation = sessionGeneration + let conversationGeneration = chatGeneration + selectedChat = chat + isAssistantThinking = false + do { + let detail = try await loadLatestChatDetail(chatID: chat.id) + guard sessionGeneration == generation, + chatGeneration == conversationGeneration, + selectedChat?.id == chat.id else { return } + messages = detail.messages.sorted { $0.createdAt < $1.createdAt } + } catch { + guard sessionGeneration == generation, chatGeneration == conversationGeneration else { return } + errorMessage = error.localizedDescription + } + } + + func newChat() { + chatGeneration &+= 1 + selectedChat = nil + messages = [] + isAssistantThinking = false + } + + func sendMessage(_ content: String) async { + let text = content.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !isAssistantThinking else { return } + let generation = sessionGeneration + let conversationGeneration = chatGeneration + isAssistantThinking = true + errorMessage = nil + do { + if let selectedChat { + let request = MessageRequest(content: text) + let _: MessageReceipt = try await client.post( + "api/v1/chats/\(selectedChat.id)/messages", + body: request + ) + guard sessionGeneration == generation, chatGeneration == conversationGeneration else { return } + messages.append(SureMessage( + id: UUID().uuidString, + type: "user_message", + role: "user", + content: text, + createdAt: ISO8601DateFormatter().string(from: Date()) + )) + } else { + let request = NewChatRequest(title: makeTitle(from: text), message: text) + let detail: ChatDetail = try await client.post("api/v1/chats", body: request) + guard sessionGeneration == generation, chatGeneration == conversationGeneration else { return } + selectedChat = detail.chat + messages = detail.messages.sorted { $0.createdAt < $1.createdAt } + } + try await pollForAssistantResponse( + sessionGeneration: generation, + chatGeneration: conversationGeneration + ) + guard sessionGeneration == generation, chatGeneration == conversationGeneration else { return } + let loadedChats = try await loadAllPages( + path: "api/v1/chats", + collection: ChatCollection.self, + items: \.chats + ) + guard sessionGeneration == generation, chatGeneration == conversationGeneration else { return } + chats = loadedChats + } catch { + guard sessionGeneration == generation, chatGeneration == conversationGeneration else { return } + errorMessage = error.localizedDescription + } + guard sessionGeneration == generation, chatGeneration == conversationGeneration else { return } + isAssistantThinking = false + } + + func registerPushToken(_ token: String) async { + guard isConfigured, UserDefaults.standard.bool(forKey: "sure.insightNotifications") else { return } + let generation = sessionGeneration + let environment = APNsEnvironment.current + let request = PushSubscriptionRequest( + token: token, + environment: environment.rawValue, + platform: "ios" + ) + do { + let receipt: PushSubscriptionReceipt = try await client.post( + "api/v1/push_subscriptions", + body: request + ) + guard sessionGeneration == generation else { + try? await client.delete("api/v1/push_subscriptions/\(receipt.id)") + return + } + UserDefaults.standard.set(receipt.id, forKey: "sure.pushSubscriptionID") + } catch let error as SureAPIError { + guard sessionGeneration == generation else { return } + if case .server(status: 404, message: _) = error { return } + errorMessage = error.localizedDescription + } catch { + guard sessionGeneration == generation else { return } + errorMessage = error.localizedDescription + } + } + + func unregisterPushToken() async { + guard isConfigured, + let id = UserDefaults.standard.string(forKey: "sure.pushSubscriptionID") else { return } + let generation = sessionGeneration + do { + try await client.delete("api/v1/push_subscriptions/\(id)") + guard sessionGeneration == generation else { return } + UserDefaults.standard.removeObject(forKey: "sure.pushSubscriptionID") + } catch let error as SureAPIError { + guard sessionGeneration == generation else { return } + if case .server(status: 404, message: _) = error { + UserDefaults.standard.removeObject(forKey: "sure.pushSubscriptionID") + return + } + errorMessage = error.localizedDescription + } catch { + guard sessionGeneration == generation else { return } + errorMessage = error.localizedDescription + } + } + + private func pollForAssistantResponse(sessionGeneration: Int, chatGeneration: Int) async throws { + guard let chat = selectedChat else { return } + let previousAssistantIDs = Set(messages.filter { !$0.isUser }.map(\.id)) + let retryDelays = [3, 5, 8, 13, 21, 34] + for delay in retryDelays { + try await Task.sleep(for: .seconds(delay)) + guard self.sessionGeneration == sessionGeneration, + self.chatGeneration == chatGeneration, + !Task.isCancelled else { return } + let detail: ChatDetail + do { + detail = try await loadLatestChatDetail(chatID: chat.id) + } catch let error as SureAPIError { + if case .server(status: 429, message: _) = error { + throw error + } + continue + } catch { + continue + } + guard self.sessionGeneration == sessionGeneration, + self.chatGeneration == chatGeneration else { return } + messages = detail.messages.sorted { $0.createdAt < $1.createdAt } + if messages.contains(where: { !$0.isUser && !previousAssistantIDs.contains($0.id) && !$0.content.isEmpty }) { + return + } + } + guard self.sessionGeneration == sessionGeneration, self.chatGeneration == chatGeneration else { return } + errorMessage = "The assistant is still working. Pull to refresh this conversation in a moment." + } + + private func loadAllPages( + path: String, + collection: Collection.Type, + items: KeyPath + ) async throws -> [Item] where Collection: PaginatedCollection { + let firstPage = try await client.get("\(path)?page=1&per_page=100", as: collection) + var allItems = firstPage[keyPath: items] + let totalPages = firstPage.pagination?.totalPages ?? 1 + guard totalPages > 1 else { return allItems } + + for page in 2...totalPages { + let nextPage = try await client.get("\(path)?page=\(page)&per_page=100", as: collection) + allItems.append(contentsOf: nextPage[keyPath: items]) + } + return allItems + } + + private func loadLatestChatDetail(chatID: String) async throws -> ChatDetail { + let firstPage: ChatDetail = try await client.get("api/v1/chats/\(chatID)") + guard let pagination = firstPage.pagination, + pagination.totalPages > pagination.page else { return firstPage } + + return try await client.get("api/v1/chats/\(chatID)?page=\(pagination.totalPages)") + } + + private func makeLocalInsights() -> [SureInsight] { + var generated: [SureInsight] = [] + if let balanceSheet { + let liabilities = balanceSheet.liabilities.decimalAmount.magnitude + let assets = balanceSheet.assets.decimalAmount.magnitude + if liabilities > 0, assets > 0 { + let ratio = NSDecimalNumber(decimal: liabilities / assets).doubleValue + generated.append(SureInsight( + id: "live-debt-ratio", + type: ratio > 0.5 ? "cash_flow_warning" : "budget_on_track", + title: ratio > 0.5 ? "Liabilities need attention" : "Your balance sheet looks resilient", + body: "Liabilities are \((ratio * 100).formatted(.number.precision(.fractionLength(0))))% of assets, based on your live Sure balances.", + priority: ratio > 0.5 ? "high" : "low", + status: "active" + )) + } + } + if let largest = accounts.filter(\.isAsset).max(by: { abs($0.balanceCents) < abs($1.balanceCents) }) { + generated.append(SureInsight( + id: "live-largest-account", + type: "idle_cash", + title: "Review your largest account", + body: "\(largest.name) holds \(largest.balance). Ask the Assistant whether that concentration fits your goals.", + priority: "medium", + status: "active" + )) + } + if let currentBudget = budgets.first(where: \.current) { + generated.append(SureInsight( + id: "live-current-budget", + type: "budget_on_track", + title: "Your current plan is ready to review", + body: "\(currentBudget.name) has \(currentBudget.allocatedSpending) allocated. Check it before the period ends.", + priority: "low", + status: "active" + )) + } + return Array(generated.prefix(3)) + } + + private func makeTitle(from content: String) -> String { + String(content.prefix(48)) + } + + private func normalizedURL(_ input: String) -> URL? { + guard var components = URLComponents(string: input.trimmingCharacters(in: .whitespacesAndNewlines)), + components.scheme == "https", components.host != nil else { return nil } + components.path = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/" + return components.url + } +} + +enum SureTab: Hashable { + case overview + case accounts + case budgets + case assistant +} + +protocol PaginatedCollection { + var pagination: SurePagination? { get } +} + +extension AccountCollection: PaginatedCollection {} +extension BudgetCollection: PaginatedCollection {} +extension ChatCollection: PaginatedCollection {} + +struct NewChatRequest: Codable, Sendable { + var title: String + var message: String +} + +struct MessageRequest: Codable, Sendable { + var content: String +} + +struct MessageReceipt: Codable, Sendable { + var id: String + var chatId: String + + enum CodingKeys: String, CodingKey { + case id + case chatId = "chat_id" + } +} + +struct PushSubscriptionRequest: Codable, Sendable { + var token: String + var environment: String + var platform: String +} + +struct PushSubscriptionReceipt: Codable, Sendable { + var id: String +} + +enum APNsEnvironment: String { + case sandbox + case production + + static var current: APNsEnvironment { + #if targetEnvironment(simulator) + return .sandbox + #else + guard let profileURL = Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision"), + let profileData = try? Data(contentsOf: profileURL), + let profileText = String(data: profileData, encoding: .isoLatin1) else { + return .production + } + return profileText.contains("development") ? .sandbox : .production + #endif + } +} diff --git a/bitrig/README.md b/bitrig/README.md new file mode 100644 index 000000000..b0c6979c0 --- /dev/null +++ b/bitrig/README.md @@ -0,0 +1,17 @@ +# Sure for iOS and iPadOS + +This SwiftUI client connects directly to a Sure instance with `X-Api-Key` authentication. + +## Demo connection + +- Server: `https://demo.sure.am` +- Sign in through the demo website using the credentials presented there. +- Create a **read/write** key under Settings → API keys, then paste it into the native app. The key is stored only in the device Keychain. + +Do not commit an API key. The shared demo account is refreshed regularly, so a generated key may need to be recreated. + +## AI insight notifications + +The app target includes the APNs entitlement and registers device tokens only after the user enables AI insight notifications. Tokens are sent to `POST /api/v1/push_subscriptions` with their sandbox or production environment. The project source intentionally keeps `aps-environment` set to `development`; Apple distribution signing replaces it with `production` for TestFlight and App Store builds. + +Server delivery additionally requires a paid Apple Developer Program membership and these backend secrets: `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_BUNDLE_ID`, and `APNS_PRIVATE_KEY_BASE64` (the base64-encoded `.p8` contents). Never add their values to this app or repository. Newly created or resurfaced insights enqueue one APNs delivery per recently registered device; development installs use APNs sandbox, while TestFlight and App Store builds use production APNs. diff --git a/config/locales/views/insights/de.yml b/config/locales/views/insights/de.yml index edc4fd52e..1a59ae4c5 100644 --- a/config/locales/views/insights/de.yml +++ b/config/locales/views/insights/de.yml @@ -1,6 +1,9 @@ --- de: insights: + notification: + title: Neue Finanzanalyse + body: Öffne Sure, um deine neueste KI-Analyse anzusehen. actions: budget: Budget ansehen cash_flow_warning: Wiederkehrende Buchungen prüfen diff --git a/config/locales/views/insights/en.yml b/config/locales/views/insights/en.yml index cc557e3c6..d47cd2372 100644 --- a/config/locales/views/insights/en.yml +++ b/config/locales/views/insights/en.yml @@ -1,6 +1,9 @@ --- en: insights: + notification: + title: New financial insight + body: Open Sure to review your latest AI insight. index: title: Insights subtitle: What's happening across your finances, refreshed nightly. diff --git a/config/locales/views/insights/es.yml b/config/locales/views/insights/es.yml index e9b0d6f3c..fc610bb16 100644 --- a/config/locales/views/insights/es.yml +++ b/config/locales/views/insights/es.yml @@ -1,6 +1,9 @@ --- es: insights: + notification: + title: Nuevo análisis financiero + body: Abre Sure para revisar tu análisis de IA más reciente. index: title: Insights subtitle: Qué está pasando en tus finanzas, actualizado cada noche. diff --git a/config/locales/views/insights/fr.yml b/config/locales/views/insights/fr.yml index c0c8be64d..611ebfd52 100644 --- a/config/locales/views/insights/fr.yml +++ b/config/locales/views/insights/fr.yml @@ -1,6 +1,9 @@ --- fr: insights: + notification: + title: Nouvelle analyse financière + body: Ouvrez Sure pour consulter votre dernière analyse par IA. actions: budget: Voir le budget cash_flow_warning: Vérifier les transactions récurrentes diff --git a/config/locales/views/insights/pl.yml b/config/locales/views/insights/pl.yml index 98f407c5d..0c5db46bd 100644 --- a/config/locales/views/insights/pl.yml +++ b/config/locales/views/insights/pl.yml @@ -1,6 +1,9 @@ --- pl: insights: + notification: + title: Nowa analiza finansowa + body: Otwórz Sure, aby przejrzeć najnowszą analizę AI. index: title: Analityka subtitle: Co dzieje się w twoich finansach, odświeżane codziennie. diff --git a/config/locales/views/insights/tr.yml b/config/locales/views/insights/tr.yml index eba60b8c7..b90535355 100644 --- a/config/locales/views/insights/tr.yml +++ b/config/locales/views/insights/tr.yml @@ -1,6 +1,9 @@ --- tr: insights: + notification: + title: Yeni finansal içgörü + body: En son yapay zekâ içgörünüzü incelemek için Sure'ı açın. actions: budget: Bütçeyi görüntüle cash_flow_warning: Yinelenen işlemleri incele diff --git a/config/locales/views/insights/uk.yml b/config/locales/views/insights/uk.yml index 96a079911..00c3246ec 100644 --- a/config/locales/views/insights/uk.yml +++ b/config/locales/views/insights/uk.yml @@ -1,5 +1,8 @@ uk: insights: + notification: + title: Новий фінансовий інсайт + body: Відкрийте Sure, щоб переглянути найновіший інсайт від ШІ. index: title: Аналітика subtitle: Комплексний огляд ваших фінансів, оновлюється щоночі. diff --git a/config/locales/views/insights/zh-TW.yml b/config/locales/views/insights/zh-TW.yml index 729797781..c6e79b99f 100644 --- a/config/locales/views/insights/zh-TW.yml +++ b/config/locales/views/insights/zh-TW.yml @@ -1,6 +1,9 @@ --- zh-TW: insights: + notification: + title: 新的財務洞察 + body: 開啟 Sure 以查看最新的 AI 洞察。 index: title: 洞察 subtitle: 您財務狀況的近期動態,每晚更新。 diff --git a/config/routes.rb b/config/routes.rb index 52eec6b28..3afb308ab 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -669,6 +669,8 @@ Rails.application.routes.draw do end resource :usage, only: [ :show ], controller: :usage resource :balance_sheet, only: [ :show ], controller: :balance_sheet + resources :insights, only: [ :index ] + resources :push_subscriptions, only: [ :create, :destroy ] resource :family_settings, only: [ :show ], controller: :family_settings post :sync, to: "sync#create", as: :sync_job resources :syncs, only: [ :index, :show ] do diff --git a/db/migrate/20260822120000_create_push_subscriptions.rb b/db/migrate/20260822120000_create_push_subscriptions.rb new file mode 100644 index 000000000..f17ef530f --- /dev/null +++ b/db/migrate/20260822120000_create_push_subscriptions.rb @@ -0,0 +1,24 @@ +class CreatePushSubscriptions < ActiveRecord::Migration[7.2] + def change + create_table :push_subscriptions, id: :uuid do |t| + t.references :user, null: false, foreign_key: true, type: :uuid + t.string :token, null: false + t.string :environment, null: false + t.string :platform, null: false, default: "ios" + t.datetime :last_registered_at, null: false + + t.timestamps + end + + add_index :push_subscriptions, "lower(token)", + unique: true, + name: "index_push_subscriptions_on_lower_token" + add_index :push_subscriptions, :last_registered_at + add_check_constraint :push_subscriptions, + "environment IN ('sandbox', 'production')", + name: "chk_push_subscriptions_environment" + add_check_constraint :push_subscriptions, + "platform = 'ios'", + name: "chk_push_subscriptions_platform" + end +end diff --git a/db/schema.rb b/db/schema.rb index cbf7a2d86..4375379ba 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1625,6 +1625,21 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_22_130000) do t.index ["provider_key", "period"], name: "index_provider_request_counts_on_provider_key_and_period", unique: true end + create_table "push_subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "user_id", null: false + t.string "token", null: false + t.string "environment", null: false + t.string "platform", default: "ios", null: false + t.datetime "last_registered_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["last_registered_at"], name: "index_push_subscriptions_on_last_registered_at" + t.index "lower((token)::text)", name: "index_push_subscriptions_on_lower_token", unique: true + t.index ["user_id"], name: "index_push_subscriptions_on_user_id" + t.check_constraint "environment::text = ANY (ARRAY['sandbox'::character varying, 'production'::character varying]::text[])", name: "chk_push_subscriptions_environment" + t.check_constraint "platform::text = 'ios'::text", name: "chk_push_subscriptions_platform" + end + create_table "questrade_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "account_number" t.string "account_status" @@ -2492,6 +2507,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_22_130000) do add_foreign_key "onchain_wallet_items", "families" add_foreign_key "plaid_accounts", "plaid_items" add_foreign_key "plaid_items", "families" + add_foreign_key "push_subscriptions", "users" add_foreign_key "questrade_accounts", "questrade_items" add_foreign_key "questrade_items", "families" add_foreign_key "recurring_transactions", "accounts", column: "destination_account_id", on_delete: :cascade diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 427db5cce..27d653cea 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -301,6 +301,72 @@ components: "$ref": "#/components/schemas/ChatSummary" pagination: "$ref": "#/components/schemas/Pagination" + Insight: + type: object + required: + - id + - type + - title + - body + - priority + - status + properties: + id: + type: string + format: uuid + type: + type: string + title: + type: string + body: + type: string + priority: + type: string + enum: + - high + - medium + - low + status: + type: string + enum: + - active + - read + generated_at: + type: string + format: date-time + nullable: true + InsightCollection: + type: object + required: + - insights + properties: + insights: + type: array + items: + "$ref": "#/components/schemas/Insight" + PushSubscription: + type: object + required: + - id + - environment + - platform + - last_registered_at + properties: + id: + type: string + format: uuid + environment: + type: string + enum: + - sandbox + - production + platform: + type: string + enum: + - ios + last_registered_at: + type: string + format: date-time RetryResponse: type: object required: @@ -4515,6 +4581,85 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/insights": + get: + summary: List proactive insights + tags: + - Insights + security: + - apiKeyAuth: [] + responses: + '200': + description: insights listed + content: + application/json: + schema: + "$ref": "#/components/schemas/InsightCollection" + '403': + description: preview features disabled + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/push_subscriptions": + post: + summary: Register an APNs device token + tags: + - Push Subscriptions + security: + - apiKeyAuth: [] + parameters: [] + responses: + '201': + description: token registered + content: + application/json: + schema: + "$ref": "#/components/schemas/PushSubscription" + '422': + description: invalid or conflicting subscription + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + required: + - token + - environment + - platform + properties: + token: + type: string + environment: + type: string + enum: + - sandbox + - production + platform: + type: string + enum: + - ios + required: true + "/api/v1/push_subscriptions/{id}": + parameters: + - name: id + in: path + required: true + schema: + type: string + delete: + summary: Unregister an APNs device token + tags: + - Push Subscriptions + security: + - apiKeyAuth: [] + responses: + '204': + description: token unregistered "/api/v1/family_exports": get: summary: Lists family exports diff --git a/spec/requests/api/v1/insights_spec.rb b/spec/requests/api/v1/insights_spec.rb new file mode 100644 index 000000000..ff7cb2235 --- /dev/null +++ b/spec/requests/api/v1/insights_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "swagger_helper" + +RSpec.describe "API V1 Insights", type: :request do + let(:family) { Family.create!(name: "API Family") } + let(:user) do + family.users.create!( + email: "insights-api@example.com", + password: "password123", + ai_enabled: true, + preferences: { "preview_features_enabled" => true } + ) + end + let(:api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!(user: user, name: "API Docs Key", key: key, scopes: %w[read_write], source: "web") + end + let(:"X-Api-Key") { api_key.plain_key } + + path "/api/v1/insights" do + get "List proactive insights" do + tags "Insights" + security [ { apiKeyAuth: [] } ] + produces "application/json" + + response "200", "insights listed" do + schema "$ref" => "#/components/schemas/InsightCollection" + run_test! + end + + + response "403", "preview features disabled" do + schema "$ref" => "#/components/schemas/ErrorResponse" + let(:user) do + family.users.create!( + email: "insights-disabled-api@example.com", + password: "password123", + ai_enabled: true, + preferences: { "preview_features_enabled" => false } + ) + end + + run_test! + end + end + end +end diff --git a/spec/requests/api/v1/push_subscriptions_spec.rb b/spec/requests/api/v1/push_subscriptions_spec.rb new file mode 100644 index 000000000..9ab2689ce --- /dev/null +++ b/spec/requests/api/v1/push_subscriptions_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "swagger_helper" + +RSpec.describe "API V1 Push Subscriptions", type: :request do + let(:family) { Family.create!(name: "API Family") } + let(:user) do + family.users.create!(email: "push-api@example.com", password: "password123", ai_enabled: true) + end + let(:api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!(user: user, name: "API Docs Key", key: key, scopes: %w[read_write], source: "web") + end + let(:"X-Api-Key") { api_key.plain_key } + + path "/api/v1/push_subscriptions" do + post "Register an APNs device token" do + tags "Push Subscriptions" + security [ { apiKeyAuth: [] } ] + consumes "application/json" + produces "application/json" + parameter name: :subscription, in: :body, required: true, schema: { + type: :object, + required: %w[token environment platform], + properties: { + token: { type: :string }, + environment: { type: :string, enum: %w[sandbox production] }, + platform: { type: :string, enum: %w[ios] } + } + } + let(:subscription) { { token: "ab" * 32, environment: "sandbox", platform: "ios" } } + + response "201", "token registered" do + schema "$ref" => "#/components/schemas/PushSubscription" + run_test! + end + + + response "422", "invalid or conflicting subscription" do + schema "$ref" => "#/components/schemas/ErrorResponse" + let(:subscription) { { token: "ab" * 32, environment: "staging", platform: "ios" } } + + run_test! + end + end + end + + + path "/api/v1/push_subscriptions/{id}" do + parameter name: :id, in: :path, type: :string, required: true + let(:subscription) do + user.push_subscriptions.create!( + token: "cd" * 32, + environment: "sandbox", + platform: "ios", + last_registered_at: Time.current + ) + end + let(:id) { subscription.id } + + delete "Unregister an APNs device token" do + tags "Push Subscriptions" + security [ { apiKeyAuth: [] } ] + + response "204", "token unregistered" do + run_test! + end + end + end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index a8cd88f8d..e0a121562 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -225,6 +225,36 @@ RSpec.configure do |config| pagination: { '$ref' => '#/components/schemas/Pagination' } } }, + Insight: { + type: :object, + required: %w[id type title body priority status], + properties: { + id: { type: :string, format: :uuid }, + type: { type: :string }, + title: { type: :string }, + body: { type: :string }, + priority: { type: :string, enum: %w[high medium low] }, + status: { type: :string, enum: %w[active read] }, + generated_at: { type: :string, format: :'date-time', nullable: true } + } + }, + InsightCollection: { + type: :object, + required: %w[insights], + properties: { + insights: { type: :array, items: { '$ref' => '#/components/schemas/Insight' } } + } + }, + PushSubscription: { + type: :object, + required: %w[id environment platform last_registered_at], + properties: { + id: { type: :string, format: :uuid }, + environment: { type: :string, enum: %w[sandbox production] }, + platform: { type: :string, enum: %w[ios] }, + last_registered_at: { type: :string, format: :'date-time' } + } + }, RetryResponse: { type: :object, required: %w[message message_id], diff --git a/test/controllers/api/v1/insights_controller_test.rb b/test/controllers/api/v1/insights_controller_test.rb new file mode 100644 index 000000000..b665e9f87 --- /dev/null +++ b/test/controllers/api/v1/insights_controller_test.rb @@ -0,0 +1,50 @@ +require "test_helper" + +class Api::V1::InsightsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @user.update!(preferences: @user.preferences.merge("preview_features_enabled" => true)) + key = ApiKey.generate_secure_key + @api_key = ApiKey.create!( + user: @user, + name: "Native insights test", + key: key, + scopes: [ "read" ], + source: "mobile" + ) + @insight = @user.family.insights.create!( + insight_type: "idle_cash", + priority: "medium", + status: "active", + title: "Put idle cash to work", + body: "One account has more cash than usual.", + generated_at: Time.current, + dedup_key: "native-insights-test" + ) + end + + test "lists visible family insights" do + get api_v1_insights_url, headers: api_headers(@api_key) + + assert_response :success + payload = response.parsed_body + row = payload.fetch("insights").find { |insight| insight.fetch("id") == @insight.id } + assert_equal "idle_cash", row.fetch("type") + assert_equal "Put idle cash to work", row.fetch("title") + end + + test "rejects requests without an API key" do + get api_v1_insights_url + + assert_response :unauthorized + end + + test "does not expose insights when the API key owner opted out of preview features" do + @user.update!(preferences: @user.preferences.merge("preview_features_enabled" => false)) + + get api_v1_insights_url, headers: api_headers(@api_key) + + assert_response :forbidden + assert_equal "feature_disabled", response.parsed_body.fetch("error") + end +end diff --git a/test/controllers/api/v1/push_subscriptions_controller_test.rb b/test/controllers/api/v1/push_subscriptions_controller_test.rb new file mode 100644 index 000000000..fa8bfbafe --- /dev/null +++ b/test/controllers/api/v1/push_subscriptions_controller_test.rb @@ -0,0 +1,160 @@ +require "test_helper" + +class Api::V1::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + key = ApiKey.generate_secure_key + @api_key = ApiKey.create!( + user: @user, + name: "Native push test", + key: key, + scopes: [ "read_write" ], + source: "mobile" + ) + @headers = api_headers(@api_key) + @token = "ab" * 32 + end + + test "registers and refreshes an APNs token" do + assert_difference "PushSubscription.count", 1 do + post api_v1_push_subscriptions_url, + params: { token: @token, environment: "sandbox", platform: "ios" }, + headers: @headers, + as: :json + end + + assert_response :created + subscription = PushSubscription.find_by!(token: @token) + assert_equal @user, subscription.user + assert_equal "sandbox", subscription.environment + + assert_no_difference "PushSubscription.count" do + post api_v1_push_subscriptions_url, + params: { token: @token, environment: "production", platform: "ios" }, + headers: @headers, + as: :json + end + assert_equal "production", subscription.reload.environment + end + + test "requires a read write API key" do + read_key_value = ApiKey.generate_secure_key + read_key = ApiKey.create!( + user: @user, + name: "Read-only native push test", + key: read_key_value, + scopes: [ "read" ], + source: "mobile" + ) + + post api_v1_push_subscriptions_url, + params: { token: @token, environment: "sandbox", platform: "ios" }, + headers: api_headers(read_key), + as: :json + + assert_response :forbidden + end + + test "rejects malformed tokens" do + post api_v1_push_subscriptions_url, + params: { token: "not-a-device-token", environment: "sandbox", platform: "ios" }, + headers: @headers, + as: :json + + assert_response :unprocessable_entity + end + + test "rejects an invalid APNs environment" do + post api_v1_push_subscriptions_url, + params: { token: @token, environment: "staging", platform: "ios" }, + headers: @headers, + as: :json + + assert_response :unprocessable_entity + end + + test "normalizes APNs tokens before lookup and persistence" do + post api_v1_push_subscriptions_url, + params: { token: @token.upcase, environment: "sandbox", platform: "ios" }, + headers: @headers, + as: :json + + assert_response :created + assert PushSubscription.exists?(token: @token) + + assert_no_difference "PushSubscription.count" do + post api_v1_push_subscriptions_url, + params: { token: @token, environment: "sandbox", platform: "ios" }, + headers: @headers, + as: :json + end + end + + test "does not transfer another user's token" do + other_user = users(:empty) + subscription = other_user.push_subscriptions.create!( + token: @token, + environment: "sandbox", + platform: "ios", + last_registered_at: Time.current + ) + + post api_v1_push_subscriptions_url, + params: { token: @token.upcase, environment: "production", platform: "ios" }, + headers: @headers, + as: :json + + assert_response :unprocessable_entity + assert_equal other_user, subscription.reload.user + assert_equal "sandbox", subscription.environment + end + + test "returns a controlled response when concurrent token registration conflicts" do + PushSubscription.any_instance.stubs(:save!).raises(ActiveRecord::RecordNotUnique) + + post api_v1_push_subscriptions_url, + params: { token: @token, environment: "sandbox", platform: "ios" }, + headers: @headers, + as: :json + + assert_response :unprocessable_entity + assert_equal "validation_error", response.parsed_body["error"] + end + + test "removes the current user's token" do + subscription = @user.push_subscriptions.create!( + token: @token, + environment: "sandbox", + platform: "ios", + last_registered_at: Time.current + ) + + assert_difference "PushSubscription.count", -1 do + delete api_v1_push_subscription_url(subscription), headers: @headers + end + + assert_response :no_content + end + + test "does not remove another user's token" do + subscription = users(:empty).push_subscriptions.create!( + token: @token, + environment: "sandbox", + platform: "ios", + last_registered_at: Time.current + ) + + delete api_v1_push_subscription_url(subscription), headers: @headers + + assert_response :not_found + assert PushSubscription.exists?(subscription.id) + end + + test "requires authentication" do + post api_v1_push_subscriptions_url, + params: { token: @token, environment: "sandbox", platform: "ios" }, + as: :json + + assert_response :unauthorized + end +end diff --git a/test/jobs/deliver_insight_notification_job_test.rb b/test/jobs/deliver_insight_notification_job_test.rb new file mode 100644 index 000000000..266b021ad --- /dev/null +++ b/test/jobs/deliver_insight_notification_job_test.rb @@ -0,0 +1,75 @@ +require "test_helper" + +class DeliverInsightNotificationJobTest < ActiveJob::TestCase + setup do + @insight = insights(:cash_flow_warning) + @subscription = @insight.family.users.first.push_subscriptions.create!( + token: "ab" * 32, + environment: "sandbox", + platform: "ios", + last_registered_at: Time.current + ) + end + + test "delivers a privacy-preserving insight notification" do + response = stub(ok?: true) + client = mock + Apns::Client.expects(:new).with(environment: "sandbox").returns(client) + client.expects(:deliver).with( + token: @subscription.token, + title: "New financial insight", + body: "Open Sure to review your latest AI insight.", + insight_id: @insight.id + ).returns(response) + + DeliverInsightNotificationJob.perform_now( + insight_id: @insight.id, + push_subscription_id: @subscription.id + ) + end + + test "localizes notifications using the family locale" do + @insight.family.update!(locale: "de") + response = stub(ok?: true) + client = mock + Apns::Client.stubs(:new).returns(client) + client.expects(:deliver).with( + token: @subscription.token, + title: "Neue Finanzanalyse", + body: "Öffne Sure, um deine neueste KI-Analyse anzusehen.", + insight_id: @insight.id + ).returns(response) + + DeliverInsightNotificationJob.perform_now( + insight_id: @insight.id, + push_subscription_id: @subscription.id + ) + end + + test "removes tokens rejected as unregistered" do + response = stub(ok?: false, status: "410", body: { "reason" => "Unregistered" }) + Apns::Client.any_instance.stubs(:deliver).returns(response) + + assert_difference "PushSubscription.count", -1 do + DeliverInsightNotificationJob.perform_now( + insight_id: @insight.id, + push_subscription_id: @subscription.id + ) + end + end + + test "does not send an insight to a device from another family" do + other_subscription = users(:empty).push_subscriptions.create!( + token: "cd" * 32, + environment: "sandbox", + platform: "ios", + last_registered_at: Time.current + ) + Apns::Client.expects(:new).never + + DeliverInsightNotificationJob.perform_now( + insight_id: @insight.id, + push_subscription_id: other_subscription.id + ) + end +end diff --git a/test/jobs/generate_insights_job_test.rb b/test/jobs/generate_insights_job_test.rb index 16e16026e..3af4f053e 100644 --- a/test/jobs/generate_insights_job_test.rb +++ b/test/jobs/generate_insights_job_test.rb @@ -89,6 +89,38 @@ class GenerateInsightsJobTest < ActiveJob::TestCase assert_equal 5000.0, insight.metadata["balance"] end + test "enqueues notifications for newly created insights" do + opted_in_user, opted_out_user = @family.users.to_a + set_preview_features(opted_out_user, false) + subscription = opted_in_user.push_subscriptions.create!( + token: "ab" * 32, + environment: "sandbox", + platform: "ios", + last_registered_at: Time.current + ) + opted_out_user.push_subscriptions.create!( + token: "cd" * 32, + environment: "sandbox", + platform: "ios", + last_registered_at: Time.current + ) + Apns::Client.stubs(:configured?).returns(true) + stub_generated([ generated_insight ]) + + assert_enqueued_jobs 1, only: DeliverInsightNotificationJob do + assert_enqueued_with( + job: DeliverInsightNotificationJob, + args: ->(args) { + args.one? && + args.first[:insight_id].present? && + args.first[:push_subscription_id] == subscription.id + } + ) do + GenerateInsightsJob.perform_now(family_id: @family.id) + end + end + end + test "re-running with unchanged numbers does not duplicate or rewrite" do stub_generated([ generated_insight ]) GenerateInsightsJob.perform_now(family_id: @family.id) diff --git a/test/services/apns/client_test.rb b/test/services/apns/client_test.rb new file mode 100644 index 000000000..4be17954d --- /dev/null +++ b/test/services/apns/client_test.rb @@ -0,0 +1,40 @@ +require "test_helper" + +class Apns::ClientTest < ActiveSupport::TestCase + setup do + @environment = { + "APNS_KEY_ID" => "key-id", + "APNS_TEAM_ID" => "team-id", + "APNS_BUNDLE_ID" => "com.example.sure", + "APNS_PRIVATE_KEY_BASE64" => Base64.strict_encode64("private-key") + } + end + + test "sends sandbox notifications with token authentication" do + connection = mock + response = stub(ok?: true) + Apnotic::Connection.expects(:development).with do |options| + options[:auth_method] == :token && + options[:key_id] == "key-id" && + options[:team_id] == "team-id" && + options[:cert_path].read == "private-key" + end.returns(connection) + connection.expects(:push).with do |notification| + notification.topic == "com.example.sure" && + notification.push_type == "alert" && + notification.custom_payload == { insight_id: "insight-id", destination: "insights" } + end.returns(response) + connection.expects(:close) + + ClimateControl.modify(@environment) do + result = Apns::Client.new(environment: "sandbox").deliver( + token: "ab" * 32, + title: "New financial insight", + body: "Open Sure to review your latest AI insight.", + insight_id: "insight-id" + ) + + assert result.ok? + end + end +end