diff --git a/.env.example b/.env.example index 7ac230429..befce8a55 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,10 @@ NEXT_PUBLIC_IOS_BUNDLE_ID=com.zotmeet.app # Register the service worker in non-production builds (e.g. when testing with PWABuilder # against a preview URL). Leave unset in local dev unless you need SW debugging. # NEXT_PUBLIC_ENABLE_SW=true + +# iOS push via AWS SNS (APNs platform application in AWS Console / IaC). +# Create an Apple platform application, upload your APNs key (.p8), then set: +# SNS_IOS_PLATFORM_APPLICATION_ARN=arn:aws:sns:us-west-1:ACCOUNT:app/APNS/ZotMeet +# SNS_REGION=us-west-1 +# Use SNS_IOS_APNS_ENV=sandbox for Xcode debug builds; production for TestFlight/App Store. +# SNS_IOS_APNS_ENV=production diff --git a/README.md b/README.md index 51a36aa59..8d839395b 100644 --- a/README.md +++ b/README.md @@ -157,9 +157,27 @@ Graph, robots, sitemap, and invite/email links. 4. Click `Package for stores` → `iOS` → `Generate Package`. 5. Take note of the `Bundle ID` and download the package. 6. Open the generated `.xcworkspace` in Xcode (≥ iOS 17 SDK), run - `pod install` in `src/`, build, and archive for distribution following the + `pod install` in `ios/src/`, build, and archive for distribution following the [PWA Builder iOS guide](https://docs.pwabuilder.com/#/builder/app-store). +### iOS push notifications (AWS SNS) + +Native push uses **AWS SNS → APNs** (same AWS account pattern as SES email). + +1. In [Apple Developer](https://developer.apple.com/account/resources/authkeys/list), + create an APNs key (`.p8`) and note Key ID, Team ID, and your app Bundle ID. +2. In AWS Console → SNS → Mobile → Push notifications → Platform applications, + create an **Apple iOS/VoIP/Mac** platform application (token-based `.p8` auth). +3. Set environment variables (see `.env.example`): + - `SNS_IOS_PLATFORM_APPLICATION_ARN` — platform application ARN + - `SNS_REGION` — region where the platform app was created (default `us-west-1` in `sst.config.ts`) + - `SNS_IOS_APNS_ENV` — `sandbox` for Xcode debug builds, `production` for TestFlight/App Store +4. Deploy so the Next.js workload has SNS IAM permissions (`sst.config.ts` includes + `sns:Publish`, `CreatePlatformEndpoint`, etc.). + +After login in the native app, iOS prompts for notification permission, registers an +APNs device token with the server, and the server stores an SNS platform endpoint ARN. + ### Regenerating PWA assets ```bash diff --git a/ios/src/Podfile b/ios/src/Podfile index f78f5a98e..f110119b3 100644 --- a/ios/src/Podfile +++ b/ios/src/Podfile @@ -5,9 +5,6 @@ target 'ZotMeet' do # Comment the next line if you don't want to use dynamic frameworks use_frameworks! - # Add the pod for Firebase Cloud Messaging - pod 'Firebase/Messaging' - end post_install do |installer| @@ -16,4 +13,4 @@ post_install do |installer| config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '17.4' end end -end \ No newline at end of file +end diff --git a/ios/src/Podfile.lock b/ios/src/Podfile.lock index b773de8b4..4388eb8e1 100644 --- a/ios/src/Podfile.lock +++ b/ios/src/Podfile.lock @@ -1,89 +1,12 @@ PODS: - - Firebase/CoreOnly (12.12.1): - - FirebaseCore (~> 12.12.1) - - Firebase/Messaging (12.12.1): - - Firebase/CoreOnly - - FirebaseMessaging (~> 12.12.0) - - FirebaseCore (12.12.1): - - FirebaseCoreInternal (~> 12.12.0) - - GoogleUtilities/Environment (~> 8.1) - - GoogleUtilities/Logger (~> 8.1) - - FirebaseCoreInternal (12.12.0): - - "GoogleUtilities/NSData+zlib (~> 8.1)" - - FirebaseInstallations (12.12.0): - - FirebaseCore (~> 12.12.0) - - GoogleUtilities/Environment (~> 8.1) - - GoogleUtilities/UserDefaults (~> 8.1) - - PromisesObjC (~> 2.4) - - FirebaseMessaging (12.12.0): - - FirebaseCore (~> 12.12.0) - - FirebaseInstallations (~> 12.12.0) - - GoogleDataTransport (~> 10.1) - - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - - GoogleUtilities/Environment (~> 8.1) - - GoogleUtilities/Reachability (~> 8.1) - - GoogleUtilities/UserDefaults (~> 8.1) - - nanopb (~> 3.30910.0) - - GoogleDataTransport (10.1.0): - - nanopb (~> 3.30910.0) - - PromisesObjC (~> 2.4) - - GoogleUtilities/AppDelegateSwizzler (8.1.0): - - GoogleUtilities/Environment - - GoogleUtilities/Logger - - GoogleUtilities/Network - - GoogleUtilities/Privacy - - GoogleUtilities/Environment (8.1.0): - - GoogleUtilities/Privacy - - GoogleUtilities/Logger (8.1.0): - - GoogleUtilities/Environment - - GoogleUtilities/Privacy - - GoogleUtilities/Network (8.1.0): - - GoogleUtilities/Logger - - "GoogleUtilities/NSData+zlib" - - GoogleUtilities/Privacy - - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (8.1.0)": - - GoogleUtilities/Privacy - - GoogleUtilities/Privacy (8.1.0) - - GoogleUtilities/Reachability (8.1.0): - - GoogleUtilities/Logger - - GoogleUtilities/Privacy - - GoogleUtilities/UserDefaults (8.1.0): - - GoogleUtilities/Logger - - GoogleUtilities/Privacy - - nanopb (3.30910.0): - - nanopb/decode (= 3.30910.0) - - nanopb/encode (= 3.30910.0) - - nanopb/decode (3.30910.0) - - nanopb/encode (3.30910.0) - - PromisesObjC (2.4.0) DEPENDENCIES: - - Firebase/Messaging SPEC REPOS: trunk: - - Firebase - - FirebaseCore - - FirebaseCoreInternal - - FirebaseInstallations - - FirebaseMessaging - - GoogleDataTransport - - GoogleUtilities - - nanopb - - PromisesObjC SPEC CHECKSUMS: - Firebase: 14f11e91129d246a8a6166b4c1c2ea61b56806ec - FirebaseCore: 86241206e656f5c80c995e370e6c975913b9b284 - FirebaseCoreInternal: 7c12fc3011d889085e765e317d7b9fd1cef97af9 - FirebaseInstallations: 4e6e162aa4abaaeeeb01dd00179dfc5ad9c2194e - FirebaseMessaging: 341004946fa7ffc741344b20f1b667514fc93e31 - GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 - GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 - nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 -PODFILE CHECKSUM: 5cd187e394ea190fb18c0c57d79f58afdc0a7b86 +PODFILE CHECKSUM: 613fd0f1fe9f534e781d74b06ed8e753449a3335 COCOAPODS: 1.16.2 diff --git a/ios/src/ZotMeet.xcodeproj/project.pbxproj b/ios/src/ZotMeet.xcodeproj/project.pbxproj index 1a3c5cf29..5b585f5df 100644 --- a/ios/src/ZotMeet.xcodeproj/project.pbxproj +++ b/ios/src/ZotMeet.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 25D3517F267A48E0002E5DC0 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 25D3517E267A48E0002E5DC0 /* GoogleService-Info.plist */; }; 595F23A525CEFBFE0053416C /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 595F239A25CEFBFD0053416C /* Settings.swift */; }; 595F23A625CEFBFE0053416C /* Entitlements in Resources */ = {isa = PBXBuildFile; fileRef = 595F239B25CEFBFD0053416C /* Entitlements */; }; 595F23A725CEFBFE0053416C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 595F239C25CEFBFD0053416C /* Assets.xcassets */; }; @@ -23,7 +22,6 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 25D3517E267A48E0002E5DC0 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "ZotMeet/GoogleService-Info.plist"; sourceTree = ""; }; 4C1C2162A8048FDF52B2A9D0 /* Pods-ZotMeet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ZotMeet.release.xcconfig"; path = "Target Support Files/Pods-ZotMeet/Pods-ZotMeet.release.xcconfig"; sourceTree = ""; }; 59333BAA25CFF706003392A4 /* ZotMeet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ZotMeet.app; sourceTree = BUILT_PRODUCTS_DIR; }; 595F239A25CEFBFD0053416C /* Settings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Settings.swift; path = ZotMeet/Settings.swift; sourceTree = ""; }; @@ -78,7 +76,6 @@ 595F239C25CEFBFD0053416C /* Assets.xcassets */, 595F239B25CEFBFD0053416C /* Entitlements */, 595F23A025CEFBFE0053416C /* Info.plist */, - 25D3517E267A48E0002E5DC0 /* GoogleService-Info.plist */, 595F23A325CEFBFE0053416C /* Printer.swift */, 595F239F25CEFBFE0053416C /* PushNotifications.swift */, 595F239D25CEFBFD0053416C /* SceneDelegate.swift */, @@ -158,7 +155,6 @@ CDC0FE292388222C002C8D56 /* Main.storyboard in Resources */, CDC0FE2A2388222C002C8D56 /* LaunchScreen.storyboard in Resources */, 595F23A725CEFBFE0053416C /* Assets.xcassets in Resources */, - 25D3517F267A48E0002E5DC0 /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/src/ZotMeet/AppDelegate.swift b/ios/src/ZotMeet/AppDelegate.swift index 01c0f9e18..9359e7cac 100644 --- a/ios/src/ZotMeet/AppDelegate.swift +++ b/ios/src/ZotMeet/AppDelegate.swift @@ -1,110 +1,54 @@ import UIKit -import FirebaseCore -import FirebaseMessaging - @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { - + var window : UIWindow? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { -// TODO: if we're using Firebase, uncomment next string - //FirebaseApp.configure() - - // [START set_messaging_delegate] - Messaging.messaging().delegate = self - // [END set_messaging_delegate] - // Register for remote notifications. This shows a permission dialog on first run, to - // show the dialog at a more appropriate time move this registration accordingly. - // [START register_for_notifications] - UNUserNotificationCenter.current().delegate = self + UNUserNotificationCenter.current().getNotificationSettings { settings in + switch settings.authorizationStatus { + case .authorized, .ephemeral, .provisional: + registerForPushNotifications() + default: + return + } + } - // let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound] - // UNUserNotificationCenter.current().requestAuthorization( - // options: authOptions, - // completionHandler: {_, _ in }) - -// TODO: if we're using Firebase, uncomment next string - // application.registerForRemoteNotifications() - - // [END register_for_notifications] return true } - // [START receive_message] func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) { - // If you are receiving a notification message while your app is in the background, - // this callback will not be fired till the user taps on the notification launching the application. - // With swizzling disabled you must let Messaging know about the message, for Analytics - // Messaging.messaging().appDidReceiveMessage(userInfo) - // Print message ID. - if let messageID = userInfo[gcmMessageIDKey] { - print("Message ID 1: \(messageID)") - } - - // Print full message. - print("push userInfo 1:", userInfo) sendPushToWebView(userInfo: userInfo) } func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - // If you are receiving a notification message while your app is in the background, - // this callback will not be fired till the user taps on the notification launching the application. - // With swizzling disabled you must let Messaging know about the message, for Analytics - // Messaging.messaging().appDidReceiveMessage(userInfo) - // Print message ID. - if let messageID = userInfo[gcmMessageIDKey] { - print("Message ID 2: \(messageID)") - } - - // Print full message. ** - print("push userInfo 2:", userInfo) sendPushToWebView(userInfo: userInfo) - completionHandler(UIBackgroundFetchResult.newData) } - // [END receive_message] func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { print("Unable to register for remote notifications: \(error.localizedDescription)") } - // This function is added here only for debugging purposes, and can be removed if swizzling is enabled. - // If swizzling is disabled then this function must be implemented so that the APNs token can be paired to - // the FCM registration token. -// func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { -// print("APNs token retrieved: \(deviceToken)") -// -// // With swizzling disabled you must set the APNs token here. -// // Messaging.messaging().apnsToken = deviceToken -// } + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() + setApnsDeviceTokenHex(token) + sendApnsTokenToWebView(token: token) + } } - // [START ios_10_message_handling] extension AppDelegate : UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { let userInfo = notification.request.content.userInfo - - // With swizzling disabled you must let Messaging know about the message, for Analytics - // Messaging.messaging().appDidReceiveMessage(userInfo) - // Print message ID. - if let messageID = userInfo[gcmMessageIDKey] { - print("Message ID: 3 \(messageID)") - } - - // Print full message. - print("push userInfo 3:", userInfo) sendPushToWebView(userInfo: userInfo) - - // Change this to your preferred presentation option completionHandler([[.banner, .list, .sound]]) } @@ -112,32 +56,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { let userInfo = response.notification.request.content.userInfo - // Print message ID. - if let messageID = userInfo[gcmMessageIDKey] { - print("Message ID 4: \(messageID)") - } - - // With swizzling disabled you must let Messaging know about the message, for Analytics - // Messaging.messaging().appDidReceiveMessage(userInfo) - // Print full message. - print("push userInfo 4:", userInfo) sendPushClickToWebView(userInfo: userInfo) - completionHandler() } } - // [END ios_10_message_handling] - - extension AppDelegate : MessagingDelegate { - // [START refresh_token] - func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { - print("Firebase registration token: \(String(describing: fcmToken))") - - let dataDict:[String: String] = ["token": fcmToken ?? ""] - NotificationCenter.default.post(name: Notification.Name("FCMToken"), object: nil, userInfo: dataDict) - handleFCMToken() - // TODO: If necessary send token to application server. - // Note: This callback is fired at each app startup and whenever a new token is generated. - } - // [END refresh_token] - } diff --git a/ios/src/ZotMeet/GoogleService-Info.plist b/ios/src/ZotMeet/GoogleService-Info.plist deleted file mode 100644 index a4d8f651d..000000000 --- a/ios/src/ZotMeet/GoogleService-Info.plist +++ /dev/null @@ -1,34 +0,0 @@ - - - - - CLIENT_ID - 000000000000-000000000000000000000000000000.apps.googleusercontent.com - REVERSED_CLIENT_ID - com.googleusercontent.apps.0000000000-00000000000000000000000 - API_KEY - 0000000000000000000000000 - GCM_SENDER_ID - 000000000000 - PLIST_VERSION - 1 - BUNDLE_ID - com.microsoft.pwabuilder-ios - PROJECT_ID - pwabuilder-ios-template - STORAGE_BUCKET - pwabuilder-ios-template.appspot.com - IS_ADS_ENABLED - - IS_ANALYTICS_ENABLED - - IS_APPINVITE_ENABLED - - IS_GCM_ENABLED - - IS_SIGNIN_ENABLED - - GOOGLE_APP_ID - 1:619930292029:ios:f6737372189b8ee9123f54 - - diff --git a/ios/src/ZotMeet/PushNotifications.swift b/ios/src/ZotMeet/PushNotifications.swift index 1bed14b4a..7a565fd9e 100644 --- a/ios/src/ZotMeet/PushNotifications.swift +++ b/ios/src/ZotMeet/PushNotifications.swift @@ -1,67 +1,88 @@ +import UIKit import WebKit -import FirebaseMessaging - -class SubscribeMessage { - var topic = "" - var eventValue = "" - var unsubscribe = false - struct Keys { - static var TOPIC = "topic" - static var UNSUBSCRIBE = "unsubscribe" - static var EVENTVALUE = "eventValue" + +var apnsDeviceTokenHex: String? + +func setApnsDeviceTokenHex(_ token: String) { + apnsDeviceTokenHex = token +} + +func registerForPushNotifications() { + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() } - convenience init(dict: Dictionary) { - self.init() - if let topic = dict[Keys.TOPIC] as? String { - self.topic = topic - } - if let unsubscribe = dict[Keys.UNSUBSCRIBE] as? Bool { - self.unsubscribe = unsubscribe - } - if let eventValue = dict[Keys.EVENTVALUE] as? String { - self.eventValue = eventValue - } +} + +func apnsTokenHexString(from deviceToken: Data) -> String { + return deviceToken.map { String(format: "%02.2hhx", $0) }.joined() +} + +func sendApnsTokenToWebView(token: String) { + let escaped = token.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + checkViewAndEvaluate(event: "push-token", detail: "'\(escaped)'") +} + +private let pushPayloadKeys: Set = [ + "type", "redirect", "title", "message", "groupId", "createdBy", +] + +func pushFieldString(_ value: Any?) -> String? { + guard let value = value else { return nil } + + if let string = value as? String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + if let string = value as? NSString { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + if let number = value as? NSNumber { + return number.stringValue + } + if let bool = value as? Bool { + return bool ? "true" : "false" + } + return nil +} + +private func pushPayloadKeyName(_ key: AnyHashable) -> String? { + if let key = key as? String { + return key } + if let key = key as? NSString { + return key as String + } + return nil } -func handleSubscribeTouch(message: WKScriptMessage) { - // [START subscribe_topic] - let subscribeMessages = parseSubscribeMessage(message: message) - if (subscribeMessages.count > 0){ - let _message = subscribeMessages[0] - if (_message.unsubscribe) { - Messaging.messaging().unsubscribe(fromTopic: _message.topic) { error in } +private func mergePushFields( + into payload: inout [String: String], + from record: [AnyHashable: Any] +) { + for (rawKey, value) in record { + guard let key = pushPayloadKeyName(rawKey), pushPayloadKeys.contains(key) else { + continue } - else { - Messaging.messaging().subscribe(toTopic: _message.topic) { error in } + if payload[key] != nil { + continue + } + if let string = pushFieldString(value) { + payload[key] = string } } - - - // [END subscribe_topic] } -func parseSubscribeMessage(message: WKScriptMessage) -> [SubscribeMessage] { - var subscribeMessages = [SubscribeMessage]() - if let objStr = message.body as? String { +func pushPayloadForWebView(userInfo: [AnyHashable: Any]) -> [String: String] { + var payload = [String: String]() + mergePushFields(into: &payload, from: userInfo) - let data: Data = objStr.data(using: .utf8)! - do { - let jsObj = try JSONSerialization.jsonObject(with: data, options: .init(rawValue: 0)) - if let jsonObjDict = jsObj as? Dictionary { - let subscribeMessage = SubscribeMessage(dict: jsonObjDict) - subscribeMessages.append(subscribeMessage) - } else if let jsonArr = jsObj as? [Dictionary] { - for jsonObj in jsonArr { - let sMessage = SubscribeMessage(dict: jsonObj) - subscribeMessages.append(sMessage) - } - } - } catch _ { - - } + if let nested = userInfo[AnyHashable("data")] as? [AnyHashable: Any] { + mergePushFields(into: &payload, from: nested) } - return subscribeMessages + + return payload } func returnPermissionResult(isGranted: Bool){ @@ -82,36 +103,34 @@ func returnPermissionState(state: String){ func handlePushPermission() { UNUserNotificationCenter.current().getNotificationSettings () { settings in - switch settings.authorizationStatus { - case .notDetermined: - let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound] - UNUserNotificationCenter.current().requestAuthorization( - options: authOptions, - completionHandler: { (success, error) in - if error == nil { - if success == true { - returnPermissionResult(isGranted: true) - DispatchQueue.main.async { - UIApplication.shared.registerForRemoteNotifications() - } - } - else { - returnPermissionResult(isGranted: false) - } - } - else { + switch settings.authorizationStatus { + case .notDetermined: + let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound] + UNUserNotificationCenter.current().requestAuthorization( + options: authOptions, + completionHandler: { (success, error) in + if error == nil { + if success == true { + registerForPushNotifications() + returnPermissionResult(isGranted: true) + } else { returnPermissionResult(isGranted: false) } } - ) - case .denied: - returnPermissionResult(isGranted: false) - case .authorized, .ephemeral, .provisional: - returnPermissionResult(isGranted: true) - @unknown default: - return; - } + else { + returnPermissionResult(isGranted: false) + } + } + ) + case .denied: + returnPermissionResult(isGranted: false) + case .authorized, .ephemeral, .provisional: + registerForPushNotifications() + returnPermissionResult(isGranted: true) + @unknown default: + return; } + } } func handlePushState() { UNUserNotificationCenter.current().getNotificationSettings () { settings in @@ -121,10 +140,13 @@ func handlePushState() { case .denied: returnPermissionState(state: "denied") case .authorized: + registerForPushNotifications() returnPermissionState(state: "authorized") case .ephemeral: + registerForPushNotifications() returnPermissionState(state: "ephemeral") case .provisional: + registerForPushNotifications() returnPermissionState(state: "provisional") @unknown default: returnPermissionState(state: "unknown") @@ -146,39 +168,46 @@ func checkViewAndEvaluate(event: String, detail: String) { } } -func handleFCMToken(){ +func handleApnsToken(){ DispatchQueue.main.async(execute: { - Messaging.messaging().token { token, error in - if let error = error { - print("Error fetching FCM registration token: \(error)") + if let token = apnsDeviceTokenHex, !token.isEmpty { + sendApnsTokenToWebView(token: token) + return + } + registerForPushNotifications() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + if let token = apnsDeviceTokenHex, !token.isEmpty { + sendApnsTokenToWebView(token: token) + } else { checkViewAndEvaluate(event: "push-token", detail: "ERROR GET TOKEN") - } else if let token = token { - print("FCM registration token: \(token)") - checkViewAndEvaluate(event: "push-token", detail: "'\(token)'") } - } + } }) } func sendPushToWebView(userInfo: [AnyHashable: Any]){ + let payload = pushPayloadForWebView(userInfo: userInfo) + guard !payload.isEmpty else { return } var json = ""; do { - let jsonData = try JSONSerialization.data(withJSONObject: userInfo) + let jsonData = try JSONSerialization.data(withJSONObject: payload) json = String(data: jsonData, encoding: .utf8)! } catch { - print("ERROR: userInfo parsing problem") + print("ERROR: push payload parsing problem") return } checkViewAndEvaluate(event: "push-notification", detail: json) } func sendPushClickToWebView(userInfo: [AnyHashable: Any]){ + let payload = pushPayloadForWebView(userInfo: userInfo) + guard !payload.isEmpty else { return } var json = ""; do { - let jsonData = try JSONSerialization.data(withJSONObject: userInfo) + let jsonData = try JSONSerialization.data(withJSONObject: payload) json = String(data: jsonData, encoding: .utf8)! } catch { - print("ERROR: userInfo parsing problem") + print("ERROR: push payload parsing problem") return } checkViewAndEvaluate(event: "push-notification-click", detail: json) diff --git a/ios/src/ZotMeet/Settings.swift b/ios/src/ZotMeet/Settings.swift index 049206776..bbf8c460a 100644 --- a/ios/src/ZotMeet/Settings.swift +++ b/ios/src/ZotMeet/Settings.swift @@ -5,8 +5,6 @@ struct Cookie { var value: String } -let gcmMessageIDKey = "00000000000" // update this with actual ID if using Firebase - // URL for first launch let rootUrl = URL(string: "https://zotmeet.com")! diff --git a/ios/src/ZotMeet/ViewController.swift b/ios/src/ZotMeet/ViewController.swift index 04ce9f615..57d8ff312 100644 --- a/ios/src/ZotMeet/ViewController.swift +++ b/ios/src/ZotMeet/ViewController.swift @@ -228,9 +228,6 @@ extension ViewController: WKScriptMessageHandler { if message.name == "print" { printView(webView: ZotMeet.webView) } - if message.name == "push-subscribe" { - handleSubscribeTouch(message: message) - } if message.name == "push-permission-request" { handlePushPermission() } @@ -238,7 +235,7 @@ extension ViewController: WKScriptMessageHandler { handlePushState() } if message.name == "push-token" { - handleFCMToken() + handleApnsToken() } } } diff --git a/ios/src/ZotMeet/WebView.swift b/ios/src/ZotMeet/WebView.swift index b3c7cf12b..d1b929813 100644 --- a/ios/src/ZotMeet/WebView.swift +++ b/ios/src/ZotMeet/WebView.swift @@ -10,7 +10,6 @@ func createWebView(container: UIView, WKSMH: WKScriptMessageHandler, WKND: WKNav let userContentController = WKUserContentController() userContentController.add(WKSMH, name: "print") - userContentController.add(WKSMH, name: "push-subscribe") userContentController.add(WKSMH, name: "push-permission-request") userContentController.add(WKSMH, name: "push-permission-state") userContentController.add(WKSMH, name: "push-token") diff --git a/package.json b/package.json index 42911c3c1..547c21396 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@aws-sdk/client-sesv2": "^3.1039.0", + "@aws-sdk/client-sns": "^3.1039.0", "@emotion/cache": "^11.14.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11d4fb4af..2162c3b02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,9 @@ importers: '@aws-sdk/client-sesv2': specifier: ^3.1039.0 version: 3.1039.0 + '@aws-sdk/client-sns': + specifier: ^3.1039.0 + version: 3.1054.0 '@emotion/cache': specifier: ^11.14.0 version: 11.14.0 @@ -35,7 +38,7 @@ importers: version: 7.3.7(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.3))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.3))(@types/react@19.2.7)(react@19.2.3))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@mui/material-nextjs': specifier: ^7.3.6 - version: 7.3.7(@emotion/cache@11.14.0)(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.3))(@types/react@19.2.7)(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 7.3.7(@emotion/cache@11.14.0)(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.3))(@types/react@19.2.7)(next@16.1.1(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) '@mui/x-date-pickers': specifier: ^8.27.2 version: 8.27.2(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.3))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.3))(@types/react@19.2.7)(react@19.2.3))(@mui/material@7.3.7(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.3))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.3))(@types/react@19.2.7)(react@19.2.3))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@mui/system@7.3.7(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.3))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.3))(@types/react@19.2.7)(react@19.2.3))(@types/react@19.2.7)(react@19.2.3))(@types/react@19.2.7)(date-fns@4.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -110,13 +113,13 @@ importers: version: 0.453.0(react@19.2.3) next: specifier: 16.1.1 - version: 16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 16.1.1(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) nuqs: specifier: ^2.7.3 - version: 2.8.8(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 2.8.8(next@16.1.1(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) pg: specifier: ^8.13.0 version: 8.18.0 @@ -186,7 +189,7 @@ importers: version: 0.28.1 drizzle-orm: specifier: 0.36.4 - version: 0.36.4(@types/pg@8.16.0)(@types/react@19.2.7)(pg@8.18.0)(react@19.2.3) + version: 0.36.4(@opentelemetry/api@1.9.1)(@types/pg@8.16.0)(@types/react@19.2.7)(pg@8.18.0)(react@19.2.3) husky: specifier: ^9.1.7 version: 9.1.7 @@ -218,6 +221,10 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + '@aws-crypto/sha256-browser@5.2.0': resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} @@ -235,6 +242,14 @@ packages: resolution: {integrity: sha512-I7k/0cW98TuIZFbeMNdrsoflVKDYAgiLstwy3wgW1Ss7t/hVdVXMrkISD+3o+TB1RR2pU/ZX11CIBTT+H74feQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-sns@3.1054.0': + resolution: {integrity: sha512-8Befe/r+dyKneJkqLBcO9dEwSNsrf1FRPUHGaVN535gq8jGTSv45ybNT4SWSWU76mxR8oGTbvNGWoJGvlMGjcQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.974.14': + resolution: {integrity: sha512-ppamm04uoj3hhNO5IlQSs5D6rWX1fWkzcn6a4pZrojk8Y6ObY9wzLDdT/Eq3gv6O9hOebi9tYTNB8b8fQj9XJw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.974.7': resolution: {integrity: sha512-YhRC90ofz5oolTJZlA8voU/oUrCB2azi8Usx51k8hhB5LpWbYQMMXKUqSqkoL0Cru+RQJgWTHpAfEDDIwfUhJw==} engines: {node: '>=20.0.0'} @@ -243,34 +258,66 @@ packages: resolution: {integrity: sha512-bJV7eViSJV6GSuuN+VIdNVPdwPsNSf75BiC2v5alPrjR/OCcqgKwSZInKbDFz9mNeizldsyf67jt6YSIiv53Cw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.40': + resolution: {integrity: sha512-jjT0p0Y7KZtcvExYiPCLJnqM9lkXDV1KBEg/13OE2DXv/9batzlyJHVKUEnRNJccY0O2Sul17E1su38CgdBhGQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.35': resolution: {integrity: sha512-x/BQGEIdq0oI+4WxLjKmnQvT7CnF9r8ezdGt7wXwxb7ckHXQz0Zmgxt8v3Ne0JaT3R5YefmuybHX6E8EnsDXyA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.42': + resolution: {integrity: sha512-+3fsKtWybe5BjKEUA3/07oh7Ayfd82IED2+gyyaVfS/4PU78E3TaOQxSGOJ1t7Imefoidw/ne9QA7apX8wEnJg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.37': resolution: {integrity: sha512-eUTpmWfd/BKsq9medhCRcu+GRAhFP2Zrn7/2jKDHHOOjCkhrMoTp/t4cEthqFoG7gE0VGp5wUxrXTdvBCmSmJg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.44': + resolution: {integrity: sha512-gZFw5wBefCIPg9vpT+gV5FdhfNKhYTVDZa1IsZCcn3SRoYUOJ/E05vwIogkJoonqBL0ttBGi5vhthX7xceekRg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.37': resolution: {integrity: sha512-Ty68y8ISSC+g5Q3D0K8uAaoINwvfaOslnNpsF/LgVUxyosYXHawcK2yV4HLXDVugiTTYLQfJfcw0ce5meAGkKw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.44': + resolution: {integrity: sha512-QqEGHfQeZgUDqh7zpqHufrZ8T644ELEWvB+4gUdewLyRw4IRF+6CJqeQuRWqucZdQzoQeMh7fNAD9BWxFAdNig==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.38': resolution: {integrity: sha512-BQ9XYnBDVxR2HuV5huXYQYF/PZMTsY+EnwfGnCU2cA8Zw63XpkOtPY8WqiMIZMQCrKPQQEiFURS/o9CIolRLqg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.45': + resolution: {integrity: sha512-3YCv52ExXIRz3LAVNysevd+s7akSpg9dl39v9LJ7dOQH+s5rHi3jMZYQyxwMmglxQGMuzYRfQ0o1VSP2UOlIRw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.33': resolution: {integrity: sha512-yfjGksI9WQbdMObb0VeLXqzTLI+a0qXLJT9gCDiv0+X/xjPpI3mTz6a5FibrhpuEKIe0gSgvs3MaoFZy5cx4WA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.40': + resolution: {integrity: sha512-cXaozlgJCOwmE6D7x4npcPdyk7kiFZdrGjN3D6tXXtItJJMNGPafDfAJn4YQmciMooG/X+b0Y6RTqdVVMx26jg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.37': resolution: {integrity: sha512-fpwE+20ntpp3i9Xb9vUuQfXLDKYHH+5I2V+ZG96SX1nBzrruhy10RXDgmN7t1etOz3c55stlA3TeQASUA451NQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.44': + resolution: {integrity: sha512-YePoj5kQuPmE0MHnyftXCfsO8ZSBd2kDr50XEIUrdejSbGFlayYvUuCohdb8drhGhPm6b65o7H1eC26EZhwUvA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.37': resolution: {integrity: sha512-aryawqyebf+3WhAFNHfF62rekFpYtVcVN7dQ89qnAWsa4n5hJst8qBG6gXC24WHtW7Nnhkf9ScYnjwo0Brn3bw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.44': + resolution: {integrity: sha512-Ys/JJe++8Z2Y5meR1taMBaVcrGBA0/XsVTQR+qOKZbdNyg+8Jlv5rYZSwh8SqEHY00goSOZy7PHzZ2rLNQxDLg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-host-header@3.972.10': resolution: {integrity: sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==} engines: {node: '>=20.0.0'} @@ -291,6 +338,10 @@ packages: resolution: {integrity: sha512-N1oNpdiLoVAWYD3WFBnUi3LlfoDA06ZHo4ozyjbsJNLvILzvt//0CnR8N+CZ0NWeYgVB/5V59ivixHCWCx2ALw==} engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.997.12': + resolution: {integrity: sha512-Js2VYaCM269feB0cs0cGmlIhdOgT9aMqzdBx68lCy6kVCYfzr0T36ovUFDvfUmatkuBeyBJhCwaLBh7P8meH5Q==} + engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.997.5': resolution: {integrity: sha512-jGFr6DxtcMTmzOkG/a0jCZYv4BBDmeNYVeO+/memSoDkYCJu4Y58xviYmzwJfYyIVSts+X/BVjJm1uGBnwHEMg==} engines: {node: '>=20.0.0'} @@ -303,14 +354,26 @@ packages: resolution: {integrity: sha512-amP7tLikppN940wbBFISYqiuzVmpzMS9U3mcgtmVLjX4fdWI/SNCvrXv6ZxfVzTT4cT0rPKOLhFah2xLwzREWw==} engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.29': + resolution: {integrity: sha512-Few9FoQqOt/0KSvZYP+qdW0dfOhfQ9N+gl2UUDvCPW6mkPKHli9LMbKxWj+wZ5zKPaOoqxuR3Hhy3OTpndkfSw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1039.0': resolution: {integrity: sha512-NMSFL2HwkAOoCeLCQiqoOq5pT3vVbSjww2QZTuYgYknVwhhv125PSDzZIcL5EYnlxuPWjEOdauZK+FspkZDVdw==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1054.0': + resolution: {integrity: sha512-hG9YKApmZOw+drJ9Nuoaf/OvC8e5W1+3eoLeN5p2uVCZRWsv27teIS0b4kiH6Sfv3WMmamqYJxmE2WMwyp/L/A==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.8': resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==} engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.9': + resolution: {integrity: sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-arn-parser@3.972.3': resolution: {integrity: sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==} engines: {node: '>=20.0.0'} @@ -339,6 +402,10 @@ packages: resolution: {integrity: sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.26': + resolution: {integrity: sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.2.4': resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} @@ -1520,6 +1587,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + '@oslojs/asn1@1.0.0': resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} @@ -2020,14 +2091,26 @@ packages: resolution: {integrity: sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==} engines: {node: '>=18.0.0'} + '@smithy/core@3.24.4': + resolution: {integrity: sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.2.14': resolution: {integrity: sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==} engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.3.4': + resolution: {integrity: sha512-vKW0MEFRU4Y3MkVZUkpJm+g9qyPGLCXhc0YLggUdSdBB4g7IaSSsCE75P9rBXyWHrXY1UYSQUl8/DwsTR7QciA==} + engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.3.17': resolution: {integrity: sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.4.4': + resolution: {integrity: sha512-qM7AUKI4G6d7lNgaZD3lA1tWSolh5r6gcixfTZAPstVURfjIbvreVTPz+994M0yC3HbX4YYhDRgr31Xy3XwWOQ==} + engines: {node: '>=18.0.0'} + '@smithy/hash-node@4.2.14': resolution: {integrity: sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==} engines: {node: '>=18.0.0'} @@ -2072,6 +2155,10 @@ packages: resolution: {integrity: sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==} engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.7.4': + resolution: {integrity: sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==} + engines: {node: '>=18.0.0'} + '@smithy/property-provider@4.2.14': resolution: {integrity: sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==} engines: {node: '>=18.0.0'} @@ -2100,6 +2187,10 @@ packages: resolution: {integrity: sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==} engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.4.4': + resolution: {integrity: sha512-e5UtkMvsatzBfbeBZjEOt0k0Z3BEsjTFL/n6fdO5vtBLe67tdy0dX7xw2DU7uZ3acwoHyeCqpU2Fzb7pxwHb6Q==} + engines: {node: '>=18.0.0'} + '@smithy/smithy-client@4.12.13': resolution: {integrity: sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==} engines: {node: '>=18.0.0'} @@ -2108,6 +2199,10 @@ packages: resolution: {integrity: sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==} engines: {node: '>=18.0.0'} + '@smithy/types@4.14.2': + resolution: {integrity: sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==} + engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.2.14': resolution: {integrity: sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==} engines: {node: '>=18.0.0'} @@ -2770,10 +2865,17 @@ packages: fast-xml-builder@1.1.5: resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==} + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} + fast-xml-parser@5.7.2: resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==} hasBin: true + fast-xml-parser@5.7.3: + resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -3993,6 +4095,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + xml2js@0.6.2: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} @@ -4069,6 +4175,12 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.9 + tslib: 2.8.1 + '@aws-crypto/sha256-browser@5.2.0': dependencies: '@aws-crypto/sha256-js': 5.2.0 @@ -4140,6 +4252,30 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-sns@3.1054.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.14 + '@aws-sdk/credential-provider-node': 3.972.45 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/core@3.974.14': + dependencies: + '@aws-sdk/types': 3.973.9 + '@aws-sdk/xml-builder': 3.972.26 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.24.4 + '@smithy/signature-v4': 5.4.4 + '@smithy/types': 4.14.2 + bowser: 2.14.1 + tslib: 2.8.1 + '@aws-sdk/core@3.974.7': dependencies: '@aws-sdk/types': 3.973.8 @@ -4165,6 +4301,14 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.40': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.35': dependencies: '@aws-sdk/core': 3.974.7 @@ -4178,6 +4322,16 @@ snapshots: '@smithy/util-stream': 4.5.25 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.42': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.972.37': dependencies: '@aws-sdk/core': 3.974.7 @@ -4197,6 +4351,22 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-ini@3.972.44': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/credential-provider-env': 3.972.40 + '@aws-sdk/credential-provider-http': 3.972.42 + '@aws-sdk/credential-provider-login': 3.972.44 + '@aws-sdk/credential-provider-process': 3.972.40 + '@aws-sdk/credential-provider-sso': 3.972.44 + '@aws-sdk/credential-provider-web-identity': 3.972.44 + '@aws-sdk/nested-clients': 3.997.12 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/credential-provider-imds': 4.3.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.37': dependencies: '@aws-sdk/core': 3.974.7 @@ -4210,6 +4380,15 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-login@3.972.44': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/nested-clients': 3.997.12 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.38': dependencies: '@aws-sdk/credential-provider-env': 3.972.33 @@ -4227,6 +4406,20 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-node@3.972.45': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.40 + '@aws-sdk/credential-provider-http': 3.972.42 + '@aws-sdk/credential-provider-ini': 3.972.44 + '@aws-sdk/credential-provider-process': 3.972.40 + '@aws-sdk/credential-provider-sso': 3.972.44 + '@aws-sdk/credential-provider-web-identity': 3.972.44 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/credential-provider-imds': 4.3.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.33': dependencies: '@aws-sdk/core': 3.974.7 @@ -4236,6 +4429,14 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.40': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.972.37': dependencies: '@aws-sdk/core': 3.974.7 @@ -4249,6 +4450,16 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-sso@3.972.44': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/nested-clients': 3.997.12 + '@aws-sdk/token-providers': 3.1054.0 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.37': dependencies: '@aws-sdk/core': 3.974.7 @@ -4261,6 +4472,15 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-web-identity@3.972.44': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/nested-clients': 3.997.12 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/middleware-host-header@3.972.10': dependencies: '@aws-sdk/types': 3.973.8 @@ -4310,6 +4530,19 @@ snapshots: '@smithy/util-retry': 4.3.6 tslib: 2.8.1 + '@aws-sdk/nested-clients@3.997.12': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.14 + '@aws-sdk/signature-v4-multi-region': 3.996.29 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/nested-clients@3.997.5': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -4371,6 +4604,13 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.29': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/signature-v4': 5.4.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.1039.0': dependencies: '@aws-sdk/core': 3.974.7 @@ -4383,11 +4623,25 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/token-providers@3.1054.0': + dependencies: + '@aws-sdk/core': 3.974.14 + '@aws-sdk/nested-clients': 3.997.12 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/types@3.973.8': dependencies: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@aws-sdk/types@3.973.9': + dependencies: + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@aws-sdk/util-arn-parser@3.972.3': dependencies: tslib: 2.8.1 @@ -4427,6 +4681,12 @@ snapshots: fast-xml-parser: 5.7.2 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.26': + dependencies: + '@smithy/types': 4.14.2 + fast-xml-parser: 5.7.3 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.2.4': {} '@babel/code-frame@7.29.0': @@ -5109,11 +5369,11 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 - '@mui/material-nextjs@7.3.7(@emotion/cache@11.14.0)(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.3))(@types/react@19.2.7)(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': + '@mui/material-nextjs@7.3.7(@emotion/cache@11.14.0)(@emotion/react@11.14.0(@types/react@19.2.7)(react@19.2.3))(@types/react@19.2.7)(next@16.1.1(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': dependencies: '@babel/runtime': 7.28.6 '@emotion/react': 11.14.0(@types/react@19.2.7)(react@19.2.3) - next: 16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next: 16.1.1(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 optionalDependencies: '@emotion/cache': 11.14.0 @@ -5334,6 +5594,9 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@opentelemetry/api@1.9.1': + optional: true + '@oslojs/asn1@1.0.0': dependencies: '@oslojs/binary': 1.0.0 @@ -5851,6 +6114,12 @@ snapshots: '@smithy/uuid': 1.1.2 tslib: 2.8.1 + '@smithy/core@3.24.4': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.2.14': dependencies: '@smithy/node-config-provider': 4.3.14 @@ -5859,6 +6128,12 @@ snapshots: '@smithy/url-parser': 4.2.14 tslib: 2.8.1 + '@smithy/credential-provider-imds@4.3.4': + dependencies: + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@smithy/fetch-http-handler@5.3.17': dependencies: '@smithy/protocol-http': 5.3.14 @@ -5867,6 +6142,12 @@ snapshots: '@smithy/util-base64': 4.3.2 tslib: 2.8.1 + '@smithy/fetch-http-handler@5.4.4': + dependencies: + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@smithy/hash-node@4.2.14': dependencies: '@smithy/types': 4.14.1 @@ -5943,6 +6224,12 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@smithy/node-http-handler@4.7.4': + dependencies: + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@smithy/property-provider@4.2.14': dependencies: '@smithy/types': 4.14.1 @@ -5984,6 +6271,12 @@ snapshots: '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 + '@smithy/signature-v4@5.4.4': + dependencies: + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@smithy/smithy-client@4.12.13': dependencies: '@smithy/core': 3.23.17 @@ -5998,6 +6291,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/types@4.14.2': + dependencies: + tslib: 2.8.1 + '@smithy/url-parser@4.2.14': dependencies: '@smithy/querystring-parser': 4.2.14 @@ -6475,8 +6772,9 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.36.4(@types/pg@8.16.0)(@types/react@19.2.7)(pg@8.18.0)(react@19.2.3): + drizzle-orm@0.36.4(@opentelemetry/api@1.9.1)(@types/pg@8.16.0)(@types/react@19.2.7)(pg@8.18.0)(react@19.2.3): optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/pg': 8.16.0 '@types/react': 19.2.7 pg: 8.18.0 @@ -6676,6 +6974,11 @@ snapshots: dependencies: path-expression-matcher: 1.5.0 + fast-xml-builder@1.2.0: + dependencies: + path-expression-matcher: 1.5.0 + xml-naming: 0.1.0 + fast-xml-parser@5.7.2: dependencies: '@nodable/entities': 2.1.0 @@ -6683,6 +6986,13 @@ snapshots: path-expression-matcher: 1.5.0 strnum: 2.2.3 + fast-xml-parser@5.7.3: + dependencies: + '@nodable/entities': 2.1.0 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.2.3 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -7115,7 +7425,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.1.1(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 16.1.1 '@swc/helpers': 0.5.15 @@ -7134,6 +7444,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.1.1 '@next/swc-win32-arm64-msvc': 16.1.1 '@next/swc-win32-x64-msvc': 16.1.1 + '@opentelemetry/api': 1.9.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -7145,12 +7456,12 @@ snapshots: normalize-path@3.0.0: {} - nuqs@2.8.8(next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): + nuqs@2.8.8(next@16.1.1(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): dependencies: '@standard-schema/spec': 1.0.0 react: 19.2.3 optionalDependencies: - next: 16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next: 16.1.1(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) object-assign@4.1.1: {} @@ -7878,6 +8189,8 @@ snapshots: wrappy@1.0.2: {} + xml-naming@0.1.0: {} + xml2js@0.6.2: dependencies: sax: 1.2.1 diff --git a/src/app/api/push-tokens/route.ts b/src/app/api/push-tokens/route.ts new file mode 100644 index 000000000..e6c8836ab --- /dev/null +++ b/src/app/api/push-tokens/route.ts @@ -0,0 +1,122 @@ +import { and, eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { db } from "@/db"; +import { nativePushTokens } from "@/db/schema"; +import { getCurrentSession } from "@/lib/auth"; +import { isApnsDeviceToken, isSnsEndpointArn } from "@/lib/push/sns-config"; +import { + deleteIosPushEndpoint, + registerIosPushEndpoint, +} from "@/lib/push/sns-register"; + +type PushTokenPayload = { + token?: unknown; + platform?: unknown; +}; + +async function readPayload(request: Request) { + try { + return (await request.json()) as PushTokenPayload; + } catch { + return null; + } +} + +function getValidApnsToken(payload: PushTokenPayload) { + if (typeof payload.token !== "string") return null; + + const token = payload.token.trim(); + if (!token || token === "ERROR GET TOKEN") return null; + if (!isApnsDeviceToken(token)) return null; + + return token; +} + +export async function POST(request: Request) { + const { user } = await getCurrentSession(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const payload = await readPayload(request); + if (!payload) { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const apnsToken = getValidApnsToken(payload); + if (!apnsToken) { + return NextResponse.json( + { error: "Invalid APNs device token" }, + { status: 400 }, + ); + } + + if (payload.platform !== "ios") { + return NextResponse.json( + { error: "Only native iOS push tokens are supported" }, + { status: 400 }, + ); + } + + const endpointArn = await registerIosPushEndpoint(apnsToken, user.id); + if (!endpointArn) { + return NextResponse.json( + { error: "Push registration is not configured" }, + { status: 503 }, + ); + } + + const platform = "ios"; + + await db + .insert(nativePushTokens) + .values({ + userId: user.id, + token: endpointArn, + platform, + userAgent: request.headers.get("user-agent"), + }) + .onConflictDoUpdate({ + target: nativePushTokens.token, + set: { + userId: user.id, + platform, + userAgent: request.headers.get("user-agent"), + updatedAt: new Date(), + }, + }); + + return NextResponse.json({ success: true }); +} + +export async function DELETE(request: Request) { + const { user } = await getCurrentSession(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const payload = await readPayload(request); + if (!payload) { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const token = typeof payload.token === "string" ? payload.token.trim() : ""; + if (!token) { + return NextResponse.json({ error: "Invalid token" }, { status: 400 }); + } + + if (isSnsEndpointArn(token)) { + await deleteIosPushEndpoint(token); + } + + await db + .delete(nativePushTokens) + .where( + and( + eq(nativePushTokens.userId, user.id), + eq(nativePushTokens.token, token), + ), + ); + + return NextResponse.json({ success: true }); +} diff --git a/src/components/nav/mui-app-shell.tsx b/src/components/nav/mui-app-shell.tsx index a6d736b3f..1bb030383 100644 --- a/src/components/nav/mui-app-shell.tsx +++ b/src/components/nav/mui-app-shell.tsx @@ -2,6 +2,7 @@ import { Box, useMediaQuery, useTheme } from "@mui/material"; import { usePathname } from "next/navigation"; +import { NativeIosPushBridge } from "@/components/push/native-ios-push-bridge"; import type { NotificationItem, UserProfile } from "@/lib/auth/user"; import { MuiBottomNav } from "./mui-bottom-nav"; import { MuiTopNav } from "./mui-top-nav"; @@ -37,6 +38,7 @@ export function MuiAppShell({ minHeight: "100vh", }} > + {user ? : null} {!isMobile && } void; + } + >; + }; + } +} + +function getBridgeHandler(name: string) { + return window.webkit?.messageHandlers?.[name]; +} + +function postMessage(name: string, payload?: unknown) { + const handler = getBridgeHandler(name); + if (!handler) return; + handler.postMessage(payload ? JSON.stringify(payload) : ""); +} + +async function savePushToken(token: string) { + const trimmedToken = token.trim(); + if (!trimmedToken || trimmedToken === "ERROR GET TOKEN") return; + + try { + const response = await fetch("/api/push-tokens", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: trimmedToken, platform: "ios" }), + }); + + if (!response.ok) { + throw new Error("Failed to save native push token"); + } + } catch (error) { + console.error("Failed to save native push token", error); + } +} + +export function NativeIosPushBridge() { + const router = useRouter(); + + useEffect(() => { + if (!isNativeIosApp()) return; + if (!window.webkit?.messageHandlers) return; + + const handlePermissionState = (event: Event) => { + const detail = (event as CustomEvent).detail; + if ( + detail === "authorized" || + detail === "ephemeral" || + detail === "provisional" + ) { + postMessage("push-token"); + return; + } + + if (detail === "notDetermined") { + postMessage("push-permission-request"); + } + }; + + const handlePermissionRequestResult = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail === "granted") { + postMessage("push-token"); + } + }; + + const handlePushToken = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (typeof detail === "string") { + void savePushToken(detail); + } + }; + + const handleNotificationClick = (event: Event) => { + const payload = parseNativePushPayload((event as CustomEvent).detail); + router.push(resolvePushNotificationPath(payload.type, payload.redirect)); + }; + + window.addEventListener("push-permission-state", handlePermissionState); + window.addEventListener( + "push-permission-request", + handlePermissionRequestResult, + ); + window.addEventListener("push-token", handlePushToken); + window.addEventListener("push-notification-click", handleNotificationClick); + + postMessage("push-permission-state"); + + return () => { + window.removeEventListener( + "push-permission-state", + handlePermissionState, + ); + window.removeEventListener( + "push-permission-request", + handlePermissionRequestResult, + ); + window.removeEventListener("push-token", handlePushToken); + window.removeEventListener( + "push-notification-click", + handleNotificationClick, + ); + }; + }, [router]); + + return null; +} diff --git a/src/db/migrations/0018_native_push_tokens.sql b/src/db/migrations/0018_native_push_tokens.sql new file mode 100644 index 000000000..3d95e3fd2 --- /dev/null +++ b/src/db/migrations/0018_native_push_tokens.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS "native_push_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "token" text NOT NULL, + "platform" text DEFAULT 'ios' NOT NULL, + "user_agent" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "native_push_tokens_token_unique" UNIQUE("token") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "native_push_tokens" ADD CONSTRAINT "native_push_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "native_push_tokens_user_id_idx" ON "native_push_tokens" USING btree ("user_id"); \ No newline at end of file diff --git a/src/db/migrations/meta/0018_snapshot.json b/src/db/migrations/meta/0018_snapshot.json new file mode 100644 index 000000000..78924d154 --- /dev/null +++ b/src/db/migrations/meta/0018_snapshot.json @@ -0,0 +1,1378 @@ +{ + "id": "b14ad417-4e8b-4bde-8702-5c666f68c39a", + "prevId": "994d6447-5510-4538-867f-4b641182950b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.availabilities": { + "name": "availabilities", + "schema": "", + "columns": { + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "meeting_id": { + "name": "meeting_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "attendance", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "meeting_availabilities": { + "name": "meeting_availabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "ifNeeded_availabilities": { + "name": "ifNeeded_availabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "availabilities_member_id_members_id_fk": { + "name": "availabilities_member_id_members_id_fk", + "tableFrom": "availabilities", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "availabilities_meeting_id_meetings_id_fk": { + "name": "availabilities_meeting_id_meetings_id_fk", + "tableFrom": "availabilities", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "availabilities_member_id_meeting_id_pk": { + "name": "availabilities_member_id_meeting_id_pk", + "columns": [ + "member_id", + "meeting_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_invite_responses": { + "name": "group_invite_responses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_id": { + "name": "invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invite_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "responded_at": { + "name": "responded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "group_invite_responses_invite_id_group_invites_id_fk": { + "name": "group_invite_responses_invite_id_group_invites_id_fk", + "tableFrom": "group_invite_responses", + "tableTo": "group_invites", + "columnsFrom": [ + "invite_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_invite_responses_user_id_users_id_fk": { + "name": "group_invite_responses_user_id_users_id_fk", + "tableFrom": "group_invite_responses", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "group_invite_responses_invite_user_unique": { + "name": "group_invite_responses_invite_user_unique", + "nullsNotDistinct": false, + "columns": [ + "invite_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.group_invites": { + "name": "group_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invite_token": { + "name": "invite_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitee_email": { + "name": "invitee_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "group_invites_group_id_groups_id_fk": { + "name": "group_invites_group_id_groups_id_fk", + "tableFrom": "group_invites", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_invites_inviter_id_users_id_fk": { + "name": "group_invites_inviter_id_users_id_fk", + "tableFrom": "group_invites", + "tableTo": "users", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "group_invites_invite_token_unique": { + "name": "group_invites_invite_token_unique", + "nullsNotDistinct": false, + "columns": [ + "invite_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.groups": { + "name": "groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "groups_user_id_users_id_fk": { + "name": "groups_user_id_users_id_fk", + "tableFrom": "groups", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meeting_invite_responses": { + "name": "meeting_invite_responses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_id": { + "name": "invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invite_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "responded_at": { + "name": "responded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "meeting_invite_responses_invite_id_meeting_invites_id_fk": { + "name": "meeting_invite_responses_invite_id_meeting_invites_id_fk", + "tableFrom": "meeting_invite_responses", + "tableTo": "meeting_invites", + "columnsFrom": [ + "invite_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meeting_invite_responses_user_id_users_id_fk": { + "name": "meeting_invite_responses_user_id_users_id_fk", + "tableFrom": "meeting_invite_responses", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "meeting_invite_responses_invite_user_unique": { + "name": "meeting_invite_responses_invite_user_unique", + "nullsNotDistinct": false, + "columns": [ + "invite_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meeting_invites": { + "name": "meeting_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "meeting_id": { + "name": "meeting_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invite_token": { + "name": "invite_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "meeting_invites_meeting_id_meetings_id_fk": { + "name": "meeting_invites_meeting_id_meetings_id_fk", + "tableFrom": "meeting_invites", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meeting_invites_inviter_id_users_id_fk": { + "name": "meeting_invites_inviter_id_users_id_fk", + "tableFrom": "meeting_invites", + "tableTo": "users", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "meeting_invites_invite_token_unique": { + "name": "meeting_invites_invite_token_unique", + "nullsNotDistinct": false, + "columns": [ + "invite_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meetings": { + "name": "meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled": { + "name": "scheduled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "from_time": { + "name": "from_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "to_time": { + "name": "to_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "host_id": { + "name": "host_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dates": { + "name": "dates", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "meeting_type": { + "name": "meeting_type", + "type": "meeting_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'dates'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "members_can_invite": { + "name": "members_can_invite", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "meetings_group_id_groups_id_fk": { + "name": "meetings_group_id_groups_id_fk", + "tableFrom": "meetings", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meetings_host_id_members_id_fk": { + "name": "meetings_host_id_members_id_fk", + "tableFrom": "meetings", + "tableTo": "members", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "google_name": { + "name": "google_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "year": { + "name": "year", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "school": { + "name": "school", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "profile_picture": { + "name": "profile_picture", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "members_username_unique": { + "name": "members_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.native_push_tokens": { + "name": "native_push_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ios'" + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "native_push_tokens_user_id_idx": { + "name": "native_push_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "native_push_tokens_user_id_users_id_fk": { + "name": "native_push_tokens_user_id_users_id_fk", + "tableFrom": "native_push_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "native_push_tokens_token_unique": { + "name": "native_push_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_preferences": { + "name": "notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "meeting_invites": { + "name": "meeting_invites", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "group_invites": { + "name": "group_invites", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "nudges": { + "name": "nudges", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "notification_preferences_member_id_members_id_fk": { + "name": "notification_preferences_member_id_members_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notification_preferences_member_id_unique": { + "name": "notification_preferences_member_id_unique", + "nullsNotDistinct": false, + "columns": [ + "member_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect": { + "name": "redirect", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "notifications_user_id_members_id_fk": { + "name": "notifications_user_id_members_id_fk", + "tableFrom": "notifications", + "tableTo": "members", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_group_id_groups_id_fk": { + "name": "notifications_group_id_groups_id_fk", + "tableFrom": "notifications", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_accounts": { + "name": "oauth_accounts", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_accounts_user_id_users_id_fk": { + "name": "oauth_accounts_user_id_users_id_fk", + "tableFrom": "oauth_accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "oauth_accounts_provider_id_provider_user_id_pk": { + "name": "oauth_accounts_provider_id_provider_user_id_pk", + "columns": [ + "provider_id", + "provider_user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scheduled_meetings": { + "name": "scheduled_meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "meeting_id": { + "name": "meeting_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scheduled_date": { + "name": "scheduled_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "scheduled_from_time": { + "name": "scheduled_from_time", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "scheduled_to_time": { + "name": "scheduled_to_time", + "type": "time", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "scheduled_meetings_meeting_id_meetings_id_fk": { + "name": "scheduled_meetings_meeting_id_meetings_id_fk", + "tableFrom": "scheduled_meetings", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_access_token": { + "name": "oidc_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oidc_refresh_token": { + "name": "oidc_refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "google_access_token": { + "name": "google_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "google_refresh_token": { + "name": "google_refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "google_access_token_expires_at": { + "name": "google_access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_idx_sessions": { + "name": "user_idx_sessions", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "theme_mode": { + "name": "theme_mode", + "type": "theme_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'light'" + } + }, + "indexes": {}, + "foreignKeys": { + "users_member_id_members_id_fk": { + "name": "users_member_id_members_id_fk", + "tableFrom": "users", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users_in_group": { + "name": "users_in_group", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "group_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + } + }, + "indexes": {}, + "foreignKeys": { + "users_in_group_user_id_users_id_fk": { + "name": "users_in_group_user_id_users_id_fk", + "tableFrom": "users_in_group", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "users_in_group_group_id_groups_id_fk": { + "name": "users_in_group_group_id_groups_id_fk", + "tableFrom": "users_in_group", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "users_in_group_group_id_user_id_pk": { + "name": "users_in_group_group_id_user_id_pk", + "columns": [ + "group_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.attendance": { + "name": "attendance", + "schema": "public", + "values": [ + "accepted", + "maybe", + "declined" + ] + }, + "public.group_role": { + "name": "group_role", + "schema": "public", + "values": [ + "member", + "admin" + ] + }, + "public.invite_status": { + "name": "invite_status", + "schema": "public", + "values": [ + "pending", + "accepted", + "declined", + "expired" + ] + }, + "public.meeting_type": { + "name": "meeting_type", + "schema": "public", + "values": [ + "dates", + "days" + ] + }, + "public.theme_mode": { + "name": "theme_mode", + "schema": "public", + "values": [ + "light", + "dark", + "system" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 054d925e6..d60ef8258 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1779145500000, "tag": "0017_notification_preferences", "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1779985305821, + "tag": "0018_native_push_tokens", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index a6f0bbbff..f2553b0dd 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -347,6 +347,37 @@ export const notificationPreferencesRelations = relations( }), ); +export const nativePushTokens = pgTable( + "native_push_tokens", + { + id: uuid("id").defaultRandom().primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + token: text("token").notNull().unique(), + platform: text("platform").notNull().default("ios"), + userAgent: text("user_agent"), + createdAt: timestamp("created_at", { + withTimezone: true, + mode: "date", + }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { + withTimezone: true, + mode: "date", + }) + .defaultNow() + .notNull(), + }, + (table) => ({ + userIdIdx: index("native_push_tokens_user_id_idx").on(table.userId), + }), +); + +export type SelectNativePushToken = InferSelectModel; +export type InsertNativePushToken = InferInsertModel; + export const availabilities = pgTable( "availabilities", { diff --git a/src/lib/notification/types.ts b/src/lib/notification/types.ts index 03ddd7483..b2584d114 100644 --- a/src/lib/notification/types.ts +++ b/src/lib/notification/types.ts @@ -62,17 +62,18 @@ export const NOTIFICATION_PREF_OPTIONS: { key: "meetingInvites", label: "Meeting Invites", description: - "Receive in-app and email notifications when you're invited to a meeting.", + "Receive in-app, email, and iOS push notifications when you're invited to a meeting.", }, { key: "groupInvites", label: "Group Invites", description: - "Receive in-app and email notifications when you're invited to a group.", + "Receive in-app, email, and iOS push notifications when you're invited to a group.", }, { key: "nudges", label: "Nudges", - description: "Receive in-app and email reminders to add your availability.", + description: + "Receive in-app, email, and iOS push reminders to add your availability.", }, ]; diff --git a/src/lib/push/parse-payload.ts b/src/lib/push/parse-payload.ts new file mode 100644 index 000000000..c4455562d --- /dev/null +++ b/src/lib/push/parse-payload.ts @@ -0,0 +1,73 @@ +const PUSH_DATA_KEYS = [ + "type", + "redirect", + "title", + "message", + "groupId", + "createdBy", +] as const; + +export type NativePushPayload = { + type?: string; + redirect?: string; + title?: string; + message?: string; + groupId?: string; + createdBy?: string; +}; + +function stringField(value: unknown): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return undefined; +} + +function pickPushFields(record: Record): NativePushPayload { + const payload: NativePushPayload = {}; + + for (const key of PUSH_DATA_KEYS) { + const value = stringField(record[key]); + if (value !== undefined) { + payload[key] = value; + } + } + + return payload; +} + +/** + * Extracts APNs custom data from the JSON blob iOS forwards from `userInfo`. + */ +export function parseNativePushPayload(detail: unknown): NativePushPayload { + if (typeof detail === "string") { + try { + return parseNativePushPayload(JSON.parse(detail) as unknown); + } catch { + return {}; + } + } + + if (!detail || typeof detail !== "object") { + return {}; + } + + const record = detail as Record; + const direct = pickPushFields(record); + if (direct.type || direct.redirect) { + return direct; + } + + if (record.data && typeof record.data === "object") { + const nested = pickPushFields(record.data as Record); + if (nested.type || nested.redirect) { + return nested; + } + } + + return direct; +} diff --git a/src/lib/push/redirect.ts b/src/lib/push/redirect.ts new file mode 100644 index 000000000..fc2594f85 --- /dev/null +++ b/src/lib/push/redirect.ts @@ -0,0 +1,67 @@ +import { NOTIFICATION_TYPES } from "@/lib/notification/types"; + +function isHttpUrl(redirect: string): boolean { + return redirect.startsWith("http://") || redirect.startsWith("https://"); +} + +function toAppPath(redirect: string): string { + if (isHttpUrl(redirect)) { + try { + const url = new URL(redirect); + return `${url.pathname}${url.search}${url.hash}` || "/summary"; + } catch { + return "/summary"; + } + } + return redirect.startsWith("/") ? redirect : "/summary"; +} + +/** Bare invite tokens are stored without a leading slash. */ +function looksLikeInviteToken(redirect: string): boolean { + return !redirect.startsWith("/") && !isHttpUrl(redirect); +} + +/** + * App-relative path used in APNs/SNS payloads and native tap handling. + * In-app notification rows may still store full URLs or invite tokens. + * + * When `type` is omitted (legacy or partial APNs payloads), infers the path from + * `redirect` shape (e.g. `/availability/…`, `/invite/…`, or a bare invite token). + */ +export function normalizePushRedirect( + type: string | undefined, + redirect: string, +): string { + const trimmed = redirect.trim(); + if (!trimmed) return "/summary"; + + if (type === NOTIFICATION_TYPES.GROUP_INVITE) { + if (trimmed.startsWith("/invite/")) return trimmed; + if (trimmed.startsWith("/")) return trimmed; + return `/invite/${encodeURIComponent(trimmed)}`; + } + + if (!type) { + if ( + trimmed.startsWith("/invite/") || + trimmed.startsWith("/availability/") + ) { + return trimmed; + } + if (looksLikeInviteToken(trimmed)) { + return `/invite/${encodeURIComponent(trimmed)}`; + } + } + + // Meeting invites, nudges, unknown types, and legacy redirects without type. + return toAppPath(trimmed); +} + +/** Resolves a tap target from native push payload fields. */ +export function resolvePushNotificationPath( + type: string | undefined, + redirect: string | undefined, +): string { + if (!redirect?.trim()) return "/summary"; + return normalizePushRedirect(type, redirect); +} diff --git a/src/lib/push/send-push.ts b/src/lib/push/send-push.ts new file mode 100644 index 000000000..aa042f108 --- /dev/null +++ b/src/lib/push/send-push.ts @@ -0,0 +1,128 @@ +import "server-only"; + +import { PublishCommand } from "@aws-sdk/client-sns"; +import { and, eq, inArray } from "drizzle-orm"; +import { db } from "@/db"; +import { nativePushTokens } from "@/db/schema"; +import { normalizePushRedirect } from "@/lib/push/redirect"; +import { getSnsClient } from "@/lib/push/sns-client"; +import { + getSnsIosApnsEnv, + getSnsIosPlatformApplicationArn, + isSnsEndpointArn, +} from "@/lib/push/sns-config"; +import { deleteIosPushEndpoint } from "@/lib/push/sns-register"; + +type PushPayload = { + title: string; + message: string; + type: string; + redirect: string; + groupId?: string | null; + createdBy?: string | null; +}; + +function chunkArray(items: T[], size: number) { + const chunks: T[][] = []; + for (let index = 0; index < items.length; index += size) { + chunks.push(items.slice(index, index + size)); + } + return chunks; +} + +function buildSnsApnsMessage(payload: PushPayload, redirect: string): string { + const apnsPayload = { + aps: { + alert: { + title: payload.title, + body: payload.message, + }, + sound: "default", + }, + type: payload.type, + redirect, + title: payload.title, + message: payload.message, + groupId: payload.groupId ?? "", + createdBy: payload.createdBy ?? "", + }; + + const apns = JSON.stringify(apnsPayload); + const apnsKey = getSnsIosApnsEnv() === "sandbox" ? "APNS_SANDBOX" : "APNS"; + + return JSON.stringify({ + default: payload.message, + [apnsKey]: apns, + }); +} + +function isStaleEndpointError(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const name = "name" in error ? String(error.name) : ""; + const message = "message" in error ? String(error.message) : ""; + return ( + name === "EndpointDisabledException" || + name === "InvalidParameterException" || + message.includes("Endpoint is disabled") || + message.includes("Invalid parameter: TargetArn") + ); +} + +export async function sendPushToUsers(userIds: string[], payload: PushPayload) { + if (userIds.length === 0) return; + if (!getSnsIosPlatformApplicationArn()) return; + + const tokenRows = await db + .select({ token: nativePushTokens.token }) + .from(nativePushTokens) + .where( + and( + inArray(nativePushTokens.userId, userIds), + eq(nativePushTokens.platform, "ios"), + ), + ); + + const endpointArns = [ + ...new Set( + tokenRows + .map((row) => row.token) + .filter((token) => isSnsEndpointArn(token)), + ), + ]; + if (endpointArns.length === 0) return; + + const redirect = normalizePushRedirect(payload.type, payload.redirect); + const message = buildSnsApnsMessage(payload, redirect); + const client = getSnsClient(); + const staleEndpoints = new Set(); + + for (const endpointChunk of chunkArray(endpointArns, 10)) { + await Promise.all( + endpointChunk.map(async (endpointArn) => { + try { + await client.send( + new PublishCommand({ + TargetArn: endpointArn, + MessageStructure: "json", + Message: message, + }), + ); + } catch (error) { + if (isStaleEndpointError(error)) { + staleEndpoints.add(endpointArn); + return; + } + console.error("Failed to send push notification:", error); + } + }), + ); + } + + if (staleEndpoints.size > 0) { + const staleList = [...staleEndpoints]; + await Promise.all(staleList.map((arn) => deleteIosPushEndpoint(arn))); + await db + .delete(nativePushTokens) + .where(inArray(nativePushTokens.token, staleList)); + } +} diff --git a/src/lib/push/sns-client.ts b/src/lib/push/sns-client.ts new file mode 100644 index 000000000..5ac800cb7 --- /dev/null +++ b/src/lib/push/sns-client.ts @@ -0,0 +1,13 @@ +import "server-only"; + +import { SNSClient } from "@aws-sdk/client-sns"; +import { getSnsRegion } from "@/lib/push/sns-config"; + +let client: SNSClient | null = null; + +export function getSnsClient(): SNSClient { + if (!client) { + client = new SNSClient({ region: getSnsRegion() }); + } + return client; +} diff --git a/src/lib/push/sns-config.ts b/src/lib/push/sns-config.ts new file mode 100644 index 000000000..4415a0dda --- /dev/null +++ b/src/lib/push/sns-config.ts @@ -0,0 +1,31 @@ +import "server-only"; + +const DEFAULT_SNS_REGION = "us-west-1"; + +export function getSnsRegion(): string { + return ( + process.env.SNS_REGION?.trim() || + process.env.AWS_REGION?.trim() || + DEFAULT_SNS_REGION + ); +} + +/** ARN of the SNS platform application for Apple Push (APNs). */ +export function getSnsIosPlatformApplicationArn(): string | null { + const arn = process.env.SNS_IOS_PLATFORM_APPLICATION_ARN?.trim(); + return arn || null; +} + +/** `sandbox` for dev/TestFlight debug builds; `production` for App Store. */ +export function getSnsIosApnsEnv(): "sandbox" | "production" { + return process.env.SNS_IOS_APNS_ENV === "sandbox" ? "sandbox" : "production"; +} + +export function isSnsEndpointArn(value: string): boolean { + return value.startsWith("arn:aws:sns:"); +} + +/** APNs device token from `UIApplication` (hex-encoded bytes). */ +export function isApnsDeviceToken(value: string): boolean { + return /^[0-9a-f]{32,}$/i.test(value); +} diff --git a/src/lib/push/sns-register.ts b/src/lib/push/sns-register.ts new file mode 100644 index 000000000..f571a651f --- /dev/null +++ b/src/lib/push/sns-register.ts @@ -0,0 +1,82 @@ +import "server-only"; + +import { + CreatePlatformEndpointCommand, + DeleteEndpointCommand, + SetEndpointAttributesCommand, +} from "@aws-sdk/client-sns"; +import { getSnsClient } from "@/lib/push/sns-client"; +import { + getSnsIosPlatformApplicationArn, + isSnsEndpointArn, +} from "@/lib/push/sns-config"; + +let warnedMissingPlatformArn = false; + +export async function registerIosPushEndpoint( + apnsDeviceToken: string, + userId: string, +): Promise { + const platformApplicationArn = getSnsIosPlatformApplicationArn(); + if (!platformApplicationArn) { + if (!warnedMissingPlatformArn) { + console.warn( + "Push notifications are disabled: missing SNS_IOS_PLATFORM_APPLICATION_ARN.", + ); + warnedMissingPlatformArn = true; + } + return null; + } + + const client = getSnsClient(); + + let endpointArn: string | undefined; + try { + const created = await client.send( + new CreatePlatformEndpointCommand({ + PlatformApplicationArn: platformApplicationArn, + Token: apnsDeviceToken, + CustomUserData: userId, + }), + ); + endpointArn = created.EndpointArn; + } catch (error) { + console.error("Failed to create SNS platform endpoint:", error); + return null; + } + + if (!endpointArn) return null; + + try { + await client.send( + new SetEndpointAttributesCommand({ + EndpointArn: endpointArn, + Attributes: { + Token: apnsDeviceToken, + Enabled: "true", + CustomUserData: userId, + }, + }), + ); + } catch (error) { + console.error("Failed to update SNS endpoint attributes:", error); + } + + return endpointArn; +} + +export async function deleteIosPushEndpoint( + endpointArn: string, +): Promise { + if (!isSnsEndpointArn(endpointArn)) return; + + try { + await getSnsClient().send( + new DeleteEndpointCommand({ + EndpointArn: endpointArn, + }), + ); + } catch (error) { + console.error("Failed to delete SNS endpoint:", error); + } +} diff --git a/src/server/data/meeting/send-meeting-invites.ts b/src/server/data/meeting/send-meeting-invites.ts index bb9f3a0db..c02d9dfae 100644 --- a/src/server/data/meeting/send-meeting-invites.ts +++ b/src/server/data/meeting/send-meeting-invites.ts @@ -58,7 +58,8 @@ export async function sendMeetingInvitesToUsers(params: { const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; const origin = baseUrl.replace(/\/$/, ""); - const meetingLink = `${origin}/availability/${meetingId}`; + const meetingPath = `/availability/${meetingId}`; + const meetingLink = `${origin}${meetingPath}`; const inviterName = inviter.displayName?.trim() || "Someone"; await createNewNotification( @@ -66,7 +67,7 @@ export async function sendMeetingInvitesToUsers(params: { meetingTitle, `You've been invited to join "${meetingTitle}". Click to view the meeting.`, NOTIFICATION_TYPES.MEETING_INVITE, - meetingLink, + meetingPath, null, inviter.id, { diff --git a/src/server/data/user/queries.ts b/src/server/data/user/queries.ts index 6a4bc6071..8a3eeb58d 100644 --- a/src/server/data/user/queries.ts +++ b/src/server/data/user/queries.ts @@ -15,6 +15,7 @@ import { type NotificationPrefs, toNotificationPrefs, } from "@/lib/notification/types"; +import { sendPushToUsers } from "@/lib/push/send-push"; import { toIlikeContainsPattern } from "@/lib/sql/like-pattern"; export async function getUserIdExists(id: string) { @@ -209,6 +210,7 @@ export async function createNewNotification( const recipientRows = await db .select({ + userId: users.id, memberId: users.memberId, email: users.email, }) @@ -274,6 +276,22 @@ export async function createNewNotification( } } + try { + await sendPushToUsers( + allowedRecipients.map((recipient) => recipient.userId), + { + title, + message, + type, + redirect: link, + groupId, + createdBy, + }, + ); + } catch (error) { + console.error("Failed to send push notification:", error); + } + return notificationsCreated; } diff --git a/sst.config.ts b/sst.config.ts index 06b2eda00..ceb20c63f 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -27,12 +27,28 @@ export default $config({ provider: sesProvider, }), ], + permissions: [ + { + actions: [ + "sns:Publish", + "sns:CreatePlatformEndpoint", + "sns:SetEndpointAttributes", + "sns:GetEndpointAttributes", + "sns:DeleteEndpoint", + ], + resources: ["*"], + }, + ], environment: { DATABASE_URL: process.env.DATABASE_URL ?? "localhost:3000", OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID!, OIDC_ISSUER_URL: process.env.OIDC_ISSUER_URL!, GOOGLE_OAUTH_REDIRECT_URI: `${baseUrl}/auth/login/google/callback`, NEXT_PUBLIC_BASE_URL: baseUrl, + SNS_REGION: "us-west-1", + SNS_IOS_PLATFORM_APPLICATION_ARN: + process.env.SNS_IOS_PLATFORM_APPLICATION_ARN ?? "", + SNS_IOS_APNS_ENV: process.env.SNS_IOS_APNS_ENV ?? "production", }, cachePolicy: "e6e88864-aee5-41aa-b393-c48f78e33d2d", domain: {