From 0aa43de10ab6010f9fedf3fbd623f2daf4d763f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Mon, 24 Aug 2026 19:16:24 -0700 Subject: [PATCH] Add experimental Swift-native Sure Insights app (#3134) * Add Swift-native Sure app * Fix push subscriptions schema for CI * Address native app review feedback * Address remaining native app review feedback * Use Flutter app logo for native icon * Honor insight notification preferences and locale --------- Co-authored-by: Juan Jose Mata <2v8shcb6pz@privaterelay.appleid.com> Co-authored-by: sure-admin --- .env.local.example | 7 + Gemfile | 1 + Gemfile.lock | 8 + Project.json | 46 ++ app/controllers/api/v1/insights_controller.rb | 40 ++ .../api/v1/push_subscriptions_controller.rb | 57 +++ app/jobs/deliver_insight_notification_job.rb | 59 +++ app/jobs/generate_insights_job.rb | 14 +- app/models/push_subscription.rb | 14 + app/models/user.rb | 1 + app/services/apns/client.rb | 50 ++ bitrig/App/APIClient.swift | 109 ++++ .../AccentColor.colorset/Contents.json | 20 + .../AppIcon.appiconset/Contents.json | 14 + .../AppIcon.appiconset/Icon.png | Bin 0 -> 34803 bytes bitrig/App/Assets.xcassets/Contents.json | 6 + bitrig/App/Info.plist | 5 + bitrig/App/KeychainStore.swift | 49 ++ bitrig/App/Models.swift | 184 +++++++ bitrig/App/Sure.entitlements | 5 + bitrig/App/SureAccountsView.swift | 74 +++ bitrig/App/SureApp.swift | 75 +++ bitrig/App/SureAssistantView.swift | 170 +++++++ bitrig/App/SureBudgetsView.swift | 52 ++ bitrig/App/SureMainTabView.swift | 29 ++ bitrig/App/SureOverviewView.swift | 137 +++++ bitrig/App/SureRootView.swift | 16 + bitrig/App/SureSettingsView.swift | 96 ++++ bitrig/App/SureSetupView.swift | 91 ++++ bitrig/App/SureStore.swift | 475 ++++++++++++++++++ bitrig/README.md | 17 + config/locales/views/insights/de.yml | 3 + config/locales/views/insights/en.yml | 3 + config/locales/views/insights/es.yml | 3 + config/locales/views/insights/fr.yml | 3 + config/locales/views/insights/pl.yml | 3 + config/locales/views/insights/tr.yml | 3 + config/locales/views/insights/uk.yml | 3 + config/locales/views/insights/zh-TW.yml | 3 + config/routes.rb | 2 + ...0260822120000_create_push_subscriptions.rb | 24 + db/schema.rb | 16 + docs/api/openapi.yaml | 145 ++++++ spec/requests/api/v1/insights_spec.rb | 48 ++ .../api/v1/push_subscriptions_spec.rb | 70 +++ spec/swagger_helper.rb | 30 ++ .../api/v1/insights_controller_test.rb | 50 ++ .../v1/push_subscriptions_controller_test.rb | 160 ++++++ .../deliver_insight_notification_job_test.rb | 75 +++ test/jobs/generate_insights_job_test.rb | 32 ++ test/services/apns/client_test.rb | 40 ++ 51 files changed, 2633 insertions(+), 4 deletions(-) create mode 100644 Project.json create mode 100644 app/controllers/api/v1/insights_controller.rb create mode 100644 app/controllers/api/v1/push_subscriptions_controller.rb create mode 100644 app/jobs/deliver_insight_notification_job.rb create mode 100644 app/models/push_subscription.rb create mode 100644 app/services/apns/client.rb create mode 100644 bitrig/App/APIClient.swift create mode 100644 bitrig/App/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 bitrig/App/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 bitrig/App/Assets.xcassets/AppIcon.appiconset/Icon.png create mode 100644 bitrig/App/Assets.xcassets/Contents.json create mode 100644 bitrig/App/Info.plist create mode 100644 bitrig/App/KeychainStore.swift create mode 100644 bitrig/App/Models.swift create mode 100644 bitrig/App/Sure.entitlements create mode 100644 bitrig/App/SureAccountsView.swift create mode 100644 bitrig/App/SureApp.swift create mode 100644 bitrig/App/SureAssistantView.swift create mode 100644 bitrig/App/SureBudgetsView.swift create mode 100644 bitrig/App/SureMainTabView.swift create mode 100644 bitrig/App/SureOverviewView.swift create mode 100644 bitrig/App/SureRootView.swift create mode 100644 bitrig/App/SureSettingsView.swift create mode 100644 bitrig/App/SureSetupView.swift create mode 100644 bitrig/App/SureStore.swift create mode 100644 bitrig/README.md create mode 100644 db/migrate/20260822120000_create_push_subscriptions.rb create mode 100644 spec/requests/api/v1/insights_spec.rb create mode 100644 spec/requests/api/v1/push_subscriptions_spec.rb create mode 100644 test/controllers/api/v1/insights_controller_test.rb create mode 100644 test/controllers/api/v1/push_subscriptions_controller_test.rb create mode 100644 test/jobs/deliver_insight_notification_job_test.rb create mode 100644 test/services/apns/client_test.rb 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 0000000000000000000000000000000000000000..6c64ce526e28ef16a981829091816f63bb0653f7 GIT binary patch literal 34803 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 + + + + 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