mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 23:01:26 +00:00
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 <sure-admin@splashblot.com>
This commit is contained in:
co-authored by
Juan Jose Mata
sure-admin
parent
311f06e404
commit
0aa43de10a
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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<T: Decodable & Sendable>(_ path: String, as type: T.Type = T.self) async throws -> T {
|
||||
try await request(path: path, method: "GET", body: Optional<String>.none, as: type)
|
||||
}
|
||||
|
||||
func post<Body: Encodable & Sendable, Response: Decodable & Sendable>(
|
||||
_ 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<Body: Encodable & Sendable>(_ 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<String>.none,
|
||||
as: EmptyResponse.self
|
||||
)
|
||||
}
|
||||
|
||||
private func request<Body: Encodable & Sendable, Response: Decodable & Sendable>(
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Icon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
@@ -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))." }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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<String>.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<String?> {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<Collection: Decodable & Sendable, Item: Sendable>(
|
||||
path: String,
|
||||
collection: Collection.Type,
|
||||
items: KeyPath<Collection, [Item]>
|
||||
) 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("<string>development</string>") ? .sandbox : .production
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
uk:
|
||||
insights:
|
||||
notification:
|
||||
title: Новий фінансовий інсайт
|
||||
body: Відкрийте Sure, щоб переглянути найновіший інсайт від ШІ.
|
||||
index:
|
||||
title: Аналітика
|
||||
subtitle: Комплексний огляд ваших фінансів, оновлюється щоночі.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
---
|
||||
zh-TW:
|
||||
insights:
|
||||
notification:
|
||||
title: 新的財務洞察
|
||||
body: 開啟 Sure 以查看最新的 AI 洞察。
|
||||
index:
|
||||
title: 洞察
|
||||
subtitle: 您財務狀況的近期動態,每晚更新。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Generated
+16
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user