diff --git a/infrastructure/eid-wallet/package.json b/infrastructure/eid-wallet/package.json index 1d6b402a..fcdf04d0 100644 --- a/infrastructure/eid-wallet/package.json +++ b/infrastructure/eid-wallet/package.json @@ -21,6 +21,7 @@ "dependencies": { "@hugeicons/core-free-icons": "^1.0.13", "@hugeicons/svelte": "^1.0.2", + "@ngneat/falso": "^7.3.0", "@tailwindcss/container-queries": "^0.1.1", "@tauri-apps/api": "^2", "@tauri-apps/plugin-barcode-scanner": "^2.2.0", diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/.idea/.gitignore b/infrastructure/eid-wallet/src-tauri/gen/android/.idea/.gitignore deleted file mode 100644 index 26d33521..00000000 --- a/infrastructure/eid-wallet/src-tauri/gen/android/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/.idea/AndroidProjectSystem.xml b/infrastructure/eid-wallet/src-tauri/gen/android/.idea/AndroidProjectSystem.xml deleted file mode 100644 index 4a53bee8..00000000 --- a/infrastructure/eid-wallet/src-tauri/gen/android/.idea/AndroidProjectSystem.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/.idea/appInsightsSettings.xml b/infrastructure/eid-wallet/src-tauri/gen/android/.idea/appInsightsSettings.xml deleted file mode 100644 index 6bbe2aee..00000000 --- a/infrastructure/eid-wallet/src-tauri/gen/android/.idea/appInsightsSettings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/.idea/deploymentTargetSelector.xml b/infrastructure/eid-wallet/src-tauri/gen/android/.idea/deploymentTargetSelector.xml index a24fc0d8..b268ef36 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/android/.idea/deploymentTargetSelector.xml +++ b/infrastructure/eid-wallet/src-tauri/gen/android/.idea/deploymentTargetSelector.xml @@ -4,14 +4,6 @@ - - - - - - - - diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/.idea/gradle.xml b/infrastructure/eid-wallet/src-tauri/gen/android/.idea/gradle.xml index 1b7f23c6..ea507115 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/android/.idea/gradle.xml +++ b/infrastructure/eid-wallet/src-tauri/gen/android/.idea/gradle.xml @@ -1,6 +1,5 @@ - @@ -20,10 +19,10 @@ - - - - + + + + diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/.idea/misc.xml b/infrastructure/eid-wallet/src-tauri/gen/android/.idea/misc.xml index b2c751a3..74dd639e 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/android/.idea/misc.xml +++ b/infrastructure/eid-wallet/src-tauri/gen/android/.idea/misc.xml @@ -1,3 +1,4 @@ + diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/.gitignore b/infrastructure/eid-wallet/src-tauri/gen/android/app/.gitignore index e3e4a05e..26e38ec7 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/android/app/.gitignore +++ b/infrastructure/eid-wallet/src-tauri/gen/android/app/.gitignore @@ -1,4 +1,4 @@ -/src/main/java/com/eid_wallet/app/generated +/src/main/java/io/tanglelabs/metastate/eid_wallet/generated /src/main/jniLibs/**/*.so /src/main/assets/tauri.conf.json /tauri.build.gradle.kts diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/build.gradle.kts b/infrastructure/eid-wallet/src-tauri/gen/android/app/build.gradle.kts index 0b72c31a..84245a45 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/android/app/build.gradle.kts +++ b/infrastructure/eid-wallet/src-tauri/gen/android/app/build.gradle.kts @@ -15,14 +15,14 @@ val tauriProperties = Properties().apply { android { compileSdk = 34 - namespace = "com.eid_wallet.app" + namespace = "io.tanglelabs.metastate.eid_wallet" defaultConfig { manifestPlaceholders["usesCleartextTraffic"] = "false" - applicationId = "com.eid_wallet.app" + applicationId = "io.tanglelabs.metastate.eid_wallet" minSdk = 24 targetSdk = 34 versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() - versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") + versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") } buildTypes { getByName("debug") { diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/MainActivity.kt b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/MainActivity.kt deleted file mode 100644 index f4ab22c6..00000000 --- a/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/MainActivity.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.eid_wallet.app - -class MainActivity : TauriActivity() \ No newline at end of file diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/Ipc.kt b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/Ipc.kt new file mode 100644 index 00000000..8e030f8e --- /dev/null +++ b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/Ipc.kt @@ -0,0 +1,33 @@ +/* THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!! */ + +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +@file:Suppress("unused") + +package com.eid_wallet.app + +import android.webkit.* + +class Ipc(val webViewClient: RustWebViewClient) { + @JavascriptInterface + fun postMessage(message: String?) { + message?.let {m -> + // we're not using WebView::getUrl() here because it needs to be executed on the main thread + // and it would slow down the Ipc + // so instead we track the current URL on the webview client + this.ipc(webViewClient.currentUrl, m) + } + } + + companion object { + init { + System.loadLibrary("eid_wallet_lib") + } + } + + private external fun ipc(url: String, message: String) + + +} diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/Logger.kt b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/Logger.kt new file mode 100644 index 00000000..0494c0e0 --- /dev/null +++ b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/Logger.kt @@ -0,0 +1,89 @@ +/* THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!! */ + +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +@file:Suppress("unused", "MemberVisibilityCanBePrivate") + +package com.eid_wallet.app + +// taken from https://github.com/ionic-team/capacitor/blob/6658bca41e78239347e458175b14ca8bd5c1d6e8/android/capacitor/src/main/java/com/getcapacitor/Logger.java + +import android.text.TextUtils +import android.util.Log + +class Logger { + companion object { + private const val LOG_TAG_CORE = "Tauri" + + fun tags(vararg subtags: String): String { + return if (subtags.isNotEmpty()) { + LOG_TAG_CORE + "/" + TextUtils.join("/", subtags) + } else LOG_TAG_CORE + } + + fun verbose(message: String) { + verbose(LOG_TAG_CORE, message) + } + + private fun verbose(tag: String, message: String) { + if (!shouldLog()) { + return + } + Log.v(tag, message) + } + + fun debug(message: String) { + debug(LOG_TAG_CORE, message) + } + + fun debug(tag: String, message: String) { + if (!shouldLog()) { + return + } + Log.d(tag, message) + } + + fun info(message: String) { + info(LOG_TAG_CORE, message) + } + + fun info(tag: String, message: String) { + if (!shouldLog()) { + return + } + Log.i(tag, message) + } + + fun warn(message: String) { + warn(LOG_TAG_CORE, message) + } + + fun warn(tag: String, message: String) { + if (!shouldLog()) { + return + } + Log.w(tag, message) + } + + fun error(message: String) { + error(LOG_TAG_CORE, message, null) + } + + fun error(message: String, e: Throwable?) { + error(LOG_TAG_CORE, message, e) + } + + fun error(tag: String, message: String, e: Throwable?) { + if (!shouldLog()) { + return + } + Log.e(tag, message, e) + } + + private fun shouldLog(): Boolean { + return BuildConfig.DEBUG + } + } +} diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/PermissionHelper.kt b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/PermissionHelper.kt new file mode 100644 index 00000000..824b2b25 --- /dev/null +++ b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/PermissionHelper.kt @@ -0,0 +1,117 @@ +/* THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!! */ + +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package com.eid_wallet.app + +// taken from https://github.com/ionic-team/capacitor/blob/6658bca41e78239347e458175b14ca8bd5c1d6e8/android/capacitor/src/main/java/com/getcapacitor/PermissionHelper.java + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.ActivityCompat +import java.util.ArrayList + +object PermissionHelper { + /** + * Checks if a list of given permissions are all granted by the user + * + * @param permissions Permissions to check. + * @return True if all permissions are granted, false if at least one is not. + */ + fun hasPermissions(context: Context?, permissions: Array): Boolean { + for (perm in permissions) { + if (ActivityCompat.checkSelfPermission( + context!!, + perm + ) != PackageManager.PERMISSION_GRANTED + ) { + return false + } + } + return true + } + + /** + * Check whether the given permission has been defined in the AndroidManifest.xml + * + * @param permission A permission to check. + * @return True if the permission has been defined in the Manifest, false if not. + */ + fun hasDefinedPermission(context: Context, permission: String): Boolean { + var hasPermission = false + val requestedPermissions = getManifestPermissions(context) + if (!requestedPermissions.isNullOrEmpty()) { + val requestedPermissionsList = listOf(*requestedPermissions) + val requestedPermissionsArrayList = ArrayList(requestedPermissionsList) + if (requestedPermissionsArrayList.contains(permission)) { + hasPermission = true + } + } + return hasPermission + } + + /** + * Check whether all of the given permissions have been defined in the AndroidManifest.xml + * @param context the app context + * @param permissions a list of permissions + * @return true only if all permissions are defined in the AndroidManifest.xml + */ + fun hasDefinedPermissions(context: Context, permissions: Array): Boolean { + for (permission in permissions) { + if (!hasDefinedPermission(context, permission)) { + return false + } + } + return true + } + + /** + * Get the permissions defined in AndroidManifest.xml + * + * @return The permissions defined in AndroidManifest.xml + */ + private fun getManifestPermissions(context: Context): Array? { + var requestedPermissions: Array? = null + try { + val pm = context.packageManager + val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getPackageInfo(context.packageName, PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong())) + } else { + @Suppress("DEPRECATION") + pm.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS) + } + if (packageInfo != null) { + requestedPermissions = packageInfo.requestedPermissions + } + } catch (_: Exception) { + } + return requestedPermissions + } + + /** + * Given a list of permissions, return a new list with the ones not present in AndroidManifest.xml + * + * @param neededPermissions The permissions needed. + * @return The permissions not present in AndroidManifest.xml + */ + fun getUndefinedPermissions(context: Context, neededPermissions: Array): Array { + val undefinedPermissions = ArrayList() + val requestedPermissions = getManifestPermissions(context) + if (!requestedPermissions.isNullOrEmpty()) { + val requestedPermissionsList = listOf(*requestedPermissions) + val requestedPermissionsArrayList = ArrayList(requestedPermissionsList) + for (permission in neededPermissions) { + if (!requestedPermissionsArrayList.contains(permission)) { + undefinedPermissions.add(permission) + } + } + var undefinedPermissionArray = arrayOfNulls(undefinedPermissions.size) + undefinedPermissionArray = undefinedPermissions.toArray(undefinedPermissionArray) + return undefinedPermissionArray + } + return neededPermissions + } +} diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/RustWebChromeClient.kt b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/RustWebChromeClient.kt new file mode 100644 index 00000000..ab675a11 --- /dev/null +++ b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/RustWebChromeClient.kt @@ -0,0 +1,495 @@ +/* THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!! */ + +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +@file:Suppress("ObsoleteSdkInt", "RedundantOverride", "QueryPermissionsNeeded", "SimpleDateFormat") + +package com.eid_wallet.app + +// taken from https://github.com/ionic-team/capacitor/blob/6658bca41e78239347e458175b14ca8bd5c1d6e8/android/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.java + +import android.Manifest +import android.app.Activity +import android.app.AlertDialog +import android.content.ActivityNotFoundException +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.view.View +import android.webkit.* +import android.widget.EditText +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.FileProvider +import java.io.File +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.* + +class RustWebChromeClient(appActivity: WryActivity) : WebChromeClient() { + private interface PermissionListener { + fun onPermissionSelect(isGranted: Boolean?) + } + + private interface ActivityResultListener { + fun onActivityResult(result: ActivityResult?) + } + + private val activity: WryActivity + private var permissionLauncher: ActivityResultLauncher> + private var activityLauncher: ActivityResultLauncher + private var permissionListener: PermissionListener? = null + private var activityListener: ActivityResultListener? = null + + init { + activity = appActivity + val permissionCallback = + ActivityResultCallback { isGranted: Map -> + if (permissionListener != null) { + var granted = true + for ((_, value) in isGranted) { + if (!value) granted = false + } + permissionListener!!.onPermissionSelect(granted) + } + } + permissionLauncher = + activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions(), permissionCallback) + activityLauncher = activity.registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (activityListener != null) { + activityListener!!.onActivityResult(result) + } + } + } + + /** + * Render web content in `view`. + * + * Both this method and [.onHideCustomView] are required for + * rendering web content in full screen. + * + * @see [](https://developer.android.com/reference/android/webkit/WebChromeClient.onShowCustomView + ) */ + override fun onShowCustomView(view: View, callback: CustomViewCallback) { + callback.onCustomViewHidden() + super.onShowCustomView(view, callback) + } + + /** + * Render web content in the original Web View again. + * + * Do not remove this method--@see #onShowCustomView(View, CustomViewCallback). + */ + override fun onHideCustomView() { + super.onHideCustomView() + } + + override fun onPermissionRequest(request: PermissionRequest) { + val isRequestPermissionRequired = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + val permissionList: MutableList = ArrayList() + if (listOf(*request.resources).contains("android.webkit.resource.VIDEO_CAPTURE")) { + permissionList.add(Manifest.permission.CAMERA) + } + if (listOf(*request.resources).contains("android.webkit.resource.AUDIO_CAPTURE")) { + permissionList.add(Manifest.permission.MODIFY_AUDIO_SETTINGS) + permissionList.add(Manifest.permission.RECORD_AUDIO) + } + if (permissionList.isNotEmpty() && isRequestPermissionRequired) { + val permissions = permissionList.toTypedArray() + permissionListener = object : PermissionListener { + override fun onPermissionSelect(isGranted: Boolean?) { + if (isGranted == true) { + request.grant(request.resources) + } else { + request.deny() + } + } + } + permissionLauncher.launch(permissions) + } else { + request.grant(request.resources) + } + } + + /** + * Show the browser alert modal + * @param view + * @param url + * @param message + * @param result + * @return + */ + override fun onJsAlert(view: WebView, url: String, message: String, result: JsResult): Boolean { + if (activity.isFinishing) { + return true + } + val builder = AlertDialog.Builder(view.context) + builder + .setMessage(message) + .setPositiveButton( + "OK" + ) { dialog: DialogInterface, _: Int -> + dialog.dismiss() + result.confirm() + } + .setOnCancelListener { dialog: DialogInterface -> + dialog.dismiss() + result.cancel() + } + val dialog = builder.create() + dialog.show() + return true + } + + /** + * Show the browser confirm modal + * @param view + * @param url + * @param message + * @param result + * @return + */ + override fun onJsConfirm(view: WebView, url: String, message: String, result: JsResult): Boolean { + if (activity.isFinishing) { + return true + } + val builder = AlertDialog.Builder(view.context) + builder + .setMessage(message) + .setPositiveButton( + "OK" + ) { dialog: DialogInterface, _: Int -> + dialog.dismiss() + result.confirm() + } + .setNegativeButton( + "Cancel" + ) { dialog: DialogInterface, _: Int -> + dialog.dismiss() + result.cancel() + } + .setOnCancelListener { dialog: DialogInterface -> + dialog.dismiss() + result.cancel() + } + val dialog = builder.create() + dialog.show() + return true + } + + /** + * Show the browser prompt modal + * @param view + * @param url + * @param message + * @param defaultValue + * @param result + * @return + */ + override fun onJsPrompt( + view: WebView, + url: String, + message: String, + defaultValue: String, + result: JsPromptResult + ): Boolean { + if (activity.isFinishing) { + return true + } + val builder = AlertDialog.Builder(view.context) + val input = EditText(view.context) + builder + .setMessage(message) + .setView(input) + .setPositiveButton( + "OK" + ) { dialog: DialogInterface, _: Int -> + dialog.dismiss() + val inputText1 = input.text.toString().trim { it <= ' ' } + result.confirm(inputText1) + } + .setNegativeButton( + "Cancel" + ) { dialog: DialogInterface, _: Int -> + dialog.dismiss() + result.cancel() + } + .setOnCancelListener { dialog: DialogInterface -> + dialog.dismiss() + result.cancel() + } + val dialog = builder.create() + dialog.show() + return true + } + + /** + * Handle the browser geolocation permission prompt + * @param origin + * @param callback + */ + override fun onGeolocationPermissionsShowPrompt( + origin: String, + callback: GeolocationPermissions.Callback + ) { + super.onGeolocationPermissionsShowPrompt(origin, callback) + Logger.debug("onGeolocationPermissionsShowPrompt: DOING IT HERE FOR ORIGIN: $origin") + val geoPermissions = + arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION) + if (!PermissionHelper.hasPermissions(activity, geoPermissions)) { + permissionListener = object : PermissionListener { + override fun onPermissionSelect(isGranted: Boolean?) { + if (isGranted == true) { + callback.invoke(origin, true, false) + } else { + val coarsePermission = + arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + PermissionHelper.hasPermissions(activity, coarsePermission) + ) { + callback.invoke(origin, true, false) + } else { + callback.invoke(origin, false, false) + } + } + } + } + permissionLauncher.launch(geoPermissions) + } else { + // permission is already granted + callback.invoke(origin, true, false) + Logger.debug("onGeolocationPermissionsShowPrompt: has required permission") + } + } + + override fun onShowFileChooser( + webView: WebView, + filePathCallback: ValueCallback?>, + fileChooserParams: FileChooserParams + ): Boolean { + val acceptTypes = listOf(*fileChooserParams.acceptTypes) + val captureEnabled = fileChooserParams.isCaptureEnabled + val capturePhoto = captureEnabled && acceptTypes.contains("image/*") + val captureVideo = captureEnabled && acceptTypes.contains("video/*") + if (capturePhoto || captureVideo) { + if (isMediaCaptureSupported) { + showMediaCaptureOrFilePicker(filePathCallback, fileChooserParams, captureVideo) + } else { + permissionListener = object : PermissionListener { + override fun onPermissionSelect(isGranted: Boolean?) { + if (isGranted == true) { + showMediaCaptureOrFilePicker(filePathCallback, fileChooserParams, captureVideo) + } else { + Logger.warn(Logger.tags("FileChooser"), "Camera permission not granted") + filePathCallback.onReceiveValue(null) + } + } + } + val camPermission = arrayOf(Manifest.permission.CAMERA) + permissionLauncher.launch(camPermission) + } + } else { + showFilePicker(filePathCallback, fileChooserParams) + } + return true + } + + private val isMediaCaptureSupported: Boolean + get() { + val permissions = arrayOf(Manifest.permission.CAMERA) + return PermissionHelper.hasPermissions(activity, permissions) || + !PermissionHelper.hasDefinedPermission(activity, Manifest.permission.CAMERA) + } + + private fun showMediaCaptureOrFilePicker( + filePathCallback: ValueCallback?>, + fileChooserParams: FileChooserParams, + isVideo: Boolean + ) { + val isVideoCaptureSupported = true + val shown = if (isVideo && isVideoCaptureSupported) { + showVideoCapturePicker(filePathCallback) + } else { + showImageCapturePicker(filePathCallback) + } + if (!shown) { + Logger.warn( + Logger.tags("FileChooser"), + "Media capture intent could not be launched. Falling back to default file picker." + ) + showFilePicker(filePathCallback, fileChooserParams) + } + } + + private fun showImageCapturePicker(filePathCallback: ValueCallback?>): Boolean { + val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + if (takePictureIntent.resolveActivity(activity.packageManager) == null) { + return false + } + val imageFileUri: Uri = try { + createImageFileUri() + } catch (ex: Exception) { + Logger.error("Unable to create temporary media capture file: " + ex.message) + return false + } + takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, imageFileUri) + activityListener = object : ActivityResultListener { + override fun onActivityResult(result: ActivityResult?) { + var res: Array? = null + if (result?.resultCode == Activity.RESULT_OK) { + res = arrayOf(imageFileUri) + } + filePathCallback.onReceiveValue(res) + } + } + activityLauncher.launch(takePictureIntent) + return true + } + + private fun showVideoCapturePicker(filePathCallback: ValueCallback?>): Boolean { + val takeVideoIntent = Intent(MediaStore.ACTION_VIDEO_CAPTURE) + if (takeVideoIntent.resolveActivity(activity.packageManager) == null) { + return false + } + activityListener = object : ActivityResultListener { + override fun onActivityResult(result: ActivityResult?) { + var res: Array? = null + if (result?.resultCode == Activity.RESULT_OK) { + res = arrayOf(result.data!!.data) + } + filePathCallback.onReceiveValue(res) + } + } + activityLauncher.launch(takeVideoIntent) + return true + } + + private fun showFilePicker( + filePathCallback: ValueCallback?>, + fileChooserParams: FileChooserParams + ) { + val intent = fileChooserParams.createIntent() + if (fileChooserParams.mode == FileChooserParams.MODE_OPEN_MULTIPLE) { + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + } + if (fileChooserParams.acceptTypes.size > 1 || intent.type!!.startsWith(".")) { + val validTypes = getValidTypes(fileChooserParams.acceptTypes) + intent.putExtra(Intent.EXTRA_MIME_TYPES, validTypes) + if (intent.type!!.startsWith(".")) { + intent.type = validTypes[0] + } + } + try { + activityListener = object : ActivityResultListener { + override fun onActivityResult(result: ActivityResult?) { + val res: Array? + val resultIntent = result?.data + if (result?.resultCode == Activity.RESULT_OK && resultIntent!!.clipData != null) { + val numFiles = resultIntent.clipData!!.itemCount + res = arrayOfNulls(numFiles) + for (i in 0 until numFiles) { + res[i] = resultIntent.clipData!!.getItemAt(i).uri + } + } else { + res = FileChooserParams.parseResult( + result?.resultCode ?: 0, + resultIntent + ) + } + filePathCallback.onReceiveValue(res) + } + } + activityLauncher.launch(intent) + } catch (e: ActivityNotFoundException) { + filePathCallback.onReceiveValue(null) + } + } + + private fun getValidTypes(currentTypes: Array): Array { + val validTypes: MutableList = ArrayList() + val mtm = MimeTypeMap.getSingleton() + for (mime in currentTypes) { + if (mime.startsWith(".")) { + val extension = mime.substring(1) + val extensionMime = mtm.getMimeTypeFromExtension(extension) + if (extensionMime != null && !validTypes.contains(extensionMime)) { + validTypes.add(extensionMime) + } + } else if (!validTypes.contains(mime)) { + validTypes.add(mime) + } + } + val validObj: Array = validTypes.toTypedArray() + return Arrays.copyOf( + validObj, validObj.size, + Array::class.java + ) + } + + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + val tag: String = Logger.tags("Console") + if (consoleMessage.message() != null && isValidMsg(consoleMessage.message())) { + val msg = String.format( + "File: %s - Line %d - Msg: %s", + consoleMessage.sourceId(), + consoleMessage.lineNumber(), + consoleMessage.message() + ) + val level = consoleMessage.messageLevel().name + if ("ERROR".equals(level, ignoreCase = true)) { + Logger.error(tag, msg, null) + } else if ("WARNING".equals(level, ignoreCase = true)) { + Logger.warn(tag, msg) + } else if ("TIP".equals(level, ignoreCase = true)) { + Logger.debug(tag, msg) + } else { + Logger.info(tag, msg) + } + } + return true + } + + private fun isValidMsg(msg: String): Boolean { + return !(msg.contains("%cresult %c") || + msg.contains("%cnative %c") || + msg.equals("[object Object]", ignoreCase = true) || + msg.equals("console.groupEnd", ignoreCase = true)) + } + + @Throws(IOException::class) + private fun createImageFileUri(): Uri { + val photoFile = createImageFile(activity) + return FileProvider.getUriForFile( + activity, + activity.packageName.toString() + ".fileprovider", + photoFile + ) + } + + @Throws(IOException::class) + private fun createImageFile(activity: Activity): File { + // Create an image file name + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss").format(Date()) + val imageFileName = "JPEG_" + timeStamp + "_" + val storageDir = activity.getExternalFilesDir(Environment.DIRECTORY_PICTURES) + return File.createTempFile(imageFileName, ".jpg", storageDir) + } + + override fun onReceivedTitle( + view: WebView, + title: String + ) { + handleReceivedTitle(view, title) + } + + private external fun handleReceivedTitle(webview: WebView, title: String) +} diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/RustWebView.kt b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/RustWebView.kt new file mode 100644 index 00000000..d51e077a --- /dev/null +++ b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/RustWebView.kt @@ -0,0 +1,101 @@ +/* THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!! */ + +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +@file:Suppress("unused", "SetJavaScriptEnabled") + +package com.eid_wallet.app + +import android.annotation.SuppressLint +import android.webkit.* +import android.content.Context +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import kotlin.collections.Map + +@SuppressLint("RestrictedApi") +class RustWebView(context: Context, val initScripts: Array, val id: String): WebView(context) { + val isDocumentStartScriptEnabled: Boolean + + init { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.setGeolocationEnabled(true) + settings.databaseEnabled = true + settings.mediaPlaybackRequiresUserGesture = false + settings.javaScriptCanOpenWindowsAutomatically = true + + if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) { + isDocumentStartScriptEnabled = true + for (script in initScripts) { + WebViewCompat.addDocumentStartJavaScript(this, script, setOf("*")); + } + } else { + isDocumentStartScriptEnabled = false + } + + + } + + fun loadUrlMainThread(url: String) { + post { + loadUrl(url) + } + } + + fun loadUrlMainThread(url: String, additionalHttpHeaders: Map) { + post { + loadUrl(url, additionalHttpHeaders) + } + } + + override fun loadUrl(url: String) { + if (!shouldOverride(url)) { + super.loadUrl(url); + } + } + + override fun loadUrl(url: String, additionalHttpHeaders: Map) { + if (!shouldOverride(url)) { + super.loadUrl(url, additionalHttpHeaders); + } + } + + fun loadHTMLMainThread(html: String) { + post { + super.loadData(html, "text/html", null) + } + } + + fun evalScript(id: Int, script: String) { + post { + super.evaluateJavascript(script) { result -> + onEval(id, result) + } + } + } + + fun clearAllBrowsingData() { + try { + super.getContext().deleteDatabase("webviewCache.db") + super.getContext().deleteDatabase("webview.db") + super.clearCache(true) + super.clearHistory() + super.clearFormData() + } catch (ex: Exception) { + Logger.error("Unable to create temporary media capture file: " + ex.message) + } + } + + fun getCookies(url: String): String { + val cookieManager = CookieManager.getInstance() + return cookieManager.getCookie(url) + } + + private external fun shouldOverride(url: String): Boolean + private external fun onEval(id: Int, result: String) + + +} diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/RustWebViewClient.kt b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/RustWebViewClient.kt new file mode 100644 index 00000000..5d262951 --- /dev/null +++ b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/RustWebViewClient.kt @@ -0,0 +1,107 @@ +/* THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!! */ + +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package com.eid_wallet.app + +import android.net.Uri +import android.webkit.* +import android.content.Context +import android.graphics.Bitmap +import android.os.Handler +import android.os.Looper +import androidx.webkit.WebViewAssetLoader + +class RustWebViewClient(context: Context): WebViewClient() { + private val interceptedState = mutableMapOf() + var currentUrl: String = "about:blank" + private var lastInterceptedUrl: Uri? = null + private var pendingUrlRedirect: String? = null + + private val assetLoader = WebViewAssetLoader.Builder() + .setDomain(assetLoaderDomain()) + .addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(context)) + .build() + + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest + ): WebResourceResponse? { + pendingUrlRedirect?.let { + Handler(Looper.getMainLooper()).post { + view.loadUrl(it) + } + pendingUrlRedirect = null + return null + } + + lastInterceptedUrl = request.url + return if (withAssetLoader()) { + assetLoader.shouldInterceptRequest(request.url) + } else { + val rustWebview = view as RustWebView; + val response = handleRequest(rustWebview.id, request, rustWebview.isDocumentStartScriptEnabled) + interceptedState[request.url.toString()] = response != null + return response + } + } + + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest + ): Boolean { + return shouldOverride(request.url.toString()) + } + + override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { + currentUrl = url + if (interceptedState[url] == false) { + val webView = view as RustWebView + for (script in webView.initScripts) { + view.evaluateJavascript(script, null) + } + } + return onPageLoading(url) + } + + override fun onPageFinished(view: WebView, url: String) { + onPageLoaded(url) + } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError + ) { + // we get a net::ERR_CONNECTION_REFUSED when an external URL redirects to a custom protocol + // e.g. oauth flow, because shouldInterceptRequest is not called on redirects + // so we must force retry here with loadUrl() to get a chance of the custom protocol to kick in + if (error.errorCode == ERROR_CONNECT && request.isForMainFrame && request.url != lastInterceptedUrl) { + // prevent the default error page from showing + view.stopLoading() + // without this initial loadUrl the app is stuck + view.loadUrl(request.url.toString()) + // ensure the URL is actually loaded - for some reason there's a race condition and we need to call loadUrl() again later + pendingUrlRedirect = request.url.toString() + } else { + super.onReceivedError(view, request, error) + } + } + + companion object { + init { + System.loadLibrary("eid_wallet_lib") + } + } + + private external fun assetLoaderDomain(): String + private external fun withAssetLoader(): Boolean + private external fun handleRequest(webviewId: String, request: WebResourceRequest, isDocumentStartScriptEnabled: Boolean): WebResourceResponse? + private external fun shouldOverride(url: String): Boolean + private external fun onPageLoading(url: String) + private external fun onPageLoaded(url: String) + + +} diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/TauriActivity.kt b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/TauriActivity.kt new file mode 100644 index 00000000..e067eb0c --- /dev/null +++ b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/TauriActivity.kt @@ -0,0 +1,30 @@ +// Copyright 2019-2024 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +/* THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!! */ + +package com.eid_wallet.app + +import android.os.Bundle +import android.content.Intent +import app.tauri.plugin.PluginManager + +abstract class TauriActivity : WryActivity() { + var pluginManager: PluginManager = PluginManager(this) + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + pluginManager.onNewIntent(intent) + } + + override fun onResume() { + super.onResume() + pluginManager.onResume() + } + + override fun onPause() { + super.onPause() + pluginManager.onPause() + } +} diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/WryActivity.kt b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/WryActivity.kt new file mode 100644 index 00000000..027bd951 --- /dev/null +++ b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/WryActivity.kt @@ -0,0 +1,136 @@ +/* THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!! */ + +// Copyright 2020-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +package com.eid_wallet.app + +import com.eid_wallet.app.RustWebView +import android.annotation.SuppressLint +import android.os.Build +import android.os.Bundle +import android.webkit.WebView +import android.view.KeyEvent +import androidx.appcompat.app.AppCompatActivity + +abstract class WryActivity : AppCompatActivity() { + private lateinit var mWebView: RustWebView + + open fun onWebViewCreate(webView: WebView) { } + + fun setWebView(webView: RustWebView) { + mWebView = webView + onWebViewCreate(webView) + } + + val version: String + @SuppressLint("WebViewApiAvailability", "ObsoleteSdkInt") + get() { + // Check getCurrentWebViewPackage() directly if above Android 8 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + return WebView.getCurrentWebViewPackage()?.versionName ?: "" + } + + // Otherwise manually check WebView versions + var webViewPackage = "com.google.android.webview" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + webViewPackage = "com.android.chrome" + } + try { + @Suppress("DEPRECATION") + val info = packageManager.getPackageInfo(webViewPackage, 0) + return info.versionName.toString() + } catch (ex: Exception) { + Logger.warn("Unable to get package info for '$webViewPackage'$ex") + } + + try { + @Suppress("DEPRECATION") + val info = packageManager.getPackageInfo("com.android.webview", 0) + return info.versionName.toString() + } catch (ex: Exception) { + Logger.warn("Unable to get package info for 'com.android.webview'$ex") + } + + // Could not detect any webview, return empty string + return "" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + create(this) + } + + override fun onStart() { + super.onStart() + start() + } + + override fun onResume() { + super.onResume() + resume() + } + + override fun onPause() { + super.onPause() + pause() + } + + override fun onStop() { + super.onStop() + stop() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + focus(hasFocus) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + save() + } + + override fun onDestroy() { + super.onDestroy() + destroy() + onActivityDestroy() + } + + override fun onLowMemory() { + super.onLowMemory() + memory() + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + if (keyCode == KeyEvent.KEYCODE_BACK && mWebView.canGoBack()) { + mWebView.goBack() + return true + } + return super.onKeyDown(keyCode, event) + } + + fun getAppClass(name: String): Class<*> { + return Class.forName(name) + } + + companion object { + init { + System.loadLibrary("eid_wallet_lib") + } + } + + private external fun create(activity: WryActivity) + private external fun start() + private external fun resume() + private external fun pause() + private external fun stop() + private external fun save() + private external fun destroy() + private external fun onActivityDestroy() + private external fun memory() + private external fun focus(focus: Boolean) + + +} diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/proguard-wry.pro b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/proguard-wry.pro new file mode 100644 index 00000000..3c1c28b7 --- /dev/null +++ b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/com/eid_wallet/app/generated/proguard-wry.pro @@ -0,0 +1,35 @@ +# THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!! + +# Copyright 2020-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +-keep class com.eid_wallet.app.* { + native ; +} + +-keep class com.eid_wallet.app.WryActivity { + public (...); + + void setWebView(com.eid_wallet.app.RustWebView); + java.lang.Class getAppClass(...); + java.lang.String getVersion(); +} + +-keep class com.eid_wallet.app.Ipc { + public (...); + + @android.webkit.JavascriptInterface public ; +} + +-keep class com.eid_wallet.app.RustWebView { + public (...); + + void loadUrlMainThread(...); + void loadHTMLMainThread(...); + void evalScript(...); +} + +-keep class com.eid_wallet.app.RustWebChromeClient,com.eid_wallet.app.RustWebViewClient { + public (...); +} diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/io/tanglelabs/metastate/eid_wallet/MainActivity.kt b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/io/tanglelabs/metastate/eid_wallet/MainActivity.kt new file mode 100644 index 00000000..66c258a9 --- /dev/null +++ b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/io/tanglelabs/metastate/eid_wallet/MainActivity.kt @@ -0,0 +1,3 @@ +package io.tanglelabs.metastate.eid_wallet + +class MainActivity : TauriActivity() \ No newline at end of file diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/res/values/strings.xml b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/res/values/strings.xml index 111ad08e..0883f3b1 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/res/values/strings.xml +++ b/infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - eid-wallet - eid-wallet + eID Wallet + eID Wallet \ No newline at end of file diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/buildSrc/src/main/java/com/eid_wallet/app/kotlin/BuildTask.kt b/infrastructure/eid-wallet/src-tauri/gen/android/buildSrc/src/main/java/io/tanglelabs/metastate/eid_wallet/kotlin/BuildTask.kt similarity index 100% rename from infrastructure/eid-wallet/src-tauri/gen/android/buildSrc/src/main/java/com/eid_wallet/app/kotlin/BuildTask.kt rename to infrastructure/eid-wallet/src-tauri/gen/android/buildSrc/src/main/java/io/tanglelabs/metastate/eid_wallet/kotlin/BuildTask.kt diff --git a/infrastructure/eid-wallet/src-tauri/gen/android/buildSrc/src/main/java/com/eid_wallet/app/kotlin/RustPlugin.kt b/infrastructure/eid-wallet/src-tauri/gen/android/buildSrc/src/main/java/io/tanglelabs/metastate/eid_wallet/kotlin/RustPlugin.kt similarity index 100% rename from infrastructure/eid-wallet/src-tauri/gen/android/buildSrc/src/main/java/com/eid_wallet/app/kotlin/RustPlugin.kt rename to infrastructure/eid-wallet/src-tauri/gen/android/buildSrc/src/main/java/io/tanglelabs/metastate/eid_wallet/kotlin/RustPlugin.kt diff --git a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj index 7512db49..e63075f2 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj +++ b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 63; objects = { /* Begin PBXBuildFile section */ @@ -32,7 +32,9 @@ 6694E9EC8CE5328251C3F669 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; 673F0CB787E12AAB4660FE44 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; 6DACB922029A0F360BDBD599 /* eid-wallet_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "eid-wallet_iOS.entitlements"; sourceTree = ""; }; - 754F6FBE80C64C6326B8E0D5 /* eid-wallet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "eid-wallet.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 754F6FBE80C64C6326B8E0D5 /* eID Wallet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "eID Wallet.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 9AEEDA12A7D9D6BF575FC67A /* errors.rs */ = {isa = PBXFileReference; lastKnownFileType = text; path = errors.rs; sourceTree = ""; }; + ADFAC2203FBFFDFF207BC8FE /* mod.rs */ = {isa = PBXFileReference; lastKnownFileType = text; path = mod.rs; sourceTree = ""; }; C636AF063138DEFB3E6A6052 /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; path = assets; sourceTree = SOURCE_ROOT; }; D515D2306077569F964CC0DF /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = ""; }; DC24C9E28ECAF90D40C14313 /* lib.rs */ = {isa = PBXFileReference; lastKnownFileType = text; path = lib.rs; sourceTree = ""; }; @@ -64,11 +66,19 @@ 0D0DAFCCB1F1630FE5DF02CB /* Products */ = { isa = PBXGroup; children = ( - 754F6FBE80C64C6326B8E0D5 /* eid-wallet.app */, + 754F6FBE80C64C6326B8E0D5 /* eID Wallet.app */, ); name = Products; sourceTree = ""; }; + 1D4383173E1210A2E9FDFF04 /* funcs */ = { + isa = PBXGroup; + children = ( + ADFAC2203FBFFDFF207BC8FE /* mod.rs */, + ); + path = funcs; + sourceTree = ""; + }; 1E772D6A351CCE2327B74186 /* eid-wallet_iOS */ = { isa = PBXGroup; children = ( @@ -96,8 +106,10 @@ 61D5698478D645257706C604 /* src */ = { isa = PBXGroup; children = ( + 9AEEDA12A7D9D6BF575FC67A /* errors.rs */, DC24C9E28ECAF90D40C14313 /* lib.rs */, 40ABD3C35AA06E16DA39304E /* main.rs */, + 1D4383173E1210A2E9FDFF04 /* funcs */, ); name = src; path = ../../src; @@ -167,8 +179,10 @@ dependencies = ( ); name = "eid-wallet_iOS"; + packageProductDependencies = ( + ); productName = "eid-wallet_iOS"; - productReference = 754F6FBE80C64C6326B8E0D5 /* eid-wallet.app */; + productReference = 754F6FBE80C64C6326B8E0D5 /* eID Wallet.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -189,6 +203,7 @@ en, ); mainGroup = 234EEFFEA1E82BEEC00B358A; + minimizedProjectReferenceProxies = 1; projectDirPath = ""; projectRoot = ""; targets = ( @@ -227,11 +242,10 @@ outputPaths = ( "$(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a", "$(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a", - "$(SRCROOT)/Externals/arm64-sim/${CONFIGURATION}/libapp.a", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "pnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}"; + shellScript = "[ -s \"$NVM_DIR/nvm.sh\" ] && \\. \"$NVM_DIR/nvm.sh\"\npnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -370,17 +384,14 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - ARCHS = ( - arm64, - "arm64-sim", - ); + ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = "eid-wallet_iOS/eid-wallet_iOS.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = 3FS4B734X5; + CURRENT_PROJECT_VERSION = 0.1.0.0; + DEVELOPMENT_TEAM = "HZ7MLR7Q46"; ENABLE_BITCODE = NO; - "EXCLUDED_ARCHS[sdk=iphoneos*]" = "arm64-sim x86_64"; - "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "\".\"", @@ -390,13 +401,6 @@ "$(inherited)", "@executable_path/Frameworks", ); - "LIBRARY_SEARCH_PATHS[arch=arm64-sim]" = ( - "$(inherited)", - "$(PROJECT_DIR)/Externals/arm64-sim/$(CONFIGURATION)", - "$(SDKROOT)/usr/lib/swift", - "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", - "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", - ); "LIBRARY_SEARCH_PATHS[arch=arm64]" = ( "$(inherited)", "$(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION)", @@ -411,11 +415,12 @@ "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", ); - PRODUCT_BUNDLE_IDENTIFIER = com.eid-wallet.app; - PRODUCT_NAME = "eid-wallet"; + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.tanglelabs.metastate.eid-wallet; + PRODUCT_NAME = "eID Wallet"; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; - VALID_ARCHS = "arm64 arm64-sim"; + VALID_ARCHS = arm64; }; name = release; }; @@ -423,17 +428,14 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - ARCHS = ( - arm64, - "arm64-sim", - ); + ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = "eid-wallet_iOS/eid-wallet_iOS.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = 3FS4B734X5; + CURRENT_PROJECT_VERSION = 0.1.0.0; + DEVELOPMENT_TEAM = "HZ7MLR7Q46"; ENABLE_BITCODE = NO; - "EXCLUDED_ARCHS[sdk=iphoneos*]" = "arm64-sim x86_64"; - "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; + "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "\".\"", @@ -443,13 +445,6 @@ "$(inherited)", "@executable_path/Frameworks", ); - "LIBRARY_SEARCH_PATHS[arch=arm64-sim]" = ( - "$(inherited)", - "$(PROJECT_DIR)/Externals/arm64-sim/$(CONFIGURATION)", - "$(SDKROOT)/usr/lib/swift", - "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", - "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", - ); "LIBRARY_SEARCH_PATHS[arch=arm64]" = ( "$(inherited)", "$(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION)", @@ -464,11 +459,12 @@ "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)", ); - PRODUCT_BUNDLE_IDENTIFIER = com.eid-wallet.app; - PRODUCT_NAME = "eid-wallet"; + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.tanglelabs.metastate.eid-wallet; + PRODUCT_NAME = "eID Wallet"; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; - VALID_ARCHS = "arm64 arm64-sim"; + VALID_ARCHS = arm64; }; name = debug; }; diff --git a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/xcshareddata/xcschemes/eid-wallet_iOS.xcscheme b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/xcshareddata/xcschemes/eid-wallet_iOS.xcscheme index cc03c9c2..2db1e379 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/xcshareddata/xcschemes/eid-wallet_iOS.xcscheme +++ b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/xcshareddata/xcschemes/eid-wallet_iOS.xcscheme @@ -15,7 +15,7 @@ @@ -31,7 +31,7 @@ @@ -66,7 +66,7 @@ @@ -95,7 +95,7 @@ diff --git a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/Info.plist b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/Info.plist index eb0c6606..b91aa9fe 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/Info.plist +++ b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/Info.plist @@ -20,6 +20,10 @@ 0.1.0 LSRequiresIPhoneOS + NSCameraUsageDescription + Read QR codes + NSFaceIDUsageDescription + Authenticate with biometric UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities @@ -40,9 +44,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - NSFaceIDUsageDescription - Authenticate with biometric - NSCameraUsageDescription - Read QR codes \ No newline at end of file diff --git a/infrastructure/eid-wallet/src-tauri/gen/apple/project.yml b/infrastructure/eid-wallet/src-tauri/gen/apple/project.yml index ccbe64bb..a04dda6d 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/apple/project.yml +++ b/infrastructure/eid-wallet/src-tauri/gen/apple/project.yml @@ -1,6 +1,6 @@ name: eid-wallet options: - bundleIdPrefix: com.eid-wallet.app + bundleIdPrefix: io.tanglelabs.metastate.eid-wallet deploymentTarget: iOS: 13.0 fileGroups: [../../src] @@ -10,8 +10,9 @@ configs: settingGroups: app: base: - PRODUCT_NAME: eid-wallet - PRODUCT_BUNDLE_IDENTIFIER: com.eid-wallet.app + PRODUCT_NAME: eID Wallet + PRODUCT_BUNDLE_IDENTIFIER: io.tanglelabs.metastate.eid-wallet + DEVELOPMENT_TEAM: HZ7MLR7Q46 targetTemplates: app: type: application @@ -62,14 +63,12 @@ targets: settings: base: ENABLE_BITCODE: false - ARCHS: [arm64, arm64-sim] - VALID_ARCHS: arm64 arm64-sim + ARCHS: [arm64] + VALID_ARCHS: arm64 LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME) LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME) - LIBRARY_SEARCH_PATHS[arch=arm64-sim]: $(inherited) $(PROJECT_DIR)/Externals/arm64-sim/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME) ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true - EXCLUDED_ARCHS[sdk=iphonesimulator*]: arm64 - EXCLUDED_ARCHS[sdk=iphoneos*]: arm64-sim x86_64 + EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64 groups: [app] dependencies: - framework: libapp.a @@ -87,5 +86,4 @@ targets: basedOnDependencyAnalysis: false outputFiles: - $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a - - $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a - - $(SRCROOT)/Externals/arm64-sim/${CONFIGURATION}/libapp.a \ No newline at end of file + - $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a \ No newline at end of file diff --git a/infrastructure/eid-wallet/src-tauri/tauri.conf.json b/infrastructure/eid-wallet/src-tauri/tauri.conf.json index 3b0ce118..59e9d81e 100644 --- a/infrastructure/eid-wallet/src-tauri/tauri.conf.json +++ b/infrastructure/eid-wallet/src-tauri/tauri.conf.json @@ -1,36 +1,41 @@ { - "$schema": "https://schema.tauri.app/config/2", - "productName": "eid-wallet", - "version": "0.1.0", - "identifier": "com.eid-wallet.app", - "build": { - "beforeDevCommand": "pnpm dev", - "devUrl": "http://localhost:1420", - "beforeBuildCommand": "pnpm build", - "frontendDist": "../build" - }, - "app": { - "windows": [ - { - "title": "eid-wallet", - "width": 800, - "height": 600 - } - ], - "security": { - "capabilities": ["mobile-capability"], - "csp": null + "$schema": "https://schema.tauri.app/config/2", + "productName": "eID Wallet", + "version": "0.1.0", + "identifier": "io.tanglelabs.metastate.eid-wallet", + "build": { + "beforeDevCommand": "pnpm dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "npm run build", + "frontendDist": "../build" + }, + "app": { + "windows": [ + { + "title": "eid-wallet", + "width": 800, + "height": 600 + } + ], + "security": { + "capabilities": [ + "mobile-capability" + ], + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "iOS": { + "developmentTeam": "HZ7MLR7Q46" + } } - }, - "bundle": { - "active": true, - "targets": "all", - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ] - } } diff --git a/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte b/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte index 3d1b5daf..061dce43 100644 --- a/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte @@ -1,49 +1,194 @@ - + - + - Your - Digital Identity, - Secured + Your + Digital Identity, Secured - By continuing you agree to our Terms & Conditions and privacy policy. + + By continuing you agree to our Terms & Conditions + + and + privacy policy. + - Get Started + Get Started + + + Already have a pre-verification code? click here + - - + Welcome to Web 3 Data Spaces - Your eName is more than a name—it's your unique digital passport. One constant identifier that travels with you across the internet, connecting your real-world self to the digital universe. - - Next - - \ No newline at end of file + + Your eName is more than a name—it's your unique digital passport. One + constant identifier that travels with you across the internet, + connecting your real-world self to the digital universe. + + + + + + {#if loading} + + + + Generating your eName + + + {:else if preVerified} + Welcome to Web 3 Data Spaces + Enter Verification Code + + + Next + + {:else} + Welcome to Web 3 Data Spaces + + Your eName is more than a name—it's your unique digital passport. + One constant identifier that travels with you across the internet, + connecting your real-world self to the digital universe. + + + Next + + {/if} + + diff --git a/infrastructure/eid-wallet/src/routes/(auth)/verify/+page.svelte b/infrastructure/eid-wallet/src/routes/(auth)/verify/+page.svelte index 7327f714..c6cd3b11 100644 --- a/infrastructure/eid-wallet/src/routes/(auth)/verify/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(auth)/verify/+page.svelte @@ -1,113 +1,123 @@ { const fetched = await service.findMetaEnvelopeById(id); expect(fetched).toBeDefined(); + if (!fetched) return; expect(fetched.id).toBeDefined(); expect(fetched.ontology).toBe("TestTypes"); expect(fetched.acl).toEqual(["@test-user"]); @@ -167,20 +168,25 @@ describe("DbService (integration)", () => { const result = await service.findMetaEnvelopeById( stored.metaEnvelope.id, ); + if (!result) return; const targetEnvelope = result.envelopes.find( (e: Envelope) => e.ontology === "value", ); // Update with a different type const newValue = new Date("2025-04-10T00:00:00Z"); + if (!targetEnvelope) return; await service.updateEnvelopeValue(targetEnvelope.id, newValue); const updated = await service.findMetaEnvelopeById( stored.metaEnvelope.id, ); + if (!updated) return; const updatedValue = updated.envelopes.find( (e: Envelope) => e.id === targetEnvelope.id, ); + + if (!updatedValue) return; expect(updatedValue.value).toBeInstanceOf(Date); expect(updatedValue.value.toISOString()).toBe( "2025-04-10T00:00:00.000Z", diff --git a/infrastructure/evault-core/src/db/db.service.ts b/infrastructure/evault-core/src/db/db.service.ts index a6efadb9..dd51d71f 100644 --- a/infrastructure/evault-core/src/db/db.service.ts +++ b/infrastructure/evault-core/src/db/db.service.ts @@ -366,6 +366,144 @@ export class DbService { ); } + /** + * Updates a meta-envelope and its associated envelopes. + * @param id - The ID of the meta-envelope to update + * @param meta - The updated meta-envelope data + * @param acl - The updated access control list + * @returns The updated meta-envelope and its envelopes + */ + async updateMetaEnvelopeById< + T extends Record = Record, + >( + id: string, + meta: Omit, "id">, + acl: string[], + ): Promise> { + try { + // First, get the existing meta-envelope to find existing envelopes + const existing = await this.findMetaEnvelopeById(id); + if (!existing) { + throw new Error(`Meta-envelope with id ${id} not found`); + } + + // Update the meta-envelope properties + await this.runQuery( + ` + MATCH (m:MetaEnvelope { id: $id }) + SET m.ontology = $ontology, m.acl = $acl + `, + { id, ontology: meta.ontology, acl } + ); + + const createdEnvelopes: Envelope[] = []; + let counter = 0; + + // For each field in the new payload + for (const [key, value] of Object.entries(meta.payload)) { + try { + const { value: storedValue, type: valueType } = serializeValue(value); + const alias = `e${counter}`; + + // Check if an envelope with this ontology already exists + const existingEnvelope = existing.envelopes.find(e => e.ontology === key); + + if (existingEnvelope) { + // Update existing envelope + await this.runQuery( + ` + MATCH (e:Envelope { id: $envelopeId }) + SET e.value = $newValue, e.valueType = $valueType + `, + { + envelopeId: existingEnvelope.id, + newValue: storedValue, + valueType, + } + ); + + createdEnvelopes.push({ + id: existingEnvelope.id, + ontology: key, + value: value as T[keyof T], + valueType, + }); + } else { + // Create new envelope + const envW3id = await new W3IDBuilder().build(); + const envelopeId = envW3id.id; + + await this.runQuery( + ` + MATCH (m:MetaEnvelope { id: $metaId }) + CREATE (${alias}:Envelope { + id: $${alias}_id, + ontology: $${alias}_ontology, + value: $${alias}_value, + valueType: $${alias}_type + }) + WITH m, ${alias} + MERGE (m)-[:LINKS_TO]->(${alias}) + `, + { + metaId: id, + [`${alias}_id`]: envelopeId, + [`${alias}_ontology`]: key, + [`${alias}_value`]: storedValue, + [`${alias}_type`]: valueType, + } + ); + + createdEnvelopes.push({ + id: envelopeId, + ontology: key, + value: value as T[keyof T], + valueType, + }); + } + + counter++; + } catch (error) { + console.error(`Error processing field ${key}:`, error); + throw error; + } + } + + // Delete envelopes that are no longer in the payload + const existingOntologies = new Set(Object.keys(meta.payload)); + const envelopesToDelete = existing.envelopes.filter( + e => !existingOntologies.has(e.ontology) + ); + + for (const envelope of envelopesToDelete) { + try { + await this.runQuery( + ` + MATCH (e:Envelope { id: $envelopeId }) + DETACH DELETE e + `, + { envelopeId: envelope.id } + ); + } catch (error) { + console.error(`Error deleting envelope ${envelope.id}:`, error); + throw error; + } + } + + return { + metaEnvelope: { + id, + ontology: meta.ontology, + acl, + }, + envelopes: createdEnvelopes, + }; + } catch (error) { + console.error('Error in updateMetaEnvelopeById:', error); + throw error; + } + } + /** * Retrieves all envelopes in the system. * @returns Array of all envelopes diff --git a/infrastructure/evault-core/src/db/retry-neo4j.ts b/infrastructure/evault-core/src/db/retry-neo4j.ts new file mode 100644 index 00000000..d08f5178 --- /dev/null +++ b/infrastructure/evault-core/src/db/retry-neo4j.ts @@ -0,0 +1,40 @@ +import neo4j, { Driver } from "neo4j-driver"; + +/** + * Attempts to connect to Neo4j with retry logic. + * @param uri - Neo4j URI + * @param user - Username + * @param password - Password + * @param maxRetries - Maximum number of retries (default: 10) + * @param delayMs - Delay between retries in ms (default: 3000) + * @returns Connected Neo4j Driver + * @throws Error if connection fails after all retries + */ +export async function connectWithRetry( + uri: string, + user: string, + password: string, + maxRetries = 30, + delayMs = 5000, +): Promise { + let attempt = 0; + while (attempt < maxRetries) { + try { + const driver = neo4j.driver( + uri, + neo4j.auth.basic(user, password), + { encrypted: "ENCRYPTION_OFF" }, // or { encrypted: false } + ); + await driver.getServerInfo(); + console.log("Connected to Neo4j!"); + return driver; + } catch (err: any) { + attempt++; + console.warn( + `Neo4j connection attempt ${attempt} failed: ${err.message}. Retrying in ${delayMs}ms...`, + ); + await new Promise((res) => setTimeout(res, delayMs)); + } + } + throw new Error("Could not connect to Neo4j after multiple attempts"); +} diff --git a/infrastructure/evault-core/src/db/types.ts b/infrastructure/evault-core/src/db/types.ts index 1b79fd52..b76f9808 100644 --- a/infrastructure/evault-core/src/db/types.ts +++ b/infrastructure/evault-core/src/db/types.ts @@ -2,20 +2,20 @@ * Represents a meta-envelope that contains multiple envelopes of data. */ export type MetaEnvelope = Record> = - { - ontology: string; - payload: T; - acl: string[]; - }; + { + ontology: string; + payload: T; + acl: string[]; + }; /** * Represents an individual envelope containing a single piece of data. */ export type Envelope = { - id: string; - value: T; - ontology: string; - valueType: string; + id: string; + value: T; + ontology: string; + valueType: string; }; /** @@ -23,34 +23,34 @@ export type Envelope = { * Includes the parsed payload structure reconstructed from the envelopes. */ export type MetaEnvelopeResult< - T extends Record = Record, + T extends Record = Record > = { - id: string; - ontology: string; - acl: string[]; - envelopes: Envelope[]; - parsed: T; + id: string; + ontology: string; + acl: string[]; + envelopes: Envelope[]; + parsed: T; }; /** * Result type for storing a new meta-envelope. */ export type StoreMetaEnvelopeResult< - T extends Record = Record, + T extends Record = Record > = { - metaEnvelope: { - id: string; - ontology: string; - acl: string[]; - }; - envelopes: Envelope[]; + metaEnvelope: { + id: string; + ontology: string; + acl: string[]; + }; + envelopes: Envelope[]; }; /** * Result type for searching meta-envelopes. */ export type SearchMetaEnvelopesResult< - T extends Record = Record, + T extends Record = Record > = MetaEnvelopeResult[]; /** diff --git a/infrastructure/evault-core/src/evault.ts b/infrastructure/evault-core/src/evault.ts index 57b220ad..d30864df 100644 --- a/infrastructure/evault-core/src/evault.ts +++ b/infrastructure/evault-core/src/evault.ts @@ -13,6 +13,7 @@ import dotenv from "dotenv"; import path from "path"; import neo4j, { Driver } from "neo4j-driver"; import { W3ID } from "./w3id/w3id"; +import { connectWithRetry } from "./db/retry-neo4j"; dotenv.config({ path: path.resolve(__dirname, "../../../.env") }); @@ -22,7 +23,17 @@ class EVault { logService: LogService; driver: Driver; - constructor() { + private constructor(driver: Driver) { + this.driver = driver; + const dbService = new DbService(driver); + this.logService = new LogService(driver); + this.graphqlServer = new GraphQLServer(dbService); + this.server = fastify({ + logger: true, + }); + } + + static async create(): Promise { const uri = process.env.NEO4J_URI || "bolt://localhost:7687"; const user = process.env.NEO4J_USER || "neo4j"; const password = process.env.NEO4J_PASSWORD || "neo4j"; @@ -37,15 +48,8 @@ class EVault { ); } - this.driver = neo4j.driver(uri, neo4j.auth.basic(user, password)); - - const dbService = new DbService(this.driver); - this.logService = new LogService(this.driver); - this.graphqlServer = new GraphQLServer(dbService); - - this.server = fastify({ - logger: true, - }); + const driver = await connectWithRetry(uri, user, password); + return new EVault(driver); } async initialize() { @@ -64,10 +68,7 @@ class EVault { url: yoga.graphqlEndpoint, method: ["GET", "POST", "OPTIONS"], handler: (req, reply) => - yoga.handleNodeRequestAndResponse(req, reply, { - req, - reply, - }), + yoga.handleNodeRequestAndResponse(req, reply), }); // Mount Voyager endpoint @@ -102,5 +103,6 @@ class EVault { } } -const evault = new EVault(); -evault.start().catch(console.error); +EVault.create() + .then(evault => evault.start()) + .catch(console.error); diff --git a/infrastructure/evault-core/src/protocol/examples/examples.ts b/infrastructure/evault-core/src/protocol/examples/examples.ts index 455f16b4..f36eb194 100644 --- a/infrastructure/evault-core/src/protocol/examples/examples.ts +++ b/infrastructure/evault-core/src/protocol/examples/examples.ts @@ -79,7 +79,7 @@ export const exampleQueries = ` # } ################################################################################ -# ✏️ 5. Update a Single Envelope’s Value +# ✏️ 5. Update a Single Envelope's Value ################################################################################ # mutation { @@ -98,7 +98,39 @@ export const exampleQueries = ` # } ################################################################################ -# 📦 7. List All Envelopes in the System +# 🔄 7. Update a MetaEnvelope by ID +################################################################################ + +# mutation { +# updateMetaEnvelopeById( +# id: "YOUR_META_ENVELOPE_ID_HERE", +# input: { +# ontology: "SocialMediaPost", +# payload: { +# text: "Updated post content", +# image: "https://example.com/new-pic.jpg", +# dateCreated: "2025-04-10T10:00:00Z", +# userLikes: ["@user1", "@user2", "@user3"] +# }, +# acl: ["@d1fa5cb1-6178-534b-a096-59794d485f65"] +# } +# ) { +# metaEnvelope { +# id +# ontology +# parsed +# } +# envelopes { +# id +# ontology +# value +# valueType +# } +# } +# } + +################################################################################ +# 📦 8. List All Envelopes in the System ################################################################################ # query { diff --git a/infrastructure/evault-core/src/protocol/graphql-server.ts b/infrastructure/evault-core/src/protocol/graphql-server.ts index bde5adde..029090ac 100644 --- a/infrastructure/evault-core/src/protocol/graphql-server.ts +++ b/infrastructure/evault-core/src/protocol/graphql-server.ts @@ -9,119 +9,162 @@ import { GraphQLSchema } from "graphql"; import { exampleQueries } from "./examples/examples"; export class GraphQLServer { - private db: DbService; - private accessGuard: VaultAccessGuard; - private schema: GraphQLSchema = createSchema({ - typeDefs, - resolvers: {}, - }); - server?: Server; - - constructor(db: DbService) { - this.db = db; - this.accessGuard = new VaultAccessGuard(db); - } + private db: DbService; + private accessGuard: VaultAccessGuard; + private schema: GraphQLSchema = createSchema({ + typeDefs, + resolvers: {}, + }); + server?: Server; + constructor(db: DbService) { + this.db = db; + this.accessGuard = new VaultAccessGuard(db); + } - public getSchema(): GraphQLSchema { - return this.schema; - } + public getSchema(): GraphQLSchema { + return this.schema; + } - init() { - const resolvers = { - JSON: require("graphql-type-json"), + init() { + const resolvers = { + JSON: require("graphql-type-json"), - Query: { - getMetaEnvelopeById: this.accessGuard.middleware( - (_: any, { id }: { id: string }) => { - return this.db.findMetaEnvelopeById(id); - } - ), - findMetaEnvelopesByOntology: this.accessGuard.middleware( - (_: any, { ontology }: { ontology: string }) => { - return this.db.findMetaEnvelopesByOntology(ontology); - } - ), - searchMetaEnvelopes: this.accessGuard.middleware( - (_: any, { ontology, term }: { ontology: string; term: string }) => { - return this.db.findMetaEnvelopesBySearchTerm(ontology, term); - } - ), - getAllEnvelopes: this.accessGuard.middleware(() => { - return this.db.getAllEnvelopes(); - }), - }, + Query: { + getMetaEnvelopeById: this.accessGuard.middleware( + (_: any, { id }: { id: string }) => { + return this.db.findMetaEnvelopeById(id); + } + ), + findMetaEnvelopesByOntology: this.accessGuard.middleware( + (_: any, { ontology }: { ontology: string }) => { + return this.db.findMetaEnvelopesByOntology(ontology); + } + ), + searchMetaEnvelopes: this.accessGuard.middleware( + ( + _: any, + { ontology, term }: { ontology: string; term: string } + ) => { + return this.db.findMetaEnvelopesBySearchTerm( + ontology, + term + ); + } + ), + getAllEnvelopes: this.accessGuard.middleware(() => { + return this.db.getAllEnvelopes(); + }), + }, - Mutation: { - storeMetaEnvelope: this.accessGuard.middleware( - async ( - _: any, - { - input, - }: { - input: { - ontology: string; - payload: any; - acl: string[]; - }; - } - ) => { - const result = await this.db.storeMetaEnvelope( - { - ontology: input.ontology, - payload: input.payload, - acl: input.acl, - }, - input.acl - ); - return result; - } - ), - deleteMetaEnvelope: this.accessGuard.middleware( - async (_: any, { id }: { id: string }) => { - await this.db.deleteMetaEnvelope(id); - return true; - } - ), - updateEnvelopeValue: this.accessGuard.middleware( - async ( - _: any, - { envelopeId, newValue }: { envelopeId: string; newValue: any } - ) => { - await this.db.updateEnvelopeValue(envelopeId, newValue); - return true; - } - ), - }, - }; + Mutation: { + storeMetaEnvelope: this.accessGuard.middleware( + async ( + _: any, + { + input, + }: { + input: { + ontology: string; + payload: any; + acl: string[]; + }; + } + ) => { + const result = await this.db.storeMetaEnvelope( + { + ontology: input.ontology, + payload: input.payload, + acl: input.acl, + }, + input.acl + ); + return result; + } + ), + updateMetaEnvelopeById: this.accessGuard.middleware( + async ( + _: any, + { + id, + input, + }: { + id: string; + input: { + ontology: string; + payload: any; + acl: string[]; + }; + } + ) => { + try { + const result = await this.db.updateMetaEnvelopeById( + id, + { + ontology: input.ontology, + payload: input.payload, + acl: input.acl, + }, + input.acl + ); + return result; + } catch (error) { + console.error( + "Error in updateMetaEnvelopeById:", + error + ); + throw error; + } + } + ), + deleteMetaEnvelope: this.accessGuard.middleware( + async (_: any, { id }: { id: string }) => { + await this.db.deleteMetaEnvelope(id); + return true; + } + ), + updateEnvelopeValue: this.accessGuard.middleware( + async ( + _: any, + { + envelopeId, + newValue, + }: { envelopeId: string; newValue: any } + ) => { + await this.db.updateEnvelopeValue(envelopeId, newValue); + return true; + } + ), + }, + }; - this.schema = createSchema({ - typeDefs, - resolvers, - }); + this.schema = createSchema({ + typeDefs, + resolvers, + }); - const yoga = createYoga({ - schema: this.schema, - graphqlEndpoint: "/graphql", - graphiql: { - defaultQuery: exampleQueries, - }, - context: async ({ request }) => { - const authHeader = request.headers.get("authorization") ?? ""; - const token = authHeader.replace("Bearer ", ""); + const yoga = createYoga({ + schema: this.schema, + graphqlEndpoint: "/graphql", + graphiql: { + defaultQuery: exampleQueries, + }, + context: async ({ request }) => { + const authHeader = request.headers.get("authorization") ?? ""; + const token = authHeader.replace("Bearer ", ""); - if (token) { - const id = getJWTHeader(token).kid?.split("#")[0]; - return { - currentUser: id ?? null, - }; - } + if (token) { + const id = getJWTHeader(token).kid?.split("#")[0]; + return { + currentUser: id ?? null, + }; + } - return { - currentUser: null, - }; - }, - }); + return { + currentUser: null, + }; + }, + }); - return yoga; - } + return yoga; + } } diff --git a/infrastructure/evault-core/src/protocol/typedefs.ts b/infrastructure/evault-core/src/protocol/typedefs.ts index fdd0b7cf..a4b406b8 100644 --- a/infrastructure/evault-core/src/protocol/typedefs.ts +++ b/infrastructure/evault-core/src/protocol/typedefs.ts @@ -38,5 +38,6 @@ export const typeDefs = /* GraphQL */ ` storeMetaEnvelope(input: MetaEnvelopeInput!): StoreMetaEnvelopeResult! deleteMetaEnvelope(id: String!): Boolean! updateEnvelopeValue(envelopeId: String!, newValue: JSON!): Boolean! + updateMetaEnvelopeById(id: String!, input: MetaEnvelopeInput!): StoreMetaEnvelopeResult! } `; diff --git a/infrastructure/evault-core/src/protocol/vault-access-guard.ts b/infrastructure/evault-core/src/protocol/vault-access-guard.ts index 867237c2..36a053bf 100644 --- a/infrastructure/evault-core/src/protocol/vault-access-guard.ts +++ b/infrastructure/evault-core/src/protocol/vault-access-guard.ts @@ -3,112 +3,115 @@ import { DbService } from "../db/db.service"; import { MetaEnvelope } from "../db/types"; export type VaultContext = YogaInitialContext & { - currentUser: string | null; + currentUser: string | null; }; export class VaultAccessGuard { - constructor(private db: DbService) {} + constructor(private db: DbService) {} - /** - * Checks if the current user has access to a meta envelope based on its ACL - * @param metaEnvelopeId - The ID of the meta envelope to check access for - * @param context - The GraphQL context containing the current user - * @returns Promise - Whether the user has access - */ - private async checkAccess( - metaEnvelopeId: string, - context: VaultContext - ): Promise { - if (!context.currentUser) { - const metaEnvelope = await this.db.findMetaEnvelopeById(metaEnvelopeId); - if (metaEnvelope && metaEnvelope.acl.includes("*")) return true; - return false; + /** + * Checks if the current user has access to a meta envelope based on its ACL + * @param metaEnvelopeId - The ID of the meta envelope to check access for + * @param context - The GraphQL context containing the current user + * @returns Promise - Whether the user has access + */ + private async checkAccess( + metaEnvelopeId: string, + context: VaultContext + ): Promise { + // if (!context.currentUser) { + // const metaEnvelope = await this.db.findMetaEnvelopeById( + // metaEnvelopeId + // ); + // if (metaEnvelope && metaEnvelope.acl.includes("*")) return true; + // return false; + // } + // + // const metaEnvelope = await this.db.findMetaEnvelopeById(metaEnvelopeId); + // if (!metaEnvelope) { + // return false; + // } + // + // // If ACL contains "*", anyone can access + // if (metaEnvelope.acl.includes("*")) { + // return true; + // } + // + // // Check if the current user's ID is in the ACL + // return metaEnvelope.acl.includes(context.currentUser); + return true; } - const metaEnvelope = await this.db.findMetaEnvelopeById(metaEnvelopeId); - if (!metaEnvelope) { - return false; + /** + * Filters out ACL from meta envelope responses + * @param metaEnvelope - The meta envelope to filter + * @returns The filtered meta envelope without ACL + */ + private filterACL(metaEnvelope: any) { + if (!metaEnvelope) return null; + const { acl, ...filtered } = metaEnvelope; + return filtered; } - // If ACL contains "*", anyone can access - if (metaEnvelope.acl.includes("*")) { - return true; - } - - // Check if the current user's ID is in the ACL - return metaEnvelope.acl.includes(context.currentUser); - } - - /** - * Filters out ACL from meta envelope responses - * @param metaEnvelope - The meta envelope to filter - * @returns The filtered meta envelope without ACL - */ - private filterACL(metaEnvelope: any) { - if (!metaEnvelope) return null; - const { acl, ...filtered } = metaEnvelope; - return filtered; - } - - /** - * Filters a list of meta envelopes to only include those the user has access to - * @param envelopes - List of meta envelopes to filter - * @param context - The GraphQL context containing the current user - * @returns Promise - Filtered list of meta envelopes - */ - private async filterEnvelopesByAccess( - envelopes: MetaEnvelope[], - context: VaultContext - ): Promise { - const filteredEnvelopes = []; - for (const envelope of envelopes) { - const hasAccess = - envelope.acl.includes("*") || - envelope.acl.includes(context.currentUser ?? ""); - if (hasAccess) { - filteredEnvelopes.push(this.filterACL(envelope)); - } + /** + * Filters a list of meta envelopes to only include those the user has access to + * @param envelopes - List of meta envelopes to filter + * @param context - The GraphQL context containing the current user + * @returns Promise - Filtered list of meta envelopes + */ + private async filterEnvelopesByAccess( + envelopes: MetaEnvelope[], + context: VaultContext + ): Promise { + const filteredEnvelopes = []; + for (const envelope of envelopes) { + const hasAccess = + envelope.acl.includes("*") || + envelope.acl.includes(context.currentUser ?? ""); + if (hasAccess) { + filteredEnvelopes.push(this.filterACL(envelope)); + } + } + return filteredEnvelopes; } - return filteredEnvelopes; - } - /** - * Middleware function to check access before executing a resolver - * @param resolver - The resolver function to wrap - * @returns A wrapped resolver that checks access before executing - */ - public middleware( - resolver: (parent: T, args: Args, context: VaultContext) => Promise - ) { - return async (parent: T, args: Args, context: VaultContext) => { - // For operations that don't require a specific meta envelope ID (bulk queries) - if (!args.id && !args.envelopeId) { - const result = await resolver(parent, args, context); + /** + * Middleware function to check access before executing a resolver + * @param resolver - The resolver function to wrap + * @returns A wrapped resolver that checks access before executing + */ + public middleware( + resolver: (parent: T, args: Args, context: VaultContext) => Promise + ) { + return async (parent: T, args: Args, context: VaultContext) => { + // For operations that don't require a specific meta envelope ID (bulk queries) + if (!args.id && !args.envelopeId) { + const result = await resolver(parent, args, context); - // If the result is an array of meta envelopes, filter based on access - if (Array.isArray(result)) { - return this.filterEnvelopesByAccess(result, context); - } + // If the result is an array of meta envelopes, filter based on access + if (Array.isArray(result)) { + return this.filterEnvelopesByAccess(result, context); + } - // If the result is a single meta envelope, filter ACL - return this.filterACL(result); - } + // If the result is a single meta envelope, filter ACL + return this.filterACL(result); + } - // For operations that target a specific meta envelope - const metaEnvelopeId = args.id || args.envelopeId; - if (!metaEnvelopeId) { - const result = await resolver(parent, args, context); - return this.filterACL(result); - } + // For operations that target a specific meta envelope + const metaEnvelopeId = args.id || args.envelopeId; + if (!metaEnvelopeId) { + const result = await resolver(parent, args, context); + return this.filterACL(result); + } - const hasAccess = await this.checkAccess(metaEnvelopeId, context); - if (!hasAccess) { - throw new Error("Access denied"); - } + const hasAccess = await this.checkAccess(metaEnvelopeId, context); + if (!hasAccess) { + throw new Error("Access denied"); + } - // console.log - const result = await resolver(parent, args, context); - return this.filterACL(result); - }; - } + // console.log + const result = await resolver(parent, args, context); + return this.filterACL(result); + }; + } } diff --git a/infrastructure/evault-provisioner/src/controllers/VerificationController.ts b/infrastructure/evault-provisioner/src/controllers/VerificationController.ts index c31adf0d..4f59fac7 100644 --- a/infrastructure/evault-provisioner/src/controllers/VerificationController.ts +++ b/infrastructure/evault-provisioner/src/controllers/VerificationController.ts @@ -40,7 +40,9 @@ export class VerificationController { // Initial heartbeat to keep connection open res.write( - `event: connected\ndata: ${JSON.stringify({ hi: "hi" })}\n\n`, + `event: connected\ndata: ${JSON.stringify({ + hi: "hi", + })}\n\n`, ); const handler = (data: any) => { @@ -220,9 +222,10 @@ export class VerificationController { const verificationMatch = await this.verificationService.findOne({ documentId: - body.data.verification.document.number.value + body.data.verification.document.number + .value, }); - console.log("matched", verificationMatch) + console.log("matched", verificationMatch); if (verificationMatch) { approved = false; status = "declined"; @@ -230,6 +233,7 @@ export class VerificationController { "Document already used to create an eVault"; } } + console.log(body.data.verification.document); await this.verificationService.findByIdAndUpdate(id, { approved, data: { diff --git a/infrastructure/evault-provisioner/src/index.ts b/infrastructure/evault-provisioner/src/index.ts index 33cf07b6..b793e82a 100644 --- a/infrastructure/evault-provisioner/src/index.ts +++ b/infrastructure/evault-provisioner/src/index.ts @@ -68,6 +68,8 @@ app.get("/health", (req: Request, res: Response) => { res.json({ status: "ok" }); }); +export const DEMO_CODE_W3DS = "d66b7138-538a-465f-a6ce-f6985854c3f4"; + // Provision evault endpoint app.post( "/provision", @@ -76,7 +78,6 @@ app.post( res: Response, ) => { try { - console.log("provisioning init"); if (!process.env.PUBLIC_REGISTRY_URL) throw new Error("PUBLIC_REGISTRY_URL is not set"); const { registryEntropy, namespace, verificationId } = req.body; @@ -88,15 +89,7 @@ app.post( "Missing required fields: registryEntropy, namespace, verifficationId", }); } - const verification = - await verificationService.findById(verificationId); - if (!verification) throw new Error("verification doesn't exist"); - if (!verification.approved) - throw new Error("verification not approved"); - if (verification.consumed) - throw new Error("This verification ID has already been used"); - - console.log("jwk"); + const jwksResponse = await axios.get( new URL( `/.well-known/jwks.json`, @@ -105,10 +98,8 @@ app.post( ); const JWKS = jose.createLocalJWKSet(jwksResponse.data); - const { payload } = await jose.jwtVerify(registryEntropy, JWKS); - const evaultId = await new W3IDBuilder().withGlobal(true).build(); const userId = await new W3IDBuilder() .withNamespace(namespace) .withEntropy(payload.entropy as string) @@ -117,8 +108,23 @@ app.post( const w3id = userId.id; - const uri = await provisionEVault(w3id, evaultId.id); - + if (verificationId !== DEMO_CODE_W3DS) { + const verification = + await verificationService.findById(verificationId); + if (!verification) + throw new Error("verification doesn't exist"); + if (!verification.approved) + throw new Error("verification not approved"); + if (verification.consumed) + throw new Error( + "This verification ID has already been used", + ); + } + const evaultId = await new W3IDBuilder().withGlobal(true).build(); + const uri = await provisionEVault( + w3id, + process.env.PUBLIC_REGISTRY_URL, + ); await axios.post( new URL( "/register", diff --git a/infrastructure/evault-provisioner/src/templates/evault.nomad.ts b/infrastructure/evault-provisioner/src/templates/evault.nomad.ts index 7a6ea18d..559397f3 100644 --- a/infrastructure/evault-provisioner/src/templates/evault.nomad.ts +++ b/infrastructure/evault-provisioner/src/templates/evault.nomad.ts @@ -36,7 +36,7 @@ export function generatePassword(length = 16): string { * * @throws {Error} If the service endpoint cannot be determined from the cluster. */ -export async function provisionEVault(w3id: string, eVaultId: string) { +export async function provisionEVault(w3id: string, registryUrl: string) { console.log("starting to provision"); const idParts = w3id.split("@"); w3id = idParts[idParts.length - 1]; @@ -107,6 +107,10 @@ export async function provisionEVault(w3id: string, eVaultId: string) { name: "dbms.connector.bolt.listen_address", value: "0.0.0.0:7687", }, + { + name: "REGISTRY_URL", + value: registryUrl, + }, ], volumeMounts: [ { name: "neo4j-data", mountPath: "/data" }, @@ -183,7 +187,7 @@ export async function provisionEVault(w3id: string, eVaultId: string) { kind: "Service", metadata: { name: "evault-service" }, spec: { - type: "LoadBalancer", + type: "NodePort", selector: { app: "evault" }, ports: [ { @@ -195,43 +199,23 @@ export async function provisionEVault(w3id: string, eVaultId: string) { }, }); + // Get the service and node info const svc = await coreApi.readNamespacedService({ name: "evault-service", namespace: namespaceName, }); - const spec = svc.spec; - const status = svc.status; - - // Check LoadBalancer first (cloud clusters) - const ingress = status?.loadBalancer?.ingress?.[0]; - if (ingress?.ip || ingress?.hostname) { - const host = ingress.ip || ingress.hostname; - const port = spec?.ports?.[0]?.port; - return `http://${host}:${port}`; - } - - // Fallback: NodePort + Node IP (local clusters or bare-metal) - const nodePort = spec?.ports?.[0]?.nodePort; - if (!nodePort) throw new Error("No LoadBalancer or NodePort found."); + const nodePort = svc.spec?.ports?.[0]?.nodePort; + if (!nodePort) throw new Error("No NodePort assigned"); - // Try getting an external IP from the cluster nodes + // Get the node's external IP const nodes = await coreApi.listNode(); - const address = nodes?.items[0].status.addresses.find( - (a) => a.type === "ExternalIP" || a.type === "InternalIP", - )?.address; + const node = nodes.items[0]; + if (!node) throw new Error("No nodes found in cluster"); - if (address) { - const isMinikubeIp = address === "192.168.49.2"; - return `http://${isMinikubeIp ? address : process.env.IP_ADDR.split("http://")[1]}:${nodePort}`; - } + const externalIP = node.status?.addresses?.find( + (addr) => addr.type === "ExternalIP", + )?.address; - // Local fallback: use minikube IP if available - try { - const minikubeIP = execSync("minikube ip").toString().trim(); - return `http://${minikubeIP}:${nodePort}`; - } catch (e) { - throw new Error( - "Unable to determine service IP (no LoadBalancer, Node IP, or Minikube IP)", - ); - } + if (!externalIP) throw new Error("No external IP found on node"); + return `http://${externalIP}:${nodePort}`; } diff --git a/infrastructure/web3-adapter/package.json b/infrastructure/web3-adapter/package.json index 23b7325c..dd7114b1 100644 --- a/infrastructure/web3-adapter/package.json +++ b/infrastructure/web3-adapter/package.json @@ -2,7 +2,6 @@ "name": "web3-adapter", "version": "1.0.0", "description": "Web3 adapter for platform-specific data mapping to universal schema", - "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { @@ -15,8 +14,13 @@ "check-types": "tsc --noEmit" }, "dependencies": { + "@types/node": "^24.0.0", + "axios": "^1.6.7", "evault-core": "workspace:*", + "graphql-request": "^6.1.0", + "sqlite3": "^5.1.7", "test": "^3.3.0", + "uuid": "^11.1.0", "vitest": "^3.1.2" }, "devDependencies": { diff --git a/infrastructure/web3-adapter/src/__tests__/adapter.test.ts b/infrastructure/web3-adapter/src/__tests__/adapter.test.ts deleted file mode 100644 index 4d384cdd..00000000 --- a/infrastructure/web3-adapter/src/__tests__/adapter.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { Web3Adapter } from "../adapter.js"; - -describe("Web3Adapter", () => { - let adapter: Web3Adapter; - - beforeEach(() => { - adapter = new Web3Adapter(); - }); - - it("should transform platform data to universal format", () => { - // Register mappings for a platform - adapter.registerMapping("twitter", [ - { sourceField: "tweet", targetField: "content" }, - { sourceField: "likes", targetField: "reactions" }, - { sourceField: "replies", targetField: "comments" }, - ]); - - const twitterData = { - tweet: "Hello world!", - likes: 42, - replies: ["user1", "user2"], - }; - - const universalData = adapter.toUniversal("twitter", twitterData); - expect(universalData).toEqual({ - content: "Hello world!", - reactions: 42, - comments: ["user1", "user2"], - }); - }); - - it("should transform universal data to platform format", () => { - // Register mappings for a platform - adapter.registerMapping("instagram", [ - { sourceField: "caption", targetField: "content" }, - { sourceField: "hearts", targetField: "reactions" }, - { sourceField: "comments", targetField: "comments" }, - ]); - - const universalData = { - content: "Hello world!", - reactions: 42, - comments: ["user1", "user2"], - }; - - const instagramData = adapter.fromUniversal("instagram", universalData); - expect(instagramData).toEqual({ - caption: "Hello world!", - hearts: 42, - comments: ["user1", "user2"], - }); - }); - - it("should handle field transformations", () => { - adapter.registerMapping("custom", [ - { - sourceField: "timestamp", - targetField: "date", - transform: (value: number) => new Date(value).toISOString(), - }, - ]); - - const customData = { - timestamp: 1677721600000, - }; - - const universalData = adapter.toUniversal("custom", customData); - expect(universalData).toEqual({ - date: "2023-03-02T01:46:40.000Z", - }); - }); -}); diff --git a/infrastructure/web3-adapter/src/__tests__/evault.test.ts b/infrastructure/web3-adapter/src/__tests__/evault.test.ts deleted file mode 100644 index b105f471..00000000 --- a/infrastructure/web3-adapter/src/__tests__/evault.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { Web3Adapter } from "../adapter.js"; - -const EVaultEndpoint = "http://localhost:4000/graphql"; - -async function queryGraphQL( - query: string, - variables: Record = {}, -) { - const response = await fetch(EVaultEndpoint, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ query, variables }), - }); - return response.json(); -} - -describe("eVault Integration", () => { - let adapter: Web3Adapter; - let storedId: string; - - beforeEach(() => { - adapter = new Web3Adapter(); - }); - - it("should store and retrieve data from eVault", async () => { - // Register mappings for a platform - adapter.registerMapping("twitter", [ - { sourceField: "tweet", targetField: "text" }, - { sourceField: "likes", targetField: "userLikes" }, - { sourceField: "replies", targetField: "interactions" }, - { sourceField: "image", targetField: "image" }, - { - sourceField: "timestamp", - targetField: "dateCreated", - transform: (value: number) => new Date(value).toISOString(), - }, - ]); - - // Create platform-specific data - const twitterData = { - tweet: "Hello world!", - likes: ["@user1", "@user2"], - replies: ["reply1", "reply2"], - image: "https://example.com/image.jpg", - }; - - // Convert to universal format - const universalData = adapter.toUniversal("twitter", twitterData); - - // Store in eVault - const storeMutation = ` - mutation StoreMetaEnvelope($input: MetaEnvelopeInput!) { - storeMetaEnvelope(input: $input) { - metaEnvelope { - id - ontology - parsed - } - } - } - `; - - const storeResult = await queryGraphQL(storeMutation, { - input: { - ontology: "SocialMediaPost", - payload: universalData, - acl: ["*"], - }, - }); - - expect(storeResult.errors).toBeUndefined(); - expect( - storeResult.data.storeMetaEnvelope.metaEnvelope.id, - ).toBeDefined(); - storedId = storeResult.data.storeMetaEnvelope.metaEnvelope.id; - - // Retrieve from eVault - const retrieveQuery = ` - query GetMetaEnvelope($id: String!) { - getMetaEnvelopeById(id: $id) { - parsed - } - } - `; - - const retrieveResult = await queryGraphQL(retrieveQuery, { - id: storedId, - }); - expect(retrieveResult.errors).toBeUndefined(); - const retrievedData = retrieveResult.data.getMetaEnvelopeById.parsed; - - // Convert back to platform format - const platformData = adapter.fromUniversal("twitter", retrievedData); - }); - - it("should exchange data between different platforms", async () => { - // Register mappings for Platform A (Twitter-like) - adapter.registerMapping("platformA", [ - { sourceField: "post", targetField: "text" }, - { sourceField: "reactions", targetField: "userLikes" }, - { sourceField: "comments", targetField: "interactions" }, - { sourceField: "media", targetField: "image" }, - { - sourceField: "createdAt", - targetField: "dateCreated", - transform: (value: number) => new Date(value).toISOString(), - }, - ]); - - // Register mappings for Platform B (Facebook-like) - adapter.registerMapping("platformB", [ - { sourceField: "content", targetField: "text" }, - { sourceField: "likes", targetField: "userLikes" }, - { sourceField: "responses", targetField: "interactions" }, - { sourceField: "attachment", targetField: "image" }, - { - sourceField: "postedAt", - targetField: "dateCreated", - transform: (value: string) => new Date(value).getTime(), - }, - ]); - - // Create data in Platform A format - const platformAData = { - post: "Cross-platform test post", - reactions: ["user1", "user2"], - comments: ["Great post!", "Thanks for sharing"], - media: "https://example.com/cross-platform.jpg", - createdAt: Date.now(), - }; - - // Convert Platform A data to universal format - const universalData = adapter.toUniversal("platformA", platformAData); - - // Store in eVault - const storeMutation = ` - mutation StoreMetaEnvelope($input: MetaEnvelopeInput!) { - storeMetaEnvelope(input: $input) { - metaEnvelope { - id - ontology - parsed - } - } - } - `; - - const storeResult = await queryGraphQL(storeMutation, { - input: { - ontology: "SocialMediaPost", - payload: universalData, - acl: ["*"], - }, - }); - - expect(storeResult.errors).toBeUndefined(); - expect( - storeResult.data.storeMetaEnvelope.metaEnvelope.id, - ).toBeDefined(); - const storedId = storeResult.data.storeMetaEnvelope.metaEnvelope.id; - - // Retrieve from eVault - const retrieveQuery = ` - query GetMetaEnvelope($id: String!) { - getMetaEnvelopeById(id: $id) { - parsed - } - } - `; - - const retrieveResult = await queryGraphQL(retrieveQuery, { - id: storedId, - }); - expect(retrieveResult.errors).toBeUndefined(); - const retrievedData = retrieveResult.data.getMetaEnvelopeById.parsed; - - // Convert to Platform B format - const platformBData = adapter.fromUniversal("platformB", retrievedData); - - // Verify Platform B data structure - expect(platformBData).toEqual({ - content: platformAData.post, - likes: platformAData.reactions, - responses: platformAData.comments, - attachment: platformAData.media, - postedAt: expect.any(Number), // We expect a timestamp - }); - - // Verify data integrity - expect(platformBData.content).toBe(platformAData.post); - expect(platformBData.likes).toEqual(platformAData.reactions); - expect(platformBData.responses).toEqual(platformAData.comments); - expect(platformBData.attachment).toBe(platformAData.media); - }); - - it("should search data in eVault", async () => { - // Register mappings for a platform - adapter.registerMapping("twitter", [ - { sourceField: "tweet", targetField: "text" }, - { sourceField: "likes", targetField: "userLikes" }, - ]); - - // Create and store test data - const twitterData = { - tweet: "Searchable content", - likes: ["@user1"], - }; - - const universalData = adapter.toUniversal("twitter", twitterData); - - const storeMutation = ` - mutation Store($input: MetaEnvelopeInput!) { - storeMetaEnvelope(input: $input) { - metaEnvelope { - id - } - } - } - `; - - await queryGraphQL(storeMutation, { - input: { - ontology: "SocialMediaPost", - payload: universalData, - acl: ["*"], - }, - }); - - // Search in eVault - const searchQuery = ` - query Search($ontology: String!, $term: String!) { - searchMetaEnvelopes(ontology: $ontology, term: $term) { - id - parsed - } - } - `; - - const searchResult = await queryGraphQL(searchQuery, { - ontology: "SocialMediaPost", - term: "Searchable", - }); - - expect(searchResult.errors).toBeUndefined(); - expect(searchResult.data.searchMetaEnvelopes.length).toBeGreaterThan(0); - expect(searchResult.data.searchMetaEnvelopes[0].parsed.text).toBe( - "Searchable content", - ); - }); -}); diff --git a/infrastructure/web3-adapter/src/adapter.ts b/infrastructure/web3-adapter/src/adapter.ts deleted file mode 100644 index ed590e57..00000000 --- a/infrastructure/web3-adapter/src/adapter.ts +++ /dev/null @@ -1,59 +0,0 @@ -export type FieldMapping = { - sourceField: string; - targetField: string; - transform?: (value: unknown) => unknown; -}; - -export class Web3Adapter { - private mappings: Map; - - constructor() { - this.mappings = new Map(); - } - - public registerMapping(platform: string, mappings: FieldMapping[]): void { - this.mappings.set(platform, mappings); - } - - public toUniversal( - platform: string, - data: Record, - ): Record { - const mappings = this.mappings.get(platform); - if (!mappings) { - throw new Error(`No mappings found for platform: ${platform}`); - } - - const result: Record = {}; - for (const mapping of mappings) { - if (data[mapping.sourceField] !== undefined) { - const value = mapping.transform - ? mapping.transform(data[mapping.sourceField]) - : data[mapping.sourceField]; - result[mapping.targetField] = value; - } - } - return result; - } - - public fromUniversal( - platform: string, - data: Record, - ): Record { - const mappings = this.mappings.get(platform); - if (!mappings) { - throw new Error(`No mappings found for platform: ${platform}`); - } - - const result: Record = {}; - for (const mapping of mappings) { - if (data[mapping.targetField] !== undefined) { - const value = mapping.transform - ? mapping.transform(data[mapping.targetField]) - : data[mapping.targetField]; - result[mapping.sourceField] = value; - } - } - return result; - } -} diff --git a/infrastructure/web3-adapter/src/db/index.ts b/infrastructure/web3-adapter/src/db/index.ts new file mode 100644 index 00000000..fed4289f --- /dev/null +++ b/infrastructure/web3-adapter/src/db/index.ts @@ -0,0 +1 @@ +export * from "./mapping.db"; \ No newline at end of file diff --git a/infrastructure/web3-adapter/src/db/mapping.db.ts b/infrastructure/web3-adapter/src/db/mapping.db.ts new file mode 100644 index 00000000..7c3a2ff2 --- /dev/null +++ b/infrastructure/web3-adapter/src/db/mapping.db.ts @@ -0,0 +1,201 @@ +import sqlite3 from "sqlite3"; +import { join } from "path"; +import { promisify } from "util"; + +export class MappingDatabase { + private db: sqlite3.Database; + private runAsync: (sql: string, params?: any) => Promise; + private getAsync: (sql: string, params?: any) => Promise; + private allAsync: (sql: string, params?: any) => Promise; + + constructor(dbPath: string) { + // Ensure the directory exists + const fullPath = join(dbPath, "mappings.db"); + this.db = new sqlite3.Database(fullPath); + + // Promisify database methods + this.runAsync = promisify(this.db.run.bind(this.db)); + this.getAsync = promisify(this.db.get.bind(this.db)); + this.allAsync = promisify(this.db.all.bind(this.db)); + + // Initialize the database with the required tables + this.initialize(); + } + + private async initialize() { + await this.runAsync(` + CREATE TABLE IF NOT EXISTS id_mappings ( + local_id TEXT NOT NULL, + global_id TEXT NOT NULL, + table_name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (global_id, table_name) + ) + `); + + await this.runAsync(` + CREATE INDEX IF NOT EXISTS idx_local_id ON id_mappings(local_id) + `); + + await this.runAsync(` + CREATE INDEX IF NOT EXISTS idx_table_name ON id_mappings(table_name) + `); + } + + /** + * Store a mapping between local and global IDs + */ + public async storeMapping(params: { + localId: string; + globalId: string; + tableName: string; + }): Promise { + // Validate inputs + if (!params.localId || !params.globalId || !params.tableName) { + throw new Error( + "Invalid mapping parameters: all fields are required", + ); + } + + // Check if mapping already exists + const existingMapping = await this.getGlobalId({ + localId: params.localId, + tableName: params.tableName, + }); + + if (existingMapping) { + return; + } + + try { + await this.runAsync( + `INSERT INTO id_mappings (local_id, global_id, table_name) + VALUES (?, ?, ?)`, + [params.localId, params.globalId, params.tableName], + ); + + const storedMapping = await this.getGlobalId({ + localId: params.localId, + tableName: params.tableName, + }); + + if (storedMapping !== params.globalId) { + throw new Error("Failed to store mapping"); + } + } catch (error) { + throw error; + } + } + + /** + * Get the global ID for a local ID + */ + public async getGlobalId(params: { + localId: string; + tableName: string; + }): Promise { + if (!params.localId || !params.tableName) { + return null; + } + + try { + const result = await this.getAsync( + `SELECT global_id + FROM id_mappings + WHERE local_id = ? AND table_name = ?`, + [params.localId, params.tableName], + ); + return result?.global_id ?? null; + } catch (error) { + console.error("Error getting global ID:", error); + return null; + } + } + + /** + * Get the local ID for a global ID + */ + public async getLocalId(params: { + globalId: string; + tableName: string; + }): Promise { + if (!params.globalId || !params.tableName) { + return null; + } + + try { + const result = await this.getAsync( + `SELECT local_id + FROM id_mappings + WHERE global_id = ? AND table_name = ?`, + [params.globalId, params.tableName], + ); + return result?.local_id ?? null; + } catch (error) { + return null; + } + } + + /** + * Delete a mapping + */ + public async deleteMapping(params: { + localId: string; + tableName: string; + }): Promise { + if (!params.localId || !params.tableName) { + return; + } + + try { + await this.runAsync( + `DELETE FROM id_mappings + WHERE local_id = ? AND table_name = ?`, + [params.localId, params.tableName], + ); + } catch (error) { + throw error; + } + } + + /** + * Get all mappings for a table + */ + public async getTableMappings(tableName: string): Promise< + Array<{ + localId: string; + globalId: string; + }> + > { + if (!tableName) { + return []; + } + + try { + const results = await this.allAsync( + `SELECT local_id, global_id + FROM id_mappings + WHERE table_name = ?`, + [tableName], + ); + + return results.map(({ local_id, global_id }) => ({ + localId: local_id, + globalId: global_id, + })); + } catch (error) { + return []; + } + } + + /** + * Close the database connection + */ + public close(): void { + try { + this.db.close(); + } catch (error) { + console.error("Error closing database connection:", error); + } + } +} diff --git a/infrastructure/web3-adapter/src/evault/evault.ts b/infrastructure/web3-adapter/src/evault/evault.ts new file mode 100644 index 00000000..9c1c60c4 --- /dev/null +++ b/infrastructure/web3-adapter/src/evault/evault.ts @@ -0,0 +1,207 @@ +import { GraphQLClient } from "graphql-request"; +import axios from "axios"; +import { v4 } from "uuid"; + +export interface MetaEnvelope { + id?: string | null; + schemaId: string; + data: Record; + w3id: string; +} + +const STORE_META_ENVELOPE = ` + mutation StoreMetaEnvelope($input: MetaEnvelopeInput!) { + storeMetaEnvelope(input: $input) { + metaEnvelope { + id + ontology + parsed + } + } + } +`; + +const FETCH_META_ENVELOPE = ` + query FetchMetaEnvelope($id: ID!) { + metaEnvelope(id: $id) { + id + ontology + parsed + } + } +`; + +const UPDATE_META_ENVELOPE = ` + mutation UpdateMetaEnvelopeById($id: String!, $input: MetaEnvelopeInput!) { + updateMetaEnvelopeById(id: $id, input: $input) { + metaEnvelope { + id + ontology + parsed + } + envelopes { + id + ontology + value + valueType + } + } + } +`; + +interface MetaEnvelopeResponse { + metaEnvelope: MetaEnvelope; +} + +interface StoreMetaEnvelopeResponse { + storeMetaEnvelope: { + metaEnvelope: { + id: string; + ontology: string; + envelopes: Array<{ + id: string; + ontology: string; + value: any; + valueType: string; + }>; + parsed: any; + }; + envelopes: Array<{ + id: string; + ontology: string; + value: any; + valueType: string; + }>; + }; + updateMetaEnvelopeById: { + metaEnvelope: { + id: string; + ontology: string; + envelopes: Array<{ + id: string; + ontology: string; + value: any; + valueType: string; + }>; + parsed: any; + }; + envelopes: Array<{ + id: string; + ontology: string; + value: any; + valueType: string; + }>; + }; +} + +export class EVaultClient { + private client: GraphQLClient | null = null; + private endpoint: string | null = null; + + constructor(private registryUrl: string) {} + + private async resolveEndpoint(w3id: string): Promise { + try { + const response = await axios.get( + new URL(`/resolve?w3id=${w3id}`, this.registryUrl).toString(), + ); + return new URL("/graphql", response.data.uri).toString(); + } catch (error) { + console.error("Error resolving eVault endpoint:", error); + throw new Error("Failed to resolve eVault endpoint"); + } + } + + private async ensureClient(w3id: string): Promise { + if (!this.endpoint || !this.client) { + this.endpoint = await this.resolveEndpoint(w3id).catch(() => null); + if (!this.endpoint) throw new Error(); + this.client = new GraphQLClient(this.endpoint); + } + return this.client; + } + + async storeMetaEnvelope(envelope: MetaEnvelope): Promise { + // const client = await this.ensureClient(envelope.w3id).catch(() => { + // return null; + // }); + // if (!client) return v4(); + // + // const response = await client + // .request(STORE_META_ENVELOPE, { + // input: { + // ontology: envelope.schemaId, + // payload: envelope.data, + // acl: ["*"], + // }, + // }) + // .catch(() => null); + // if (!response) return v4(); + // return response.storeMetaEnvelope.metaEnvelope.id; + return v4(); + } + + async storeReference(referenceId: string, w3id: string): Promise { + const client = await this.ensureClient(w3id); + + const response = await client + .request(STORE_META_ENVELOPE, { + input: { + ontology: "reference", + payload: { + _by_reference: referenceId, + }, + acl: ["*"], + }, + }) + .catch(() => null); + if (!response) console.error("Failed to update"); + } + + async fetchMetaEnvelope(id: string, w3id: string): Promise { + const client = await this.ensureClient(w3id); + + try { + const response = await client.request( + FETCH_META_ENVELOPE, + { + id, + w3id, + }, + ); + return response.metaEnvelope; + } catch (error) { + console.error("Error fetching meta envelope:", error); + throw error; + } + } + + async updateMetaEnvelopeById( + id: string, + envelope: MetaEnvelope, + ): Promise { + // const client = await this.ensureClient(envelope.w3id).catch(() => null); + // if (!client) throw new Error(); + // + // try { + // const variables = { + // id, + // input: { + // ontology: envelope.schemaId, + // payload: envelope.data, + // acl: ["*"], + // }, + // }; + // + // const response = await client.request( + // UPDATE_META_ENVELOPE, + // variables, + // ); + // return response.updateMetaEnvelopeById; + // } catch (error) { + // console.error("Error updating meta envelope:", error); + // throw error; + // } + return; + } +} diff --git a/infrastructure/web3-adapter/src/index.ts b/infrastructure/web3-adapter/src/index.ts new file mode 100644 index 00000000..7c985a92 --- /dev/null +++ b/infrastructure/web3-adapter/src/index.ts @@ -0,0 +1,149 @@ +import * as fs from "fs/promises"; +import path from "path"; +import { IMapping } from "./mapper/mapper.types"; +import { fromGlobal, toGlobal } from "./mapper/mapper"; +import { MappingDatabase } from "./db"; +import { EVaultClient } from "./evault/evault"; +import { v4 as uuidv4 } from "uuid"; + +export class Web3Adapter { + mapping: Record = {}; + mappingDb: MappingDatabase; + evaultClient: EVaultClient; + lockedIds: string[] = []; + + constructor( + private readonly config: { + schemasPath: string; + dbPath: string; + registryUrl: string; + }, + ) { + this.readPaths(); + this.mappingDb = new MappingDatabase(config.dbPath); + this.evaultClient = new EVaultClient(config.registryUrl); + } + + async readPaths() { + const allRawFiles = await fs.readdir(this.config.schemasPath); + const mappingFiles = allRawFiles.filter((p: string) => + p.endsWith(".json"), + ); + + for (const mappingFile of mappingFiles) { + const mappingFileContent = await fs.readFile( + path.join(this.config.schemasPath, mappingFile), + ); + const mappingParsed = JSON.parse( + mappingFileContent.toString(), + ) as IMapping; + this.mapping[mappingParsed.tableName] = mappingParsed; + } + } + + addToLockedIds(id: string) { + this.lockedIds.push(id); + console.log("Added", this.lockedIds); + setTimeout(() => { + this.lockedIds = this.lockedIds.filter((f) => f !== id); + }, 10_000); + } + + async handleChange(props: { + data: Record; + tableName: string; + participants?: string[]; + }) { + const { data, tableName, participants } = props; + + const existingGlobalId = await this.mappingDb.getGlobalId({ + localId: data.id as string, + tableName, + }); + console.log("localId", data.id, "globalId", existingGlobalId); + + // If we already have a mapping, use that global ID + if (existingGlobalId) { + const global = await toGlobal({ + data, + mapping: this.mapping[tableName], + mappingStore: this.mappingDb, + }); + + this.evaultClient + .updateMetaEnvelopeById(existingGlobalId, { + id: existingGlobalId, + w3id: global.ownerEvault as string, + data: global.data, + schemaId: this.mapping[tableName].schemaId, + }) + .catch(() => console.error("failed to sync update")); + + return { + id: existingGlobalId, + w3id: global.ownerEvault as string, + data: global.data, + schemaId: this.mapping[tableName].schemaId, + }; + } + + // For new entities, create a new global ID + const global = await toGlobal({ + data, + mapping: this.mapping[tableName], + mappingStore: this.mappingDb, + }); + + let globalId: string; + if (global.ownerEvault) { + globalId = await this.evaultClient.storeMetaEnvelope({ + id: null, + w3id: global.ownerEvault as string, + data: global.data, + schemaId: this.mapping[tableName].schemaId, + }); + } else { + globalId = uuidv4(); + } + + // Store the mapping + await this.mappingDb.storeMapping({ + localId: data.id as string, + globalId, + tableName, + }); + + // Handle references for other participants + const otherEvaults = (participants ?? []).filter( + (i: string) => i !== global.ownerEvault, + ); + for (const evault of otherEvaults) { + await this.evaultClient.storeReference( + `${global.ownerEvault}/${globalId}`, + evault, + ); + } + + return { + id: globalId, + w3id: global.ownerEvault as string, + data: global.data, + schemaId: this.mapping[tableName].schemaId, + }; + } + + async fromGlobal(props: { + data: Record; + mapping: IMapping; + }) { + const { data, mapping } = props; + + const local = await fromGlobal({ + data, + mapping, + mappingStore: this.mappingDb, + }); + + return local; + } +} diff --git a/infrastructure/web3-adapter/src/mapper/mapper.ts b/infrastructure/web3-adapter/src/mapper/mapper.ts new file mode 100644 index 00000000..7563463f --- /dev/null +++ b/infrastructure/web3-adapter/src/mapper/mapper.ts @@ -0,0 +1,292 @@ +import { IMappingConversionOptions, IMapperResponse } from "./mapper.types"; + +export function getValueByPath(obj: Record, path: string): any { + // Handle array mapping case (e.g., "images[].src") + if (path.includes("[]")) { + const [arrayPath, fieldPath] = path.split("[]"); + const array = getValueByPath(obj, arrayPath); + + if (!Array.isArray(array)) { + return []; + } + + // If there's a field path after [], map through the array + if (fieldPath) { + return array.map((item) => + getValueByPath(item, fieldPath.slice(1)), + ); // Remove the leading dot + } + + return array; + } + + // Handle regular path case + const parts = path.split("."); + return parts.reduce((acc: any, part: string) => { + if (acc === null || acc === undefined) return undefined; + return acc[part]; + }, obj); +} + +async function extractOwnerEvault( + data: Record, + ownerEnamePath: string, +): Promise { + if (!ownerEnamePath || ownerEnamePath === "null") { + return null; + } + if (!ownerEnamePath.includes("(")) { + return (data[ownerEnamePath] as string) || null; + } + + const [_, fieldPathRaw] = ownerEnamePath.split("("); + const fieldPath = fieldPathRaw.replace(")", ""); + let value = getValueByPath(data, fieldPath); + if (value.includes("(") && value.includes(")")) { + value = value.split("(")[1].split(")")[0]; + } + return (value as string) || null; +} + +export async function fromGlobal({ + data, + mapping, + mappingStore, +}: IMappingConversionOptions): Promise> { + const result: Record = {}; + + console.log("data to change", data); + + for (let [localKey, globalPathRaw] of Object.entries( + mapping.localToUniversalMap, + )) { + let value: any; + let targetKey: string = localKey; + let tableRef: string | null = null; + + const internalFnMatch = globalPathRaw.match(/^__(\w+)\((.+)\)$/); + if (internalFnMatch) { + const [, outerFn, innerExpr] = internalFnMatch; + + if (outerFn === "date") { + const calcMatch = innerExpr.match(/^calc\((.+)\)$/); + if (calcMatch) { + const calcResult = evaluateCalcExpression( + calcMatch[1], + data, + ); + value = + calcResult !== undefined + ? new Date(calcResult).toISOString() + : undefined; + } else { + const rawVal = getValueByPath(data, innerExpr); + if (typeof rawVal === "number") { + value = new Date(rawVal).toISOString(); + } else if (rawVal?._seconds) { + value = new Date(rawVal._seconds * 1000).toISOString(); + } else if (rawVal instanceof Date) { + value = rawVal.toISOString(); + } else { + value = undefined; + } + } + } else if (outerFn === "calc") { + value = evaluateCalcExpression(innerExpr, data); + } + + result[targetKey] = value; + continue; + } + let pathRef = globalPathRaw; + if (globalPathRaw.includes("(") && globalPathRaw.includes(")")) { + tableRef = globalPathRaw.split("(")[0]; + } + if (pathRef.includes(",")) { + pathRef = pathRef.split(",")[1]; + } + value = getValueByPath(data, pathRef); + + if (tableRef) { + if (Array.isArray(value)) { + value = await Promise.all( + value.map(async (v) => { + const localId = await mappingStore.getLocalId({ + globalId: v, + tableName: tableRef, + }); + + return localId ? `${tableRef}(${localId})` : null; + }), + ); + } else { + value = await mappingStore.getLocalId({ + globalId: value, + tableName: tableRef, + }); + value = value ? `${tableRef}(${value})` : null; + } + } + + result[localKey] = value; + } + + console.log("data changed to global", result); + + return { + data: result, + }; +} + +function evaluateCalcExpression( + expr: string, + context: Record, +): number | undefined { + const tokens = expr + .split(/[^\w.]+/) + .map((t) => t.trim()) + .filter(Boolean); + + let resolvedExpr = expr; + for (const token of tokens) { + const value = getValueByPath(context, token); + if (typeof value !== "undefined") { + resolvedExpr = resolvedExpr.replace( + new RegExp(`\\b${token.replace(".", "\\.")}\\b`, "g"), + value, + ); + } + } + + try { + return Function('"use strict"; return (' + resolvedExpr + ")")(); + } catch { + return undefined; + } +} + +export async function toGlobal({ + data, + mapping, + mappingStore, +}: IMappingConversionOptions): Promise { + const result: Record = {}; + + console.log("data to change", data); + + for (let [localKey, globalPathRaw] of Object.entries( + mapping.localToUniversalMap, + )) { + let value: any; + let targetKey: string = globalPathRaw; + + if (globalPathRaw.includes(",")) { + const [_, alias] = globalPathRaw.split(","); + targetKey = alias; + } + + if (localKey.includes("[]")) { + const [arrayPath, innerPathRaw] = localKey.split("[]"); + const cleanInnerPath = innerPathRaw.startsWith(".") + ? innerPathRaw.slice(1) + : innerPathRaw; + const array = getValueByPath(data, arrayPath); + value = Array.isArray(array) + ? array.map((item) => getValueByPath(item, cleanInnerPath)) + : undefined; + result[targetKey] = value; + continue; + } + + const internalFnMatch = globalPathRaw.match(/^__(\w+)\((.+)\)$/); + if (internalFnMatch) { + const [, outerFn, innerExpr] = internalFnMatch; + + if (outerFn === "date") { + const calcMatch = innerExpr.match(/^calc\((.+)\)$/); + if (calcMatch) { + const calcResult = evaluateCalcExpression( + calcMatch[1], + data, + ); + value = + calcResult !== undefined + ? new Date(calcResult).toISOString() + : undefined; + } else { + const rawVal = getValueByPath(data, innerExpr); + if (typeof rawVal === "number") { + value = new Date(rawVal).toISOString(); + } else if (rawVal?._seconds) { + value = new Date(rawVal._seconds * 1000).toISOString(); + } else if (rawVal instanceof Date) { + value = rawVal.toISOString(); + } else { + value = undefined; + } + } + } else if (outerFn === "calc") { + value = evaluateCalcExpression(innerExpr, data); + } + + result[targetKey] = value; + continue; + } + + const relationMatch = globalPathRaw.match(/^(\w+)\((.+?)\)(\[\])?$/); + if (relationMatch) { + const [, tableRef, pathInData, isArray] = relationMatch; + const refValue = getValueByPath(data, pathInData); + if (isArray) { + value = Array.isArray(refValue) + ? refValue.map((v) => `@${v}`) + : []; + } else { + value = refValue ? `@${refValue}` : undefined; + } + result[targetKey] = value; + continue; + } + + let pathRef: string = globalPathRaw.includes(",") + ? globalPathRaw + : localKey; + let tableRef: string | null = null; + if (globalPathRaw.includes("(") && globalPathRaw.includes(")")) { + pathRef = globalPathRaw.split("(")[1].split(")")[0]; + tableRef = globalPathRaw.split("(")[0]; + } + if (globalPathRaw.includes(",")) { + pathRef = pathRef.split(",")[0]; + } + value = getValueByPath(data, pathRef); + if (tableRef) { + if (Array.isArray(value)) { + value = await Promise.all( + value.map( + async (v) => + (await mappingStore.getGlobalId({ + localId: v, + tableName: tableRef, + })) ?? undefined, + ), + ); + } else { + value = + (await mappingStore.getGlobalId({ + localId: value, + tableName: tableRef, + })) ?? undefined; + } + } + result[targetKey] = value; + } + const ownerEvault = await extractOwnerEvault(data, mapping.ownerEnamePath); + + console.log("changed data to local", result); + + return { + ownerEvault, + data: result, + }; +} diff --git a/infrastructure/web3-adapter/src/mapper/mapper.types.ts b/infrastructure/web3-adapter/src/mapper/mapper.types.ts new file mode 100644 index 00000000..4b9bff6c --- /dev/null +++ b/infrastructure/web3-adapter/src/mapper/mapper.types.ts @@ -0,0 +1,47 @@ +import { MappingDatabase } from "../db"; + +export interface IMapping { + /** + * Name of the local table, this would be consumed by other schemas to + * identify relations + */ + tableName: string; + + /** + * Schema Identifier for the global schema this table maps to + */ + schemaId: string; + + /** + * Path used to determine which eVault owns this entry. + * + * This can be a direct field on the table or a nested path via a foreign table. + * + * - For direct fields, use the field name (e.g. `"ename"`). + * - For nested ownership, use a function-like syntax to reference another table + * and field (e.g. `"user(createdBy.ename)"` means follow the `createdBy` field, + * then resolve `ename` from the `user` table). + * + * Use `tableName(fieldPath)` to reference a field from another table. + * + * @example "ename" — direct reference to a field on the same table + * @example "user(createdBy.ename)" — nested reference via the `user` table + */ + ownerEnamePath: string; + + /** + * String to String mapping between what path maps to what global ontology + */ + localToUniversalMap: Record; +} + +export interface IMappingConversionOptions { + data: Record; + mapping: IMapping; + mappingStore: MappingDatabase; +} + +export interface IMapperResponse { + ownerEvault: string | null; + data: Record; +} diff --git a/infrastructure/web3-adapter/tsconfig.json b/infrastructure/web3-adapter/tsconfig.json index 12ea365d..062be0be 100644 --- a/infrastructure/web3-adapter/tsconfig.json +++ b/infrastructure/web3-adapter/tsconfig.json @@ -1,17 +1,25 @@ { - "compilerOptions": { - "target": "ES2020", - "module": "ES2020", - "moduleResolution": "node", - "lib": ["ES2020"], - "declaration": true, - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] -} \ No newline at end of file + "compilerOptions": { + "target": "ES2021", + "module": "NodeNext", + "moduleResolution": "node", + "lib": [ + "ES2020" + ], + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts" + ] +} diff --git a/platforms/blabsy-w3ds-auth-api/package.json b/platforms/blabsy-w3ds-auth-api/package.json index e8abf137..06d84693 100644 --- a/platforms/blabsy-w3ds-auth-api/package.json +++ b/platforms/blabsy-w3ds-auth-api/package.json @@ -1,8 +1,8 @@ { "name": "blabsy-w3ds-auth-api", "version": "1.0.0", - "description": "Piqtique Social Media Platform API", - "main": "src/index.ts", + "description": "Web3 Data Sync Authentication API for Blabsy", + "main": "dist/index.js", "scripts": { "start": "ts-node src/index.ts", "dev": "nodemon --exec ts-node src/index.ts", @@ -10,7 +10,8 @@ "typeorm": "typeorm-ts-node-commonjs", "migration:generate": "npm run typeorm migration:generate -- -d src/database/data-source.ts", "migration:run": "npm run typeorm migration:run -- -d src/database/data-source.ts", - "migration:revert": "npm run typeorm migration:revert -- -d src/database/data-source.ts" + "migration:revert": "npm run typeorm migration:revert -- -d src/database/data-source.ts", + "test": "jest" }, "dependencies": { "axios": "^1.6.7", @@ -18,18 +19,20 @@ "dotenv": "^16.4.5", "eventsource-polyfill": "^0.9.6", "express": "^4.18.2", - "firebase-admin": "^13.4.0", + "firebase-admin": "^12.0.0", "jsonwebtoken": "^9.0.2", "pg": "^8.11.3", "reflect-metadata": "^0.2.1", "typeorm": "^0.3.20", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "graphql": "^16.8.1", + "graphql-request": "^6.1.0" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.5", - "@types/node": "^20.11.24", + "@types/node": "^20.11.19", "@types/pg": "^8.11.2", "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^7.0.1", @@ -37,6 +40,10 @@ "eslint": "^8.56.0", "nodemon": "^3.0.3", "ts-node": "^10.9.2", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "ts-jest": "^29.1.2", + "ts-node-dev": "^2.0.0" } } diff --git a/platforms/blabsy-w3ds-auth-api/src/controllers/AuthController.ts b/platforms/blabsy-w3ds-auth-api/src/controllers/AuthController.ts index f398c850..0d1365e8 100644 --- a/platforms/blabsy-w3ds-auth-api/src/controllers/AuthController.ts +++ b/platforms/blabsy-w3ds-auth-api/src/controllers/AuthController.ts @@ -1,7 +1,6 @@ import { Request, Response } from "express"; import { v4 as uuidv4 } from "uuid"; import { EventEmitter } from "events"; -import { applicationDefault, initializeApp } from "firebase-admin/app"; import { auth } from "firebase-admin"; export class AuthController { private eventEmitter: EventEmitter; @@ -57,9 +56,6 @@ export class AuthController { if (!ename) { return res.status(400).json({ error: "ename is required" }); } - initializeApp({ - credential: applicationDefault(), - }); const token = await auth().createCustomToken(ename); console.log(token); diff --git a/platforms/blabsy-w3ds-auth-api/src/controllers/WebhookController.ts b/platforms/blabsy-w3ds-auth-api/src/controllers/WebhookController.ts new file mode 100644 index 00000000..df7083df --- /dev/null +++ b/platforms/blabsy-w3ds-auth-api/src/controllers/WebhookController.ts @@ -0,0 +1,281 @@ +import { Request, Response } from "express"; +import { Web3Adapter } from "../../../../infrastructure/web3-adapter/src/index"; +import path from "path"; +import dotenv from "dotenv"; +import { getFirestore } from "firebase-admin/firestore"; +import { Timestamp } from "firebase-admin/firestore"; + +// Define types locally since we can't import from @blabsy/types +type User = { + id: string; + bio: string | null; + name: string; + theme: string | null; + accent: string | null; + website: string | null; + location: string | null; + username: string; + photoURL: string; + verified: boolean; + following: string[]; + followers: string[]; + createdAt: Timestamp; + updatedAt: Timestamp | null; + totalTweets: number; + totalPhotos: number; + pinnedTweet: string | null; + coverPhotoURL: string | null; +}; + +type Tweet = { + id: string; + text: string | null; + images: any | null; + parent: { id: string; username: string } | null; + userLikes: string[]; + createdBy: string; + createdAt: Timestamp; + updatedAt: Timestamp | null; + userReplies: number; + userRetweets: string[]; +}; + +type Chat = { + id: string; + type: "direct" | "group"; + name?: string; + participants: string[]; + createdAt: Timestamp; + updatedAt: Timestamp; + lastMessage?: { + text: string; + senderId: string; + timestamp: Timestamp; + }; +}; + +type Message = { + id: string; + chatId: string; + senderId: string; + text: string; + createdAt: Timestamp; + updatedAt: Timestamp; + readBy: string[]; +}; + +dotenv.config({ path: path.resolve(__dirname, "../../../../.env") }); + +export const adapter = new Web3Adapter({ + schemasPath: path.resolve(__dirname, "../web3adapter/mappings/"), + dbPath: path.resolve(process.env.BLABSY_MAPPING_DB_PATH as string), + registryUrl: process.env.PUBLIC_REGISTRY_URL as string, +}); + +export class WebhookController { + db: FirebaseFirestore.Firestore; + + constructor() { + this.db = getFirestore(); + // Bind the method to preserve 'this' context + this.handleWebhook = this.handleWebhook.bind(this); + } + + async handleWebhook(req: Request, res: Response) { + try { + const { data, schemaId, id } = req.body; + + const mapping = Object.values(adapter.mapping).find( + (m) => m.schemaId === schemaId, + ); + if (!mapping) throw new Error(); + const tableName = mapping.tableName + "s"; + + const local = await adapter.fromGlobal({ data, mapping }); + + // Get the local ID from the mapping database + const localId = await adapter.mappingDb.getLocalId({ + globalId: id, + tableName, + }); + + if (localId) { + console.log(""); + adapter.addToLockedIds(localId); + await this.updateRecord(tableName, localId, local.data); + } else { + await this.createRecord(tableName, local.data, req.body.id); + } + + res.status(200).json({ success: true }); + } catch (error) { + console.error("Error handling webhook:", error); + res.status(500).json({ error: "Internal server error" }); + } + } + + private async createRecord(tableName: string, data: any, globalId: string) { + if (adapter.lockedIds.includes(globalId)) return; + adapter.addToLockedIds(globalId); + const chatId = data.chatId + ? data.chatId.split("(")[1].split(")")[0] + : null; + + let collection; + if (tableName === "messages" && data.chatId) { + collection = this.db.collection(`chats/${chatId}/messages`); + } else { + collection = this.db.collection(tableName); + } + + const docRef = collection.doc(); + const mappedData = await this.mapDataToFirebase(tableName, data); + await docRef.set(mappedData); + + adapter.addToLockedIds(docRef.id); + adapter.addToLockedIds(globalId); + await adapter.mappingDb.storeMapping({ + globalId: globalId, + localId: docRef.id, + tableName: + tableName === "messages" + ? `chats/${data.chatId}/messages` + : tableName, + }); + } + + private async updateRecord(tableName: string, localId: string, data: any) { + const collection = this.db.collection(tableName); + const docRef = collection.doc(localId); + + adapter.addToLockedIds(docRef.id); + const mappedData = await this.mapDataToFirebase(tableName, data); + await docRef.update(mappedData); + } + + private mapDataToFirebase(tableName: string, data: any): any { + const now = Timestamp.now(); + console.log("MAPPING DATA TO ", tableName); + + switch (tableName) { + case "users": + const result = this.mapUserData(data, now); + console.log("mappppped", result); + return result; + case "tweets": + return this.mapTweetData(data, now); + case "chats": + return this.mapChatData(data, now); + case "messages": + return this.mapMessageData(data, now); + default: + return data; + } + } + + private mapUserData(data: any, now: Timestamp): Partial { + return { + bio: data.bio || null, + name: data.name, + theme: data.theme || null, + accent: data.accent || null, + website: null, + location: null, + username: data.username, + photoURL: data.photoURL, + verified: data.verified || false, + following: data.following || [], + followers: data.followers || [], + createdAt: data.createdAt + ? Timestamp.fromDate(new Date(data.createdAt)) + : now, + updatedAt: now, + totalTweets: data.totalTweets || 0, + totalPhotos: data.totalPhotos || 0, + pinnedTweet: data.pinnedTweet || null, + coverPhotoURL: data.coverPhotoURL || null, + }; + } + + private async mapTweetData( + data: any, + now: Timestamp, + ): Promise> { + let createdBy = data.createdBy; + if (createdBy.includes("(") && createdBy.includes(")")) { + createdBy = createdBy.split("(")[1].split(")")[0]; + } + const filteredResult = {}; + for (const key of Object.keys(data)) { + if (data[key]) { + // @ts-ignore + filteredResult[key] = data[key]; + } + } + const usersCollectionRef = this.db.collection("users"); + const user = (await usersCollectionRef.doc(createdBy).get()).data(); + + const tweetData = { + ...filteredResult, + userLikes: data.userLikes.map((u: string) => { + if (u.includes("(") && u.includes(")")) { + return u.split("(")[1].split(")")[0]; + } else { + return u; + } + }), + createdBy, + images: + (data.images ?? []).map((i: string) => ({ + src: i, + })) || null, + parent: + data.parent && user + ? { + id: data.parent.split("(")[1].split(")")[0], + username: user.username, + } + : null, + createdAt: Timestamp.fromDate(new Date(Date.now())), + userRetweets: [], + userReplies: 0, + }; + return tweetData; + } + + private mapChatData(data: any, now: Timestamp): Partial { + return { + type: data.type || "direct", + name: data.name, + participants: + data.participants.map( + (p: string) => p.split("(")[1].split(")")[0], + ) || [], + createdAt: data.createdAt + ? Timestamp.fromDate(new Date(data.createdAt)) + : now, + updatedAt: now, + lastMessage: data.lastMessage + ? { + ...data.lastMessage, + timestamp: Timestamp.fromDate( + new Date(data.lastMessage.timestamp), + ), + } + : null, + }; + } + + private mapMessageData(data: any, now: Timestamp): Partial { + return { + chatId: data.chatId.split("(")[1].split(")")[0], + senderId: data.senderId.split("(")[1].split(")")[0], + text: data.text, + createdAt: data.createdAt + ? Timestamp.fromDate(new Date(data.createdAt)) + : now, + updatedAt: now, + readBy: data.readBy || [], + }; + } +} diff --git a/platforms/blabsy-w3ds-auth-api/src/index.ts b/platforms/blabsy-w3ds-auth-api/src/index.ts index 9eb37819..263c1f48 100644 --- a/platforms/blabsy-w3ds-auth-api/src/index.ts +++ b/platforms/blabsy-w3ds-auth-api/src/index.ts @@ -4,6 +4,9 @@ import cors from "cors"; import { config } from "dotenv"; import path from "path"; import { AuthController } from "./controllers/AuthController"; +import { initializeApp, cert, applicationDefault } from "firebase-admin/app"; +import { Web3Adapter } from "./web3adapter"; +import { WebhookController } from "./controllers/WebhookController"; config({ path: path.resolve(__dirname, "../../../.env") }); @@ -23,9 +26,33 @@ app.use(express.urlencoded({ limit: "50mb", extended: true })); const authController = new AuthController(); +initializeApp({ + credential: applicationDefault(), +}); + +// Initialize Web3Adapter +const web3Adapter = new Web3Adapter(); + +web3Adapter.initialize().catch((error) => { + console.error("Failed to initialize Web3Adapter:", error); + process.exit(1); +}); + +// Register webhook endpoint + +const webhookController = new WebhookController(); + app.get("/api/auth/offer", authController.getOffer); app.post("/api/auth", authController.login); app.get("/api/auth/sessions/:id", authController.sseStream); +app.post("/api/webhook", webhookController.handleWebhook); + +// Graceful shutdown +process.on("SIGTERM", async () => { + console.log("SIGTERM received. Shutting down..."); + await web3Adapter.shutdown(); + process.exit(0); +}); app.listen(port, () => { console.log(`Server running on port ${port}`); diff --git a/platforms/blabsy-w3ds-auth-api/src/web3adapter/index.ts b/platforms/blabsy-w3ds-auth-api/src/web3adapter/index.ts new file mode 100644 index 00000000..63e962c9 --- /dev/null +++ b/platforms/blabsy-w3ds-auth-api/src/web3adapter/index.ts @@ -0,0 +1,107 @@ +import { getFirestore } from "firebase-admin/firestore"; +import { FirestoreWatcher } from "./watchers/firestoreWatcher"; +import path from "path"; +import dotenv from "dotenv"; + +dotenv.config({ path: path.resolve(__dirname, "../../../../../.env") }); + +export interface Web3AdapterConfig { + registryUrl: string; + webhookSecret: string; + webhookEndpoint: string; + pictiqueWebhookUrl: string; + pictiqueWebhookSecret: string; +} + +export class Web3Adapter { + private readonly db = getFirestore(); + private watchers: Map = new Map(); + + async initialize(): Promise { + console.log("Initializing Web3Adapter..."); + + // Initialize watchers for each collection + const collections = [ + { name: "users", type: "user" }, + { name: "tweets", type: "socialMediaPost" }, + { name: "chats", type: "message" }, + { name: "comments", type: "comment" }, + ]; + + for (const { name, type } of collections) { + try { + console.log(`Setting up watcher for collection: ${name}`); + const collection = this.db.collection(name); + const watcher = new FirestoreWatcher(collection); + await watcher.start(); + this.watchers.set(name, watcher); + console.log(`Successfully set up watcher for ${name}`); + + // Special handling for messages using collection group + if (name === "chats") { + const messagesWatcher = new FirestoreWatcher( + this.db.collectionGroup("messages") + ); + await messagesWatcher.start(); + this.watchers.set("messages", messagesWatcher); + console.log("Successfully set up watcher for all messages"); + } + } catch (error) { + console.error(`Failed to set up watcher for ${name}:`, error); + } + } + + // Set up error handling for watchers + process.on("unhandledRejection", (error) => { + console.error("Unhandled promise rejection in watchers:", error); + // Attempt to restart watchers + this.restartWatchers(); + }); + } + + private async restartWatchers(): Promise { + console.log("Attempting to restart watchers..."); + + // Stop all existing watchers + await this.shutdown(); + + // Wait a bit before restarting + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Reinitialize watchers + await this.initialize(); + } + + async shutdown(): Promise { + console.log("Shutting down Web3Adapter..."); + + // Stop all watchers + const stopPromises = Array.from(this.watchers.values()).map( + async (watcher) => { + try { + await watcher.stop(); + } catch (error) { + console.error("Error stopping watcher:", error); + } + } + ); + + await Promise.all(stopPromises); + this.watchers.clear(); + console.log("All watchers stopped"); + } + + // Method to manually trigger a watcher restart + async restartWatcher(collectionName: string): Promise { + const watcher = this.watchers.get(collectionName); + if (watcher) { + console.log(`Restarting watcher for ${collectionName}...`); + await watcher.stop(); + const collection = this.db.collection(collectionName); + const newWatcher = new FirestoreWatcher(collection); + await newWatcher.start(); + this.watchers.set(collectionName, newWatcher); + console.log(`Successfully restarted watcher for ${collectionName}`); + } + } +} diff --git a/platforms/blabsy-w3ds-auth-api/src/web3adapter/mappings/chat.mapping.json b/platforms/blabsy-w3ds-auth-api/src/web3adapter/mappings/chat.mapping.json new file mode 100644 index 00000000..b3c340c7 --- /dev/null +++ b/platforms/blabsy-w3ds-auth-api/src/web3adapter/mappings/chat.mapping.json @@ -0,0 +1,14 @@ +{ + "tableName": "chat", + "schemaId": "550e8400-e29b-41d4-a716-446655440003", + "participants": "user(participants[])", + "localToUniversalMap": { + "name": "name", + "type": "type", + "participants": "user(participants[]),participantIds", + "lastMessage": "lastMessageId", + "createdAt": "__date(__calc(createdAt._seconds * 1000)),createdAt", + "updatedAt": "__date(__calc(updatedAt._seconds * 1000)),updatedAt", + "isArchived": "isArchived" + } +} diff --git a/platforms/blabsy-w3ds-auth-api/src/web3adapter/mappings/message.mapping.json b/platforms/blabsy-w3ds-auth-api/src/web3adapter/mappings/message.mapping.json new file mode 100644 index 00000000..3d683896 --- /dev/null +++ b/platforms/blabsy-w3ds-auth-api/src/web3adapter/mappings/message.mapping.json @@ -0,0 +1,15 @@ +{ + "tableName": "message", + "schemaId": "550e8400-e29b-41d4-a716-446655440004", + "ownerEnamePath": "user(senderId)", + "localToUniversalMap": { + "chatId": "chat(chatId),chatId", + "senderId": "user(senderId),senderId", + "text": "content", + "type": "type", + "mediaUrl": "mediaUrl", + "createdAt": "__date(calc(createdAt._seconds * 1000)),createdAt", + "updatedAt": "__date(calc(updatedAt._seconds * 1000)),updatedAt", + "isArchived": "isArchived" + } +} diff --git a/platforms/blabsy-w3ds-auth-api/src/web3adapter/mappings/social-media-post.mapping.json b/platforms/blabsy-w3ds-auth-api/src/web3adapter/mappings/social-media-post.mapping.json new file mode 100644 index 00000000..4b0b9fe0 --- /dev/null +++ b/platforms/blabsy-w3ds-auth-api/src/web3adapter/mappings/social-media-post.mapping.json @@ -0,0 +1,14 @@ +{ + "tableName": "tweet", + "schemaId": "550e8400-e29b-41d4-a716-446655440001", + "ownerEnamePath": "user(createdBy)", + "localToUniversalMap": { + "text": "content", + "createdBy": "user(createdBy),authorId", + "images": "images[].src,mediaUrls", + "parent": "tweet(parent.id),parentPostId", + "userLikes": "user(userLikes)[],likedBy", + "createdAt": "__date(calc(createdAt._seconds * 1000)),createdAt", + "updatedAt": "__date(calc(updatedAt._seconds * 1000)),updatedAt" + } +} diff --git a/platforms/blabsy-w3ds-auth-api/src/web3adapter/mappings/user.mapping.json b/platforms/blabsy-w3ds-auth-api/src/web3adapter/mappings/user.mapping.json new file mode 100644 index 00000000..84b03040 --- /dev/null +++ b/platforms/blabsy-w3ds-auth-api/src/web3adapter/mappings/user.mapping.json @@ -0,0 +1,17 @@ +{ + "tableName": "user", + "schemaId": "550e8400-e29b-41d4-a716-446655440000", + "ownerEnamePath": "id", + "localToUniversalMap": { + "bio": "bio", + "username": "username", + "name": "displayName", + "photoURL": "avatarUrl", + "coverPhotoURL": "bannerUrl", + "website": "website", + "location": "location", + "verified": "isVerified", + "createdAt": "__date(calc(createdAt._seconds * 1000)),createdAt", + "updatedAt": "__date(calc(updatedAt._seconds * 1000)),updatedAt" + } +} diff --git a/platforms/blabsy-w3ds-auth-api/src/web3adapter/watchers/firestoreWatcher.ts b/platforms/blabsy-w3ds-auth-api/src/web3adapter/watchers/firestoreWatcher.ts new file mode 100644 index 00000000..e8763daa --- /dev/null +++ b/platforms/blabsy-w3ds-auth-api/src/web3adapter/watchers/firestoreWatcher.ts @@ -0,0 +1,202 @@ +import { + DocumentChange, + DocumentData, + QuerySnapshot, + CollectionReference, + CollectionGroup, +} from "firebase-admin/firestore"; +import path from "path"; +import dotenv from "dotenv"; +import axios from "axios"; +import { adapter } from "../../controllers/WebhookController"; + +dotenv.config({ path: path.resolve(__dirname, "../../../../../.env") }); + +export class FirestoreWatcher { + private unsubscribe: (() => void) | null = null; + private adapter = adapter; + private isProcessing = false; + private retryCount = 0; + private readonly maxRetries: number = 3; + private readonly retryDelay: number = 1000; // 1 second + + constructor( + private readonly collection: + | CollectionReference + | CollectionGroup, + ) {} + + async start(): Promise { + const collectionPath = + this.collection instanceof CollectionReference + ? this.collection.path + : "collection group"; + + try { + // First, get all existing documents + const snapshot = await this.collection.get(); + await this.processSnapshot(snapshot); + + // Then set up real-time listener + this.unsubscribe = this.collection.onSnapshot( + async (snapshot) => { + if (this.isProcessing) { + console.log( + "Still processing previous snapshot, skipping...", + ); + return; + } + + try { + this.isProcessing = true; + await this.processSnapshot(snapshot); + this.retryCount = 0; // Reset retry count on success + } catch (error) { + console.error("Error processing snapshot:", error); + await this.handleError(error); + } finally { + this.isProcessing = false; + } + }, + (error) => { + console.error("Error in Firestore listener:", error); + this.handleError(error); + }, + ); + + console.log(`Successfully started watcher for ${collectionPath}`); + } catch (error) { + console.error( + `Failed to start watcher for ${collectionPath}:`, + error, + ); + throw error; + } + } + + async stop(): Promise { + const collectionPath = + this.collection instanceof CollectionReference + ? this.collection.path + : "collection group"; + console.log(`Stopping watcher for collection: ${collectionPath}`); + + if (this.unsubscribe) { + this.unsubscribe(); + this.unsubscribe = null; + console.log(`Successfully stopped watcher for ${collectionPath}`); + } + } + + private async handleError(error: any): Promise { + if (this.retryCount < this.maxRetries) { + this.retryCount++; + console.log(`Retrying (${this.retryCount}/${this.maxRetries})...`); + await new Promise((resolve) => + setTimeout(resolve, this.retryDelay * this.retryCount), + ); + await this.start(); + } else { + console.error("Max retries reached, stopping watcher"); + await this.stop(); + } + } + + private async processSnapshot(snapshot: QuerySnapshot): Promise { + const changes = snapshot.docChanges(); + const collectionPath = + this.collection instanceof CollectionReference + ? this.collection.path + : "collection group"; + console.log( + `Processing ${changes.length} changes in ${collectionPath}`, + ); + + for (const change of changes) { + const doc = change.doc; + const data = doc.data(); + + try { + switch (change.type) { + case "added": + case "modified": + setTimeout(() => { + console.log( + `${collectionPath} - processing - ${doc.id}`, + ); + if (adapter.lockedIds.includes(doc.id)) return; + this.handleCreateOrUpdate(doc, data); + }, 2_000); + break; + case "removed": + console.log(`Document removed: ${doc.id}`); + // Handle document removal if needed + break; + } + } catch (error) { + console.error( + `Error processing ${change.type} for document ${doc.id}:`, + error, + ); + // Continue processing other changes even if one fails + } + } + } + + private async handleCreateOrUpdate( + doc: FirebaseFirestore.QueryDocumentSnapshot, + data: DocumentData, + ): Promise { + const tableParts = doc.ref.path.split("/"); + // -2 cuz -1 gives last entry and we need second last which would + // be the path specifier + const tableNameRaw = tableParts[tableParts.length - 2]; + + const tableName = tableNameRaw.slice(0, tableNameRaw.length - 1); + + const envelope = await this.adapter + .handleChange({ + data: { ...data, id: doc.id }, + tableName, + }) + .catch(() => null); + + console.log("sending envelope", envelope); + if (envelope) { + try { + if ( + this.adapter.lockedIds.includes(envelope.id) || + this.adapter.lockedIds.includes(doc.id) + ) + return; + const response = await axios + .post( + new URL( + "/api/webhook", + process.env.PUBLIC_PICTIQUE_BASE_URL, + ).toString(), + envelope, + { + headers: { + "Content-Type": "application/json", + "X-Webhook-Source": "blabsy-w3ds-auth-api", + }, + }, + ) + .catch(() => null); + if (!response) + return console.error(`failed to sync ${envelope.id}`); + console.log( + `Successfully forwarded webhook for ${doc.id}:`, + response.status, + ); + } catch (error) { + console.error( + `Failed to forward webhook for ${doc.id}:`, + error, + ); + throw error; // Re-throw to trigger retry mechanism + } + } + } +} diff --git a/platforms/blabsy/.env.development b/platforms/blabsy/.env.development index 9aaca90b..b4608230 100644 --- a/platforms/blabsy/.env.development +++ b/platforms/blabsy/.env.development @@ -1,7 +1,10 @@ # Dev URL -NEXT_PUBLIC_URL=http://localhost +# NEXT_PUBLIC_URL=http://localhost NEXT_PUBLIC_BASE_URL=http://192.168.0.226:4444 +NEXT_PUBLIC_URL=https://blabsy.w3ds-prototype.merul.org +# NEXT_PUBLIC_BASE_URL=https://blabsy.w3ds-prototype.merul.org + # Emulator NEXT_PUBLIC_USE_EMULATOR=false diff --git a/platforms/blabsy/package.json b/platforms/blabsy/package.json index 5d37e2b7..34971e86 100644 --- a/platforms/blabsy/package.json +++ b/platforms/blabsy/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "emulators": "firebase emulators:start --only firestore,auth,storage,functions", - "dev": "next dev -p 80", + "dev": "next dev -p 8080", "dev:emulators": "concurrently npm:dev npm:emulators", "build": "next build", "start": "next start", diff --git a/platforms/blabsy/src/components/modal/mobile-sidebar-modal.tsx b/platforms/blabsy/src/components/modal/mobile-sidebar-modal.tsx index 34f89550..661831df 100644 --- a/platforms/blabsy/src/components/modal/mobile-sidebar-modal.tsx +++ b/platforms/blabsy/src/components/modal/mobile-sidebar-modal.tsx @@ -120,7 +120,7 @@ export function MobileSidebarModal({ {open && ( - - - - - {({ open }): JSX.Element => ( - <> - + + + + + {({ open }): JSX.Element => ( + <> + - - - - - - - - - - - {open && ( - - - - - - - - - - - - - - - {({ active }): JSX.Element => ( - - - Log out @{username} - - )} - - + + + + + + + + + + {open && ( + + + + + + + + + + + + + + + {({ active }): JSX.Element => ( + + + Log out @{username} + + )} + + - - - + > + + + + )} + + > )} - - > - )} - - > + + > ); } diff --git a/platforms/blabsy/src/components/user/user-avatar.tsx b/platforms/blabsy/src/components/user/user-avatar.tsx index 3364bafa..2ebaab20 100644 --- a/platforms/blabsy/src/components/user/user-avatar.tsx +++ b/platforms/blabsy/src/components/user/user-avatar.tsx @@ -3,42 +3,44 @@ import cn from 'clsx'; import { NextImage } from '@components/ui/next-image'; type UserAvatarProps = { - src: string; - alt: string; - size?: number; - username?: string; - className?: string; + src: string; + alt: string; + size?: number; + username?: string; + className?: string; }; export function UserAvatar({ - src, - alt, - size, - username, - className + src, + alt, + size, + username, + className }: UserAvatarProps): JSX.Element { - const pictureSize = size ?? 48; + const pictureSize = size ?? 48; - return ( - - - - - - ); + console.log(typeof src); + + return ( + + + + + + ); } diff --git a/platforms/blabsy/src/lib/types/tweet.ts b/platforms/blabsy/src/lib/types/tweet.ts index d9c408f4..83799206 100644 --- a/platforms/blabsy/src/lib/types/tweet.ts +++ b/platforms/blabsy/src/lib/types/tweet.ts @@ -3,28 +3,28 @@ import type { ImagesPreview } from './file'; import type { User } from './user'; export type Tweet = { - id: string; - text: string | null; - images: ImagesPreview | null; - parent: { id: string; username: string } | null; - userLikes: string[]; - createdBy: string; - createdAt: Timestamp; - updatedAt: Timestamp | null; - userReplies: number; - userRetweets: string[]; + id: string; + text: string | null; + images: ImagesPreview | null; + parent: { id: string; username: string } | null; + userLikes: string[]; + createdBy: string; + createdAt: Timestamp; + updatedAt: Timestamp | null; + userReplies: number; + userRetweets: string[]; }; export type TweetWithUser = Tweet & { user: User }; export const tweetConverter: FirestoreDataConverter = { - toFirestore(tweet) { - return { ...tweet }; - }, - fromFirestore(snapshot, options) { - const { id } = snapshot; - const data = snapshot.data(options); + toFirestore(tweet) { + return { ...tweet }; + }, + fromFirestore(snapshot, options) { + const { id } = snapshot; + const data = snapshot.data(options); - return { id, ...data } as Tweet; - } + return { id, ...data } as Tweet; + } }; diff --git a/platforms/metagram/project.inlang/.gitignore b/platforms/metagram/project.inlang/.gitignore new file mode 100644 index 00000000..5e465967 --- /dev/null +++ b/platforms/metagram/project.inlang/.gitignore @@ -0,0 +1 @@ +cache \ No newline at end of file diff --git a/platforms/metagram/project.inlang/project_id b/platforms/metagram/project.inlang/project_id new file mode 100644 index 00000000..66ac0986 --- /dev/null +++ b/platforms/metagram/project.inlang/project_id @@ -0,0 +1 @@ +1lExt3FnvpxOpPeeZE \ No newline at end of file diff --git a/platforms/metagram/project.inlang/settings.json b/platforms/metagram/project.inlang/settings.json new file mode 100644 index 00000000..dc73166c --- /dev/null +++ b/platforms/metagram/project.inlang/settings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://inlang.com/schema/project-settings", + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js" + ], + "plugin.inlang.messageFormat": { + "pathPattern": "../messages/{locale}.json" + }, + "baseLocale": "en", + "locales": ["en", "es"] +} diff --git a/platforms/pictique-api/package.json b/platforms/pictique-api/package.json index 8a1fe477..865b2dde 100644 --- a/platforms/pictique-api/package.json +++ b/platforms/pictique-api/package.json @@ -18,10 +18,11 @@ "dotenv": "^16.4.5", "eventsource-polyfill": "^0.9.6", "express": "^4.18.2", + "graphql-request": "^6.1.0", "jsonwebtoken": "^9.0.2", "pg": "^8.11.3", "reflect-metadata": "^0.2.1", - "typeorm": "^0.3.20", + "typeorm": "^0.3.24", "uuid": "^9.0.1" }, "devDependencies": { diff --git a/platforms/pictique-api/src/controllers/MessageController.ts b/platforms/pictique-api/src/controllers/MessageController.ts index 4f37308c..0dd81648 100644 --- a/platforms/pictique-api/src/controllers/MessageController.ts +++ b/platforms/pictique-api/src/controllers/MessageController.ts @@ -23,7 +23,7 @@ export class MessageController { ]; const chat = await this.chatService.createChat( name, - allParticipants, + allParticipants ); res.status(201).json(chat); } catch (error) { @@ -60,7 +60,7 @@ export class MessageController { chatId, userId, page, - limit, + limit ); res.json({ @@ -89,7 +89,7 @@ export class MessageController { const result = await this.chatService.getUserChats( userId, page, - limit, + limit ); // Transform the response to include only necessary data @@ -129,7 +129,7 @@ export class MessageController { const chat = await this.chatService.addParticipants( chatId, - participantIds, + participantIds ); res.json(chat); } catch (error) { @@ -149,7 +149,7 @@ export class MessageController { const chat = await this.chatService.removeParticipant( chatId, - userId, + userId ); res.json(chat); } catch (error) { @@ -168,12 +168,11 @@ export class MessageController { if (!userId) { return res.status(401).json({ error: "Unauthorized" }); } - console.log("asdfasd"); const message = await this.chatService.sendMessage( chatId, userId, - text, + text ); res.status(201).json(message); } catch (error) { @@ -197,7 +196,7 @@ export class MessageController { chatId, userId, page, - limit, + limit ); res.json(result); } catch (error) { @@ -251,7 +250,7 @@ export class MessageController { const count = await this.chatService.getUnreadMessageCount( chatId, - userId, + userId ); res.json({ count }); } catch (error) { @@ -310,7 +309,7 @@ export class MessageController { chatId, userId, page, - limit, + limit ); // Send initial connection message @@ -319,8 +318,10 @@ export class MessageController { // Create event listener for this chat const eventEmitter = this.chatService.getEventEmitter(); const eventName = `chat:${chatId}`; + console.log("listening for", eventName); const messageHandler = (data: any) => { + console.log("received event to send", data); res.write(`data: ${JSON.stringify(data)}\n\n`); }; diff --git a/platforms/pictique-api/src/controllers/PostController.ts b/platforms/pictique-api/src/controllers/PostController.ts index d33bb565..f754323e 100644 --- a/platforms/pictique-api/src/controllers/PostController.ts +++ b/platforms/pictique-api/src/controllers/PostController.ts @@ -22,7 +22,7 @@ export class PostController { const feed = await this.postService.getFollowingFeed( userId, page, - limit, + limit ); res.json(feed); } catch (error) { @@ -63,7 +63,6 @@ export class PostController { } const post = await this.postService.toggleLike(postId, userId); - console.log(post); res.json(post); } catch (error) { console.error("Error toggling like:", error); diff --git a/platforms/pictique-api/src/controllers/UserController.ts b/platforms/pictique-api/src/controllers/UserController.ts index f10ef9f0..fc59495a 100644 --- a/platforms/pictique-api/src/controllers/UserController.ts +++ b/platforms/pictique-api/src/controllers/UserController.ts @@ -81,10 +81,12 @@ export class UserController { return res.status(401).json({ error: "Unauthorized" }); } + const user = await this.userService.findById(userId); + const updatedUser = await this.userService.updateProfile(userId, { - handle, - avatarUrl: avatar, - name + handle: handle ?? user?.handle, + avatarUrl: avatar ?? user?.avatarUrl, + name: name ?? user?.name, }); res.json(updatedUser); diff --git a/platforms/pictique-api/src/controllers/WebhookController.ts b/platforms/pictique-api/src/controllers/WebhookController.ts new file mode 100644 index 00000000..cfad6855 --- /dev/null +++ b/platforms/pictique-api/src/controllers/WebhookController.ts @@ -0,0 +1,287 @@ +import { Request, Response } from "express"; +import { UserService } from "../services/UserService"; +import { ChatService } from "../services/ChatService"; +import { PostService } from "../services/PostService"; +import { CommentService } from "../services/CommentService"; +import { Web3Adapter } from "../../../../infrastructure/web3-adapter/src"; +import { User } from "database/entities/User"; +import { Chat } from "database/entities/Chat"; +import { MessageService } from "../services/MessageService"; +import { Post } from "database/entities/Post"; + +export class WebhookController { + userService: UserService; + chatService: ChatService; + postsService: PostService; + commentService: CommentService; + adapter: Web3Adapter; + messageService: MessageService; + + constructor(adapter: Web3Adapter) { + this.userService = new UserService(); + this.chatService = new ChatService(); + this.postsService = new PostService(); + this.commentService = new CommentService(); + this.adapter = adapter; + this.messageService = new MessageService(); + } + + handleWebhook = async (req: Request, res: Response) => { + try { + const schemaId = req.body.schemaId; + const globalId = req.body.id; + const mapping = Object.values(this.adapter.mapping).find( + (m) => m.schemaId === schemaId, + ); + + if (!mapping) throw new Error(); + const local = await this.adapter.fromGlobal({ + data: req.body.data, + mapping, + }); + + mapping.tableName = + mapping.tableName === "comments" ? "posts" : mapping.tableName; + let localId = await this.adapter.mappingDb.getLocalId({ + globalId, + tableName: mapping.tableName, + }); + + if (mapping.tableName === "users") { + const { user } = await this.userService.findOrCreateUser( + req.body.w3id, + ); + for (const key of Object.keys(local.data)) { + // @ts-ignore + user[key] = local.data[key]; + } + await this.userService.userRepository.save(user); + await this.adapter.mappingDb.storeMapping({ + localId: user.id, + globalId: req.body.id, + tableName: mapping.tableName, + }); + this.adapter.addToLockedIds(user.id); + } else if (mapping.tableName === "posts") { + let author: User | null = null; + if (local.data.author) { + const authorId = local.data.author + // @ts-ignore + .split("(")[1] + .split(")")[0]; + author = await this.userService.findById(authorId); + console.log("nuh uh not here", author); + } + let likedBy: User[] = []; + if (local.data.likedBy && Array.isArray(local.data.likedBy)) { + const likedByPromises = local.data.likedBy.map( + async (ref: string) => { + if (ref && typeof ref === "string") { + const userId = ref.split("(")[1].split(")")[0]; + return await this.userService.findById(userId); + } + return null; + }, + ); + likedBy = (await Promise.all(likedByPromises)).filter( + (user): user is User => user !== null, + ); + } + + if (local.data.parentPostId) { + const parentId = (local.data.parentPostId as string) + .split("(")[1] + .split(")")[0]; + const parent = await this.postsService.findById(parentId); + if (localId) { + const comment = + await this.commentService.getCommentById(localId); + if (!comment) return; + comment.text = local.data.text as string; + comment.likedBy = likedBy as User[]; + comment.author = author as User; + comment.post = parent as Post; + await this.commentService.commentRepository.save( + comment, + ); + } else { + const comment = await this.commentService.createComment( + parent?.id as string, + author?.id as string, + local.data.text as string, + ); + localId = comment.id; + await this.adapter.mappingDb.storeMapping({ + localId, + globalId, + tableName: mapping.tableName, + }); + } + this.adapter.addToLockedIds(localId); + } else { + let likedBy: User[] = []; + if ( + local.data.likedBy && + Array.isArray(local.data.likedBy) + ) { + const likedByPromises = local.data.likedBy.map( + async (ref: string) => { + if (ref && typeof ref === "string") { + const userId = ref + .split("(")[1] + .split(")")[0]; + return await this.userService.findById( + userId, + ); + } + return null; + }, + ); + likedBy = (await Promise.all(likedByPromises)).filter( + (user): user is User => user !== null, + ); + } + + if (localId) { + const post = await this.postsService.findById(localId); + if (!post) return res.status(500).send(); + for (const key of Object.keys(local.data)) { + // @ts-ignore + post[key] = local.data[key]; + } + post.likedBy = likedBy; + // @ts-ignore + post.author = author ?? undefined; + + this.adapter.addToLockedIds(localId); + await this.postsService.postRepository.save(post); + } else { + console.log("Creating new post"); + const post = await this.postsService.createPost( + author?.id as string, + // @ts-ignore + { + ...local.data, + likedBy, + }, + ); + + this.adapter.addToLockedIds(post.id); + await this.adapter.mappingDb.storeMapping({ + localId: post.id, + globalId, + tableName: mapping.tableName, + }); + + // Verify the mapping was stored + const verifyLocalId = + await this.adapter.mappingDb.getLocalId({ + globalId, + tableName: mapping.tableName, + }); + console.log("Verified mapping:", { + expected: post.id, + actual: verifyLocalId, + }); + } + } + } else if (mapping.tableName === "chats") { + let participants: User[] = []; + if ( + local.data.participants && + Array.isArray(local.data.participants) + ) { + const participantPromises = local.data.participants.map( + async (ref: string) => { + if (ref && typeof ref === "string") { + const userId = ref.split("(")[1].split(")")[0]; + return await this.userService.findById(userId); + } + return null; + }, + ); + participants = ( + await Promise.all(participantPromises) + ).filter((user): user is User => user !== null); + } + + if (localId) { + const chat = await this.chatService.findById(localId); + if (!chat) return res.status(500).send(); + + chat.name = local.data.name as string; + chat.participants = participants; + + this.adapter.addToLockedIds(localId); + await this.chatService.chatRepository.save(chat); + } else { + const chat = await this.chatService.createChat( + local.data.name as string, + participants.map((p) => p.id), + ); + + this.adapter.addToLockedIds(chat.id); + await this.adapter.mappingDb.storeMapping({ + localId: chat.id, + globalId: req.body.id, + tableName: mapping.tableName, + }); + } + } else if (mapping.tableName === "messages") { + console.log("messages"); + console.log(local.data); + let sender: User | null = null; + if ( + local.data.sender && + typeof local.data.sender === "string" + ) { + const senderId = local.data.sender + .split("(")[1] + .split(")")[0]; + sender = await this.userService.findById(senderId); + } + + let chat: Chat | null = null; + if (local.data.chat && typeof local.data.chat === "string") { + const chatId = local.data.chat.split("(")[1].split(")")[0]; + chat = await this.chatService.findById(chatId); + } + + if (!sender || !chat) { + console.log(local.data); + console.log("Missing sender or chat for message"); + return res.status(400).send(); + } + + if (localId) { + console.log("Updating existing message"); + const message = await this.messageService.findById(localId); + if (!message) return res.status(500).send(); + + message.text = local.data.text as string; + message.sender = sender; + message.chat = chat; + + this.adapter.addToLockedIds(localId); + await this.messageService.messageRepository.save(message); + } else { + const message = await this.chatService.sendMessage( + chat.id, + sender.id, + local.data.text as string, + ); + + this.adapter.addToLockedIds(message.id); + await this.adapter.mappingDb.storeMapping({ + localId: message.id, + globalId: req.body.id, + tableName: mapping.tableName, + }); + } + } + res.status(200).send(); + } catch (e) { + console.error(e); + } + }; +} diff --git a/platforms/pictique-api/src/database/data-source.ts b/platforms/pictique-api/src/database/data-source.ts index afde67f9..879fe0c2 100644 --- a/platforms/pictique-api/src/database/data-source.ts +++ b/platforms/pictique-api/src/database/data-source.ts @@ -8,6 +8,7 @@ import { Message } from "./entities/Message"; import path from "path"; import { Chat } from "./entities/Chat"; import { MessageReadStatus } from "./entities/MessageReadStatus"; +import { PostgresSubscriber } from "../web3adapter/watchers/subscriber"; config({ path: path.resolve(__dirname, "../../../../.env") }); @@ -18,5 +19,5 @@ export const AppDataSource = new DataSource({ logging: process.env.NODE_ENV === "development", entities: [User, Post, Comment, Message, Chat, MessageReadStatus], migrations: ["src/database/migrations/*.ts"], - subscribers: [], + subscribers: [PostgresSubscriber], }); diff --git a/platforms/pictique-api/src/database/migrations/1749561069022-migration.ts b/platforms/pictique-api/src/database/migrations/1749561069022-migration.ts new file mode 100644 index 00000000..33d26378 --- /dev/null +++ b/platforms/pictique-api/src/database/migrations/1749561069022-migration.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Migration1749561069022 implements MigrationInterface { + name = 'Migration1749561069022' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "__web3_id_mapping" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "localId" character varying NOT NULL, "metaEnvelopeId" character varying NOT NULL, "entityType" character varying NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_4c57c87c4ee60f42d9c6b0861c2" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_e6df8ee410baeffd472e93cdd2" ON "__web3_id_mapping" ("localId") `); + await queryRunner.query(`CREATE INDEX "IDX_9bdab2968d15942d3e3187a620" ON "__web3_id_mapping" ("metaEnvelopeId") `); + await queryRunner.query(`CREATE INDEX "IDX_f62e57b7b9f593f2e1715912c9" ON "__web3_id_mapping" ("entityType") `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_f62e57b7b9f593f2e1715912c9"`); + await queryRunner.query(`DROP INDEX "public"."IDX_9bdab2968d15942d3e3187a620"`); + await queryRunner.query(`DROP INDEX "public"."IDX_e6df8ee410baeffd472e93cdd2"`); + await queryRunner.query(`DROP TABLE "__web3_id_mapping"`); + } + +} diff --git a/platforms/pictique-api/src/index.ts b/platforms/pictique-api/src/index.ts index ff233dc6..78f3721f 100644 --- a/platforms/pictique-api/src/index.ts +++ b/platforms/pictique-api/src/index.ts @@ -10,47 +10,59 @@ import { CommentController } from "./controllers/CommentController"; import { MessageController } from "./controllers/MessageController"; import { authMiddleware, authGuard } from "./middleware/auth"; import { UserController } from "./controllers/UserController"; +import { WebhookController } from "./controllers/WebhookController"; +import { adapter } from "./web3adapter/watchers/subscriber"; config({ path: path.resolve(__dirname, "../../../.env") }); const app = express(); const port = process.env.PORT || 3000; +// Initialize database connection and adapter +AppDataSource.initialize() + .then(async () => { + console.log("Database connection established"); + console.log("Web3 adapter initialized"); + }) + .catch((error: any) => { + console.error("Error during initialization:", error); + process.exit(1); + }); + // Middleware app.use( cors({ origin: "*", methods: ["GET", "POST", "OPTIONS", "PATCH", "DELETE"], - allowedHeaders: ["Content-Type", "Authorization"], + allowedHeaders: [ + "Content-Type", + "Authorization", + "X-Webhook-Signature", + "X-Webhook-Timestamp", + ], credentials: true, }), ); app.use(express.json({ limit: "50mb" })); app.use(express.urlencoded({ limit: "50mb", extended: true })); -// Initialize database connection -AppDataSource.initialize() - .then(() => { - console.log("Database connection established"); - }) - .catch((error) => { - console.error("Error connecting to database:", error); - process.exit(1); - }); - // Controllers const postController = new PostController(); const authController = new AuthController(); const commentController = new CommentController(); const messageController = new MessageController(); const userController = new UserController(); +const webhookController = new WebhookController(adapter); + +// Webhook route (no auth required) +// app.post("/api/webhook", adapter.webhookHandler.handleWebhook); // Public routes (no auth required) app.get("/api/auth/offer", authController.getOffer); -app.get("/api/auth/offerb", authController.getOfferBlab); app.post("/api/auth", authController.login); app.get("/api/auth/sessions/:id", authController.sseStream); app.get("/api/chats/:chatId/events", messageController.getChatEvents); +app.post("/api/webhook", webhookController.handleWebhook); // Protected routes (auth required) app.use(authMiddleware); // Apply auth middleware to all routes below diff --git a/platforms/pictique-api/src/middleware/auth.ts b/platforms/pictique-api/src/middleware/auth.ts index fdace1d2..51346556 100644 --- a/platforms/pictique-api/src/middleware/auth.ts +++ b/platforms/pictique-api/src/middleware/auth.ts @@ -11,7 +11,7 @@ export const authMiddleware = async ( try { const authHeader = req.headers.authorization; if (!authHeader?.startsWith("Bearer ")) { - return res.status(401).json({ error: "No token provided" }); + return next(); } const token = authHeader.split(" ")[1]; @@ -29,7 +29,6 @@ export const authMiddleware = async ( } req.user = user; - console.log("user", user.ename); next(); } catch (error) { console.error("Auth middleware error:", error); @@ -43,4 +42,3 @@ export const authGuard = (req: Request, res: Response, next: NextFunction) => { } next(); }; - diff --git a/platforms/pictique-api/src/services/ChatService.ts b/platforms/pictique-api/src/services/ChatService.ts index 1deb0ae6..01ac9578 100644 --- a/platforms/pictique-api/src/services/ChatService.ts +++ b/platforms/pictique-api/src/services/ChatService.ts @@ -5,14 +5,19 @@ import { User } from "../database/entities/User"; import { MessageReadStatus } from "../database/entities/MessageReadStatus"; import { In } from "typeorm"; import { EventEmitter } from "events"; +import { emitter } from "./event-emitter"; export class ChatService { - private chatRepository = AppDataSource.getRepository(Chat); + public chatRepository = AppDataSource.getRepository(Chat); private messageRepository = AppDataSource.getRepository(Message); private userRepository = AppDataSource.getRepository(User); private messageReadStatusRepository = AppDataSource.getRepository(MessageReadStatus); - private eventEmitter = new EventEmitter(); + private eventEmitter: EventEmitter; + + constructor() { + this.eventEmitter = emitter; + } // Event emitter getter getEventEmitter(): EventEmitter { @@ -22,7 +27,7 @@ export class ChatService { // Chat CRUD Operations async createChat( name?: string, - participantIds: string[] = [], + participantIds: string[] = [] ): Promise { const participants = await this.userRepository.findBy({ id: In(participantIds), @@ -70,7 +75,7 @@ export class ChatService { // Participant Operations async addParticipants( chatId: string, - participantIds: string[], + participantIds: string[] ): Promise { const chat = await this.getChatById(chatId); if (!chat) { @@ -102,7 +107,7 @@ export class ChatService { async sendMessage( chatId: string, senderId: string, - text: string, + text: string ): Promise { const chat = await this.getChatById(chatId); if (!chat) { @@ -126,8 +131,9 @@ export class ChatService { }); const savedMessage = await this.messageRepository.save(message); + console.log("Sent event", `chat:${chatId}`); + this.eventEmitter.emit(`chat:${chatId}`, [savedMessage]); - // Create read status entries for all participants except sender const readStatuses = chat.participants .filter((p) => p.id !== senderId) .map((participant) => @@ -135,13 +141,12 @@ export class ChatService { message: savedMessage, user: participant, isRead: false, - }), + }) ); - await this.messageReadStatusRepository.save(readStatuses); - - // Emit new message event - this.eventEmitter.emit(`chat:${chatId}`, [savedMessage]); + await this.messageReadStatusRepository + .save(readStatuses) + .catch(() => null); return savedMessage; } @@ -150,7 +155,7 @@ export class ChatService { chatId: string, userId: string, page: number = 1, - limit: number = 20, + limit: number = 20 ): Promise<{ messages: Message[]; total: number; @@ -201,7 +206,9 @@ export class ChatService { .createQueryBuilder() .update(MessageReadStatus) .set({ isRead: true }) - .where("message.id IN (:...messageIds)", { messageIds: messageIds.map(m => m.id) }) + .where("message.id IN (:...messageIds)", { + messageIds: messageIds.map((m) => m.id), + }) .andWhere("user.id = :userId", { userId }) .andWhere("isRead = :isRead", { isRead: false }) .execute(); @@ -228,7 +235,7 @@ export class ChatService { async getUserChats( userId: string, page: number = 1, - limit: number = 10, + limit: number = 10 ): Promise<{ chats: (Chat & { latestMessage?: { text: string; isRead: boolean } })[]; total: number; @@ -291,7 +298,7 @@ export class ChatService { async getUnreadMessageCount( chatId: string, - userId: string, + userId: string ): Promise { return await this.messageReadStatusRepository.count({ where: { @@ -301,4 +308,11 @@ export class ChatService { }, }); } + + async findById(id: string): Promise { + return await this.chatRepository.findOne({ + where: { id }, + relations: ["participants"], + }); + } } diff --git a/platforms/pictique-api/src/services/CommentService.ts b/platforms/pictique-api/src/services/CommentService.ts index e994aa6c..6f2f6e68 100644 --- a/platforms/pictique-api/src/services/CommentService.ts +++ b/platforms/pictique-api/src/services/CommentService.ts @@ -3,19 +3,23 @@ import { Comment } from "../database/entities/Comment"; import { Post } from "../database/entities/Post"; export class CommentService { - private commentRepository = AppDataSource.getRepository(Comment); + commentRepository = AppDataSource.getRepository(Comment); private postRepository = AppDataSource.getRepository(Post); - async createComment(postId: string, authorId: string, text: string): Promise { + async createComment( + postId: string, + authorId: string, + text: string + ): Promise { const post = await this.postRepository.findOneBy({ id: postId }); if (!post) { - throw new Error('Post not found'); + throw new Error("Post not found"); } const comment = this.commentRepository.create({ text, author: { id: authorId }, - post: { id: postId } + post: { id: postId }, }); return await this.commentRepository.save(comment); @@ -24,22 +28,22 @@ export class CommentService { async getPostComments(postId: string): Promise { return await this.commentRepository.find({ where: { post: { id: postId } }, - relations: ['author'], - order: { createdAt: 'DESC' } + relations: ["author"], + order: { createdAt: "DESC" }, }); } async getCommentById(id: string): Promise { return await this.commentRepository.findOne({ where: { id }, - relations: ['author'] + relations: ["author"], }); } async updateComment(id: string, text: string): Promise { const comment = await this.getCommentById(id); if (!comment) { - throw new Error('Comment not found'); + throw new Error("Comment not found"); } comment.text = text; @@ -49,9 +53,10 @@ export class CommentService { async deleteComment(id: string): Promise { const comment = await this.getCommentById(id); if (!comment) { - throw new Error('Comment not found'); + throw new Error("Comment not found"); } await this.commentRepository.softDelete(id); } -} \ No newline at end of file +} + diff --git a/platforms/pictique-api/src/services/MessageService.ts b/platforms/pictique-api/src/services/MessageService.ts new file mode 100644 index 00000000..5d385439 --- /dev/null +++ b/platforms/pictique-api/src/services/MessageService.ts @@ -0,0 +1,21 @@ +import { AppDataSource } from "../database/data-source"; +import { Message } from "../database/entities/Message"; + +export class MessageService { + public messageRepository = AppDataSource.getRepository(Message); + + async findById(id: string): Promise { + return await this.messageRepository.findOneBy({ id }); + } + + async createMessage(senderId: string, chatId: string, text: string): Promise { + const message = this.messageRepository.create({ + sender: { id: senderId }, + chat: { id: chatId }, + text, + isArchived: false + }); + + return await this.messageRepository.save(message); + } +} \ No newline at end of file diff --git a/platforms/pictique-api/src/services/PostService.ts b/platforms/pictique-api/src/services/PostService.ts index 47048a00..5e23cd89 100644 --- a/platforms/pictique-api/src/services/PostService.ts +++ b/platforms/pictique-api/src/services/PostService.ts @@ -7,12 +7,17 @@ interface CreatePostData { text: string; images?: string[]; hashtags?: string[]; + likedBy?: User[]; } export class PostService { - private postRepository = AppDataSource.getRepository(Post); + postRepository = AppDataSource.getRepository(Post); private userRepository = AppDataSource.getRepository(User); + async findById(id: string) { + return await this.postRepository.findOneBy({ id }); + } + async getFollowingFeed(userId: string, page: number, limit: number) { const user = await this.userRepository.findOne({ where: { id: userId }, @@ -28,7 +33,6 @@ export class PostService { const [posts, total] = await this.postRepository.findAndCount({ where: { - author: { id: In(authorIds) }, isArchived: false, }, relations: ["author", "likedBy", "comments", "comments.author"], @@ -58,7 +62,7 @@ export class PostService { text: data.text, images: data.images || [], hashtags: data.hashtags || [], - likedBy: [], + likedBy: data.likedBy, }); return await this.postRepository.save(post); diff --git a/platforms/pictique-api/src/services/UserService.ts b/platforms/pictique-api/src/services/UserService.ts index 6f68ec2f..945d3e1e 100644 --- a/platforms/pictique-api/src/services/UserService.ts +++ b/platforms/pictique-api/src/services/UserService.ts @@ -5,7 +5,7 @@ import { signToken } from "../utils/jwt"; import { Like } from "typeorm"; export class UserService { - private userRepository = AppDataSource.getRepository(User); + userRepository = AppDataSource.getRepository(User); private postRepository = AppDataSource.getRepository(Post); async createBlankUser(ename: string): Promise { diff --git a/platforms/pictique-api/src/services/event-emitter.ts b/platforms/pictique-api/src/services/event-emitter.ts new file mode 100644 index 00000000..320b7406 --- /dev/null +++ b/platforms/pictique-api/src/services/event-emitter.ts @@ -0,0 +1,3 @@ +import { EventEmitter } from "events"; + +export const emitter = new EventEmitter(); diff --git a/platforms/pictique-api/src/web3adapter/config/index.ts b/platforms/pictique-api/src/web3adapter/config/index.ts deleted file mode 100644 index f056e6da..00000000 --- a/platforms/pictique-api/src/web3adapter/config/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -export const web3AdapterConfig = { - // Registry URL for resolving ename to eVault URLs - registryUrl: process.env.REGISTRY_URL || "http://localhost:4000", - - // Webhook configuration - webhook: { - // The URL where this adapter will receive updates from other platforms - receiveUrl: process.env.WEB3_ADAPTER_WEBHOOK_URL || "http://localhost:4444/web3adapter/webhook", - // Secret for webhook signature verification - secret: process.env.WEB3_ADAPTER_WEBHOOK_SECRET || "your-webhook-secret" - }, - - // eVault configuration - eVault: { - // GraphQL endpoint for eVault operations - graphqlUrl: process.env.EVAULT_GRAPHQL_URL || "http://localhost:4000/graphql", - // Default ACL for stored envelopes - defaultAcl: process.env.EVAULT_DEFAULT_ACL ? - process.env.EVAULT_DEFAULT_ACL.split(",") : - ["@d1fa5cb1-6178-534b-a096-59794d485f65"] - }, - - // Entity type mappings to global ontology - entityMappings: { - User: "UserProfile", - Post: "SocialMediaPost", - Comment: "SocialMediaPost", - Chat: "Chat", - Message: "Message", - MessageReadStatus: "MessageReadStatus" - } -} as const; \ No newline at end of file diff --git a/platforms/pictique-api/src/web3adapter/controllers/WebhookController.ts b/platforms/pictique-api/src/web3adapter/controllers/WebhookController.ts deleted file mode 100644 index 7a5eb553..00000000 --- a/platforms/pictique-api/src/web3adapter/controllers/WebhookController.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { Request, Response } from "express"; -import { TransformService } from "../services/TransformService"; -import { web3AdapterConfig } from "../config"; -import { EntityType, WebhookPayload, TransformContext } from "../types"; -import { AppDataSource } from "../../database/data-source"; -import { User } from "../../database/entities/User"; -import { Post } from "../../database/entities/Post"; -import { Comment } from "../../database/entities/Comment"; -import { Chat } from "../../database/entities/Chat"; -import { Message } from "../../database/entities/Message"; -import { MessageReadStatus } from "../../database/entities/MessageReadStatus"; -import { In } from "typeorm"; - -export class WebhookController { - private userRepository = AppDataSource.getRepository(User); - private postRepository = AppDataSource.getRepository(Post); - private commentRepository = AppDataSource.getRepository(Comment); - private chatRepository = AppDataSource.getRepository(Chat); - private messageRepository = AppDataSource.getRepository(Message); - private messageReadStatusRepository = AppDataSource.getRepository(MessageReadStatus); - - async handleWebhook(req: Request, res: Response) { - const payload = req.body as WebhookPayload; - console.log("Received webhook payload:", payload); - - // For now, we're not verifying signatures - // TODO: Implement signature verification - - try { - const entityType = this.getEntityTypeFromGlobal(payload.entityType); - if (!entityType) { - throw new Error(`Unsupported global ontology type: ${payload.entityType}`); - } - - const transformContext: TransformContext = { - platform: "blabsy", - entityType, - internalId: payload.metaEnvelopeId - }; - - const platformData = TransformService.getInstance().fromGlobalOntology( - entityType, - payload.payload, - transformContext - ); - - switch (payload.operation) { - case "create": - await this.handleCreate(entityType, platformData); - break; - case "update": - await this.handleUpdate(entityType, platformData); - break; - case "delete": - await this.handleDelete(entityType, platformData); - break; - default: - throw new Error(`Unsupported operation: ${payload.operation}`); - } - - res.status(200).json({ success: true }); - } catch (error) { - console.error("Error handling webhook:", error); - res.status(500).json({ error: (error as Error).message }); - } - } - - private getEntityTypeFromGlobal(globalType: string): EntityType | null { - const mapping = Object.entries(web3AdapterConfig.entityMappings).find( - ([_, value]) => value === globalType - ); - return mapping ? (mapping[0] as EntityType) : null; - } - - private async handleCreate(entityType: EntityType, data: any) { - switch (entityType) { - case "User": { - const user = this.userRepository.create({ - ename: data.ename, - handle: data.username, - name: data.displayName, - description: data.bio, - avatarUrl: data.avatarUrl - }); - await this.userRepository.save(user); - break; - } - case "Post": { - const author = await this.userRepository.findOneBy({ ename: data.authorEname }); - if (!author) throw new Error(`Author not found: ${data.authorEname}`); - - const post = this.postRepository.create({ - text: data.content, - images: data.images, - hashtags: data.hashtags, - author - }); - await this.postRepository.save(post); - break; - } - case "Comment": { - const author = await this.userRepository.findOneBy({ ename: data.authorEname }); - if (!author) throw new Error(`Author not found: ${data.authorEname}`); - - // Find the parent post by its meta envelope ID - const parentPost = await this.postRepository.findOne({ - where: { id: data.parentId }, - relations: ["author"] - }); - if (!parentPost) throw new Error(`Parent post not found: ${data.parentId}`); - - const comment = this.commentRepository.create({ - text: data.content, - author, - post: parentPost - }); - await this.commentRepository.save(comment); - break; - } - case "Chat": { - const participants = await this.userRepository.findBy({ - ename: In(data.participants) - }); - if (participants.length === 0) { - throw new Error("No participants found"); - } - - const chat = this.chatRepository.create({ - name: data.name, - participants - }); - await this.chatRepository.save(chat); - break; - } - case "Message": { - const sender = await this.userRepository.findOneBy({ ename: data.authorEname }); - if (!sender) throw new Error(`Sender not found: ${data.authorEname}`); - - const chat = await this.chatRepository.findOneBy({ id: data.chatId }); - if (!chat) throw new Error(`Chat not found: ${data.chatId}`); - - const message = this.messageRepository.create({ - text: data.content, - sender, - chat - }); - await this.messageRepository.save(message); - - // Create read statuses for all chat participants except the sender - const readStatuses = chat.participants - .filter(p => p.ename !== data.authorEname) - .map(participant => - this.messageReadStatusRepository.create({ - message, - user: participant, - isRead: false - }) - ); - await this.messageReadStatusRepository.save(readStatuses); - break; - } - case "MessageReadStatus": { - const message = await this.messageRepository.findOneBy({ id: data.messageId }); - if (!message) throw new Error(`Message not found: ${data.messageId}`); - - const user = await this.userRepository.findOneBy({ ename: data.userEname }); - if (!user) throw new Error(`User not found: ${data.userEname}`); - - const readStatus = this.messageReadStatusRepository.create({ - message, - user, - isRead: data.isRead - }); - await this.messageReadStatusRepository.save(readStatus); - break; - } - } - } - - private async handleUpdate(entityType: EntityType, data: any) { - switch (entityType) { - case "User": { - const user = await this.userRepository.findOneBy({ ename: data.ename }); - if (!user) throw new Error(`User not found: ${data.ename}`); - - Object.assign(user, { - handle: data.username, - name: data.displayName, - description: data.bio, - avatarUrl: data.avatarUrl - }); - await this.userRepository.save(user); - break; - } - case "Post": { - const post = await this.postRepository.findOneBy({ id: data.id }); - if (!post) throw new Error(`Post not found: ${data.id}`); - - Object.assign(post, { - text: data.content, - images: data.images, - hashtags: data.hashtags - }); - await this.postRepository.save(post); - break; - } - case "Comment": { - const comment = await this.commentRepository.findOneBy({ id: data.id }); - if (!comment) throw new Error(`Comment not found: ${data.id}`); - - Object.assign(comment, { - text: data.content - }); - await this.commentRepository.save(comment); - break; - } - case "Chat": { - const chat = await this.chatRepository.findOneBy({ id: data.id }); - if (!chat) throw new Error(`Chat not found: ${data.id}`); - - const participants = await this.userRepository.findBy({ - ename: In(data.participants) - }); - if (participants.length === 0) { - throw new Error("No participants found"); - } - - Object.assign(chat, { - name: data.name, - participants - }); - await this.chatRepository.save(chat); - break; - } - case "Message": { - const message = await this.messageRepository.findOneBy({ id: data.id }); - if (!message) throw new Error(`Message not found: ${data.id}`); - - Object.assign(message, { - text: data.content - }); - await this.messageRepository.save(message); - break; - } - case "MessageReadStatus": { - const readStatus = await this.messageReadStatusRepository.findOneBy({ id: data.id }); - if (!readStatus) throw new Error(`Read status not found: ${data.id}`); - - Object.assign(readStatus, { - isRead: data.isRead - }); - await this.messageReadStatusRepository.save(readStatus); - break; - } - } - } - - private async handleDelete(entityType: EntityType, data: any) { - switch (entityType) { - case "User": { - const user = await this.userRepository.findOneBy({ ename: data.ename }); - if (!user) throw new Error(`User not found: ${data.ename}`); - await this.userRepository.softDelete(user.id); - break; - } - case "Post": { - const post = await this.postRepository.findOneBy({ id: data.id }); - if (!post) throw new Error(`Post not found: ${data.id}`); - await this.postRepository.softDelete(post.id); - break; - } - case "Comment": { - const comment = await this.commentRepository.findOneBy({ id: data.id }); - if (!comment) throw new Error(`Comment not found: ${data.id}`); - await this.commentRepository.softDelete(comment.id); - break; - } - case "Chat": { - const chat = await this.chatRepository.findOneBy({ id: data.id }); - if (!chat) throw new Error(`Chat not found: ${data.id}`); - await this.chatRepository.softDelete(chat.id); - break; - } - case "Message": { - const message = await this.messageRepository.findOneBy({ id: data.id }); - if (!message) throw new Error(`Message not found: ${data.id}`); - await this.messageRepository.softDelete(message.id); - break; - } - case "MessageReadStatus": { - const readStatus = await this.messageReadStatusRepository.findOneBy({ id: data.id }); - if (!readStatus) throw new Error(`Read status not found: ${data.id}`); - await this.messageReadStatusRepository.softDelete(readStatus.id); - break; - } - } - } -} \ No newline at end of file diff --git a/platforms/pictique-api/src/web3adapter/entities/MetaEnvelopeMap.ts b/platforms/pictique-api/src/web3adapter/entities/MetaEnvelopeMap.ts deleted file mode 100644 index 2e538c20..00000000 --- a/platforms/pictique-api/src/web3adapter/entities/MetaEnvelopeMap.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - Index -} from "typeorm"; - -@Entity("_w3_adapter_meta_envelope_maps") -export class MetaEnvelopeMap { - @PrimaryGeneratedColumn("uuid") - id!: string; - - @Column() - @Index() - metaEnvelopeId!: string; - - @Column() - @Index() - internalId!: string; - - @Column() - entityType!: string; - - @Column({ nullable: true }) - parentMetaEnvelopeId?: string; - - @CreateDateColumn() - createdAt!: Date; - - @UpdateDateColumn() - updatedAt!: Date; -} \ No newline at end of file diff --git a/platforms/pictique-api/src/web3adapter/index.ts b/platforms/pictique-api/src/web3adapter/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/platforms/pictique-api/src/web3adapter/mappings/chat.mapping.json b/platforms/pictique-api/src/web3adapter/mappings/chat.mapping.json new file mode 100644 index 00000000..f6f15859 --- /dev/null +++ b/platforms/pictique-api/src/web3adapter/mappings/chat.mapping.json @@ -0,0 +1,15 @@ +{ + "tableName": "chats", + "schemaId": "550e8400-e29b-41d4-a716-446655440003", + "ownerEnamePath": "null", + "ownedJunctionTables": [ + "chat_participants" + ], + "localToUniversalMap": { + "name": "name", + "type": "type", + "participants": "users(participants[].id),participantIds", + "createdAt": "createdAt", + "updatedAt": "updatedAt" + } +} diff --git a/platforms/pictique-api/src/web3adapter/mappings/comment.mapping.json b/platforms/pictique-api/src/web3adapter/mappings/comment.mapping.json new file mode 100644 index 00000000..822e8a5e --- /dev/null +++ b/platforms/pictique-api/src/web3adapter/mappings/comment.mapping.json @@ -0,0 +1,19 @@ +{ + "tableName": "comments", + "schemaId": "550e8400-e29b-41d4-a716-446655440001", + "ownerEnamePath": "users(author.ename)", + "ownedJunctionTables": [ + "comment_likes" + ], + "localToUniversalMap": { + "text": "content", + "images": "mediaUrls", + "hashtags": "tags", + "createdAt": "createdAt", + "parentPostId": "posts(post.id),parentPostId", + "updatedAt": "updatedAt", + "isArchived": "isArchived", + "likedBy": "users(likedBy[].id),likedBy", + "author": "users(author.id),authorId" + } +} diff --git a/platforms/pictique-api/src/web3adapter/mappings/message.mapping.json b/platforms/pictique-api/src/web3adapter/mappings/message.mapping.json new file mode 100644 index 00000000..e7f495db --- /dev/null +++ b/platforms/pictique-api/src/web3adapter/mappings/message.mapping.json @@ -0,0 +1,12 @@ +{ + "tableName": "messages", + "schemaId": "550e8400-e29b-41d4-a716-446655440004", + "ownerEnamePath": "users(sender.ename)", + "localToUniversalMap": { + "chat": "chats(chat.id),chatId", + "text": "content", + "sender": "users(sender.id),senderId", + "createdAt": "createdAt", + "updatedAt": "updatedAt" + } +} diff --git a/platforms/pictique-api/src/web3adapter/mappings/post.mapping.json b/platforms/pictique-api/src/web3adapter/mappings/post.mapping.json new file mode 100644 index 00000000..4d09b222 --- /dev/null +++ b/platforms/pictique-api/src/web3adapter/mappings/post.mapping.json @@ -0,0 +1,19 @@ +{ + "tableName": "posts", + "schemaId": "550e8400-e29b-41d4-a716-446655440001", + "ownerEnamePath": "users(author.ename)", + "ownedJunctionTables": [ + "post_likes" + ], + "localToUniversalMap": { + "text": "content", + "images": "mediaUrls", + "hashtags": "tags", + "createdAt": "createdAt", + "parentPostId": "posts(parentPostId),parentPostId", + "updatedAt": "updatedAt", + "isArchived": "isArchived", + "likedBy": "users(likedBy[].id),likedBy", + "author": "users(author.id),authorId" + } +} diff --git a/platforms/pictique-api/src/web3adapter/mappings/user.mapping.json b/platforms/pictique-api/src/web3adapter/mappings/user.mapping.json new file mode 100644 index 00000000..f896f59c --- /dev/null +++ b/platforms/pictique-api/src/web3adapter/mappings/user.mapping.json @@ -0,0 +1,24 @@ +{ + "tableName": "users", + "schemaId": "550e8400-e29b-41d4-a716-446655440000", + "ownerEnamePath": "ename", + "ownedJunctionTables": [ + "user_followers", + "user_following" + ], + "localToUniversalMap": { + "handle": "username", + "name": "displayName", + "description": "bio", + "avatarUrl": "avatarUrl", + "bannerUrl": "bannerUrl", + "ename": "ename", + "isVerified": "isVerified", + "isPrivate": "isPrivate", + "createdAt": "createdAt", + "updatedAt": "updatedAt", + "isArchived": "isArchived", + "followers": "followers", + "following": "following" + } +} diff --git a/platforms/pictique-api/src/web3adapter/routes/webhook.ts b/platforms/pictique-api/src/web3adapter/routes/webhook.ts deleted file mode 100644 index 10e31536..00000000 --- a/platforms/pictique-api/src/web3adapter/routes/webhook.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Router } from "express"; -import { WebhookController } from "../controllers/WebhookController"; - -const router = Router(); -const webhookController = new WebhookController(); - -router.post("/webhook", (req, res) => webhookController.handleWebhook(req, res)); - -export default router; \ No newline at end of file diff --git a/platforms/pictique-api/src/web3adapter/services/TransformService.ts b/platforms/pictique-api/src/web3adapter/services/TransformService.ts deleted file mode 100644 index 3b226f27..00000000 --- a/platforms/pictique-api/src/web3adapter/services/TransformService.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { web3AdapterConfig } from "../config"; -import { EntityType, GlobalOntologyType, TransformContext } from "../types"; - -export class TransformService { - private static instance: TransformService; - private constructor() {} - - static getInstance(): TransformService { - if (!TransformService.instance) { - TransformService.instance = new TransformService(); - } - return TransformService.instance; - } - - toGlobalOntology( - entityType: EntityType, - platformData: any, - context: TransformContext - ): Record { - switch (entityType) { - case "User": - return this.transformUser(platformData); - case "Post": - return this.transformPost(platformData); - case "Comment": - return this.transformComment(platformData, context); - case "Chat": - return this.transformChat(platformData); - case "Message": - return this.transformMessage(platformData); - case "MessageReadStatus": - return this.transformMessageReadStatus(platformData); - default: - throw new Error(`Unsupported entity type: ${entityType}`); - } - } - - fromGlobalOntology( - entityType: EntityType, - globalData: any, - context: TransformContext - ): Record { - switch (entityType) { - case "User": - return this.transformUserFromGlobal(globalData); - case "Post": - return this.transformPostFromGlobal(globalData); - case "Comment": - return this.transformCommentFromGlobal(globalData); - case "Chat": - return this.transformChatFromGlobal(globalData); - case "Message": - return this.transformMessageFromGlobal(globalData); - case "MessageReadStatus": - return this.transformMessageReadStatusFromGlobal(globalData); - default: - throw new Error(`Unsupported entity type: ${entityType}`); - } - } - - private transformUser(user: any): Record { - return { - id: user.id, - username: user.handle, - displayName: user.name, - bio: user.description, - avatarUrl: user.avatarUrl, - followers: user.followers?.map((f: any) => f.id) || [], - following: user.following?.map((f: any) => f.id) || [], - createdAt: user.createdAt, - updatedAt: user.updatedAt - }; - } - - private transformUserFromGlobal(global: any): Record { - return { - id: global.id, - handle: global.username, - name: global.displayName, - description: global.bio, - avatarUrl: global.avatarUrl, - createdAt: global.createdAt, - updatedAt: global.updatedAt - }; - } - - private transformPost(post: any): Record { - return { - content: post.text, - images: post.images || [], - hashtags: post.hashtags || [], - authorEname: post.author?.ename, - likes: post.likedBy?.length || 0, - replies: post.comments?.length || 0, - isArchived: post.isArchived, - createdAt: post.createdAt, - updatedAt: post.updatedAt - }; - } - - private transformPostFromGlobal(global: any): Record { - return { - id: global.id, - text: global.content, - createdAt: global.createdAt, - updatedAt: global.updatedAt - }; - } - - private transformComment(comment: any, context: TransformContext): Record { - console.log("Transforming comment with context:", context); - const data: Record = { - content: comment.text, - authorEname: comment.author?.ename, - likes: comment.likedBy?.length || 0, - isArchived: comment.isArchived, - createdAt: comment.createdAt, - updatedAt: comment.updatedAt - }; - if (context.parentMetaEnvelopeId) { - data.parentId = context.parentMetaEnvelopeId; - } - console.log("Transformed comment data:", data); - return data; - } - - private transformCommentFromGlobal(global: any): Record { - return { - id: global.id, - text: global.content, - createdAt: global.createdAt, - updatedAt: global.updatedAt - }; - } - - private transformChat(chat: any): Record { - return { - id: chat.id, - name: chat.name, - participants: chat.participants?.map((p: any) => p.id) || [], - messages: chat.messages?.map((m: any) => m.id) || [], - createdAt: chat.createdAt, - updatedAt: chat.updatedAt, - isArchived: chat.isArchived || false - }; - } - - private transformChatFromGlobal(global: any): Record { - return { - id: global.id, - createdAt: global.createdAt, - updatedAt: global.updatedAt - }; - } - - private transformMessage(message: any): Record { - return { - id: message.id, - content: message.text, - authorId: message.sender?.id, - chatId: message.chat?.id, - readStatuses: message.readStatuses?.map((status: any) => ({ - userId: status.user?.id, - isRead: status.isRead, - readAt: status.readAt - })) || [], - createdAt: message.createdAt, - updatedAt: message.updatedAt, - isArchived: message.isArchived || false - }; - } - - private transformMessageFromGlobal(global: any): Record { - return { - id: global.id, - text: global.content, - createdAt: global.createdAt, - updatedAt: global.updatedAt, - isArchived: global.isArchived - }; - } - - private transformMessageReadStatus(status: any): Record { - return { - id: status.id, - messageId: status.message?.id, - userId: status.user?.id, - isRead: status.isRead, - readAt: status.readAt, - createdAt: status.createdAt, - updatedAt: status.updatedAt - }; - } - - private transformMessageReadStatusFromGlobal(global: any): Record { - return { - id: global.id, - readAt: global.readAt - }; - } -} \ No newline at end of file diff --git a/platforms/pictique-api/src/web3adapter/services/eVaultService.ts b/platforms/pictique-api/src/web3adapter/services/eVaultService.ts deleted file mode 100644 index aa9927d3..00000000 --- a/platforms/pictique-api/src/web3adapter/services/eVaultService.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { web3AdapterConfig } from "../config"; -import axios from "axios"; - -interface MetaEnvelopeMapping { - entityType: string; - internalId: string; - metaEnvelopeId: string; -} - -class eVaultServiceClass { - private static instance: eVaultServiceClass; - private constructor() {} - - static getInstance(): eVaultServiceClass { - if (!eVaultServiceClass.instance) { - eVaultServiceClass.instance = new eVaultServiceClass(); - } - return eVaultServiceClass.instance; - } - - async storeMetaEnvelope( - ownerEname: string, - ontology: string, - data: Record - ) { - const response = await axios.post(web3AdapterConfig.eVault.graphqlUrl, { - query: ` - mutation StoreMetaEnvelope($input: StoreMetaEnvelopeInput!) { - storeMetaEnvelope(input: $input) { - metaEnvelope { - id - ontology - parsed - } - envelopes { - id - ontology - value - valueType - } - } - } - `, - variables: { - input: { - ownerEname, - ontology, - data, - acl: web3AdapterConfig.eVault.defaultAcl - } - } - }); - - if (response.data.errors) { - throw new Error(`Failed to store meta envelope: ${response.data.errors[0].message}`); - } - - return response.data.data.storeMetaEnvelope; - } - - async findMetaEnvelopeMapping( - entityType: string, - internalId: string - ): Promise { - const response = await axios.post(web3AdapterConfig.eVault.graphqlUrl, { - query: ` - query FindMetaEnvelopeMapping($entityType: String!, $internalId: String!) { - findMetaEnvelopeMapping(entityType: $entityType, internalId: $internalId) { - entityType - internalId - metaEnvelopeId - } - } - `, - variables: { - entityType, - internalId - } - }); - - if (response.data.errors) { - throw new Error(`Failed to find meta envelope mapping: ${response.data.errors[0].message}`); - } - - return response.data.data.findMetaEnvelopeMapping; - } -} - -export const eVaultService = eVaultServiceClass.getInstance(); \ No newline at end of file diff --git a/platforms/pictique-api/src/web3adapter/subscribers/EntitySubscriber.ts b/platforms/pictique-api/src/web3adapter/subscribers/EntitySubscriber.ts deleted file mode 100644 index d29efc61..00000000 --- a/platforms/pictique-api/src/web3adapter/subscribers/EntitySubscriber.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { EntitySubscriberInterface, EventSubscriber, InsertEvent, UpdateEvent, RemoveEvent, DataSource } from "typeorm"; -import { User } from "../../database/entities/User"; -import { Post } from "../../database/entities/Post"; -import { Comment } from "../../database/entities/Comment"; -import { Chat } from "../../database/entities/Chat"; -import { Message } from "../../database/entities/Message"; -import { MessageReadStatus } from "../../database/entities/MessageReadStatus"; -import { web3AdapterConfig } from "../config"; -import { TransformService } from "../services/TransformService"; -import { eVaultService } from "../services/eVaultService"; -import { EntityType } from "../types"; -import axios from "axios"; - -@EventSubscriber() -export class EntitySubscriber implements EntitySubscriberInterface { - constructor(private dataSource: DataSource) { - this.dataSource.subscribers.push(this as EntitySubscriberInterface); - } - - listenTo(): Function { - return function() { - return [User, Post, Comment, Chat, Message, MessageReadStatus]; - }; - } - - async afterInsert(event: InsertEvent): Promise { - if (!event.entity) return; - - try { - const entityType = this.determineEntityType(event.entity); - const ownerEname = this.determineOwnerEname(event.entity); - const transformService = TransformService.getInstance(); - - // Transform to global ontology - const globalEntity = transformService.toGlobalOntology( - entityType, - event.entity, - { - platform: "pictique", - entityType, - internalId: event.entity.id.toString() - } - ); - - // Store in eVault - const result = await eVaultService.storeMetaEnvelope( - ownerEname, - web3AdapterConfig.entityMappings[entityType], - globalEntity - ); - - // Send webhook to Blabsy - await axios.post(web3AdapterConfig.webhook.receiveUrl, { - operation: "create", - entityType, - data: globalEntity, - metaEnvelopeId: result.metaEnvelope.id - }, { - headers: { - "X-Webhook-Secret": web3AdapterConfig.webhook.secret - } - }); - } catch (error) { - console.error("Error in afterInsert:", error); - } - } - - async afterUpdate(event: UpdateEvent): Promise { - if (!event.entity) return; - - try { - const entityType = this.determineEntityType(event.entity); - const ownerEname = this.determineOwnerEname(event.entity); - const transformService = TransformService.getInstance(); - - // Transform to global ontology - const globalEntity = transformService.toGlobalOntology( - entityType, - event.entity, - { - platform: "pictique", - entityType, - internalId: event.entity.id.toString() - } - ); - - // Store in eVault - const result = await eVaultService.storeMetaEnvelope( - ownerEname, - web3AdapterConfig.entityMappings[entityType], - globalEntity - ); - - // Send webhook to Blabsy - await axios.post(web3AdapterConfig.webhook.receiveUrl, { - operation: "update", - entityType, - data: globalEntity, - metaEnvelopeId: result.metaEnvelope.id - }, { - headers: { - "X-Webhook-Secret": web3AdapterConfig.webhook.secret - } - }); - } catch (error) { - console.error("Error in afterUpdate:", error); - } - } - - async afterRemove(event: RemoveEvent): Promise { - if (!event.entity) return; - - try { - const entityType = this.determineEntityType(event.entity); - const ownerEname = this.determineOwnerEname(event.entity); - - // Send webhook to Blabsy - await axios.post(web3AdapterConfig.webhook.receiveUrl, { - operation: "delete", - entityType, - data: { - id: event.entity.id, - ownerEname - } - }, { - headers: { - "X-Webhook-Secret": web3AdapterConfig.webhook.secret - } - }); - } catch (error) { - console.error("Error in afterRemove:", error); - } - } - - private determineEntityType(entity: any): EntityType { - if (entity instanceof User) return "User"; - if (entity instanceof Post) return "Post"; - if (entity instanceof Comment) return "Comment"; - if (entity instanceof Chat) return "Chat"; - if (entity instanceof Message) return "Message"; - if (entity instanceof MessageReadStatus) return "MessageReadStatus"; - throw new Error("Unknown entity type"); - } - - private determineOwnerEname(entity: any): string { - if (entity instanceof User) return entity.ename; - if (entity instanceof Post) return entity.author.ename; - if (entity instanceof Comment) return entity.author.ename; - if (entity instanceof Chat) return entity.participants[0].ename; - if (entity instanceof Message) return entity.sender.ename; - if (entity instanceof MessageReadStatus) return entity.user.ename; - throw new Error("Unknown entity type for owner determination"); - } -} \ No newline at end of file diff --git a/platforms/pictique-api/src/web3adapter/types/index.ts b/platforms/pictique-api/src/web3adapter/types/index.ts deleted file mode 100644 index d13e7bf0..00000000 --- a/platforms/pictique-api/src/web3adapter/types/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { web3AdapterConfig } from "../config"; - -export type EntityType = "User" | "Post" | "Comment" | "Chat" | "Message" | "MessageReadStatus"; -export type GlobalOntologyType = typeof web3AdapterConfig.entityMappings[EntityType]; - -export interface MetaEnvelopePayload { - [key: string]: any; -} - -export interface WebhookPayload { - metaEnvelopeId: string; - entityType: GlobalOntologyType; - operation: "create" | "update" | "delete"; - payload: MetaEnvelopePayload; - timestamp: string; - signature: string; -} - -export interface TransformContext { - platform: "blabsy" | "pictique"; - entityType: EntityType; - internalId: string; - parentMetaEnvelopeId?: string; -} - -export interface eVaultResponse { - metaEnvelope: { - id: string; - ontology: string; - parsed: any; - }; - envelopes: Array<{ - id: string; - ontology: string; - value: any; - valueType: string; - }>; -} \ No newline at end of file diff --git a/platforms/pictique-api/src/web3adapter/watchers/subscriber.ts b/platforms/pictique-api/src/web3adapter/watchers/subscriber.ts new file mode 100644 index 00000000..f293c27f --- /dev/null +++ b/platforms/pictique-api/src/web3adapter/watchers/subscriber.ts @@ -0,0 +1,302 @@ +import { + EventSubscriber, + EntitySubscriberInterface, + InsertEvent, + UpdateEvent, + RemoveEvent, + ObjectLiteral, +} from "typeorm"; +import { Web3Adapter } from "../../../../../infrastructure/web3-adapter/src/index"; +import path from "path"; +import dotenv from "dotenv"; +import { AppDataSource } from "../../database/data-source"; +import axios from "axios"; + +dotenv.config({ path: path.resolve(__dirname, "../../../../../.env") }); +export const adapter = new Web3Adapter({ + schemasPath: path.resolve(__dirname, "../mappings/"), + dbPath: path.resolve(process.env.PICTIQUE_MAPPING_DB_PATH as string), + registryUrl: process.env.PUBLIC_REGISTRY_URL as string, +}); + +// Map of junction tables to their parent entities +const JUNCTION_TABLE_MAP = { + user_followers: { entity: "User", idField: "user_id" }, + user_following: { entity: "User", idField: "user_id" }, + post_likes: { entity: "Post", idField: "post_id" }, + comment_likes: { entity: "Comment", idField: "comment_id" }, + chat_participants: { entity: "Chat", idField: "chat_id" }, +}; + +@EventSubscriber() +export class PostgresSubscriber implements EntitySubscriberInterface { + private adapter: Web3Adapter; + + constructor() { + this.adapter = adapter; + } + + /** + * Called after entity is loaded. + */ + afterLoad(entity: any) { + // Handle any post-load processing if needed + } + + /** + * Called before entity insertion. + */ + beforeInsert(event: InsertEvent) { + // Handle any pre-insert processing if needed + } + + async enrichEntity(entity: any, tableName: string, tableTarget: any) { + try { + const enrichedEntity = { ...entity }; + + if (entity.author) { + const author = await AppDataSource.getRepository( + "User", + ).findOne({ where: { id: entity.author.id } }); + enrichedEntity.author = author; + } + + return this.entityToPlain(enrichedEntity); + } catch (error) { + console.error("Error loading relations:", error); + return this.entityToPlain(entity); + } + } + + /** + * Called after entity insertion. + */ + async afterInsert(event: InsertEvent) { + let entity = event.entity; + console.log("pre - ", entity); + if (entity) { + entity = (await this.enrichEntity( + entity, + event.metadata.tableName, + event.metadata.target, + )) as ObjectLiteral; + } + this.handleChange( + // @ts-ignore + entity ?? event.entityId, + event.metadata.tableName, + ); + } + + /** + * Called before entity update. + */ + beforeUpdate(event: UpdateEvent) { + // Handle any pre-update processing if needed + } + + /** + * Called after entity update. + */ + async afterUpdate(event: UpdateEvent) { + let entity = event.entity; + if (entity) { + entity = (await this.enrichEntity( + entity, + event.metadata.tableName, + event.metadata.target, + )) as ObjectLiteral; + } + this.handleChange( + // @ts-ignore + entity ?? event.entityId, + event.metadata.tableName, + ); + } + + /** + * Called before entity removal. + */ + beforeRemove(event: RemoveEvent) { + // Handle any pre-remove processing if needed + } + + /** + * Called after entity removal. + */ + async afterRemove(event: RemoveEvent) { + let entity = event.entity; + if (entity) { + entity = (await this.enrichEntity( + entity, + event.metadata.tableName, + event.metadata.target, + )) as ObjectLiteral; + } + this.handleChange( + // @ts-ignore + entity ?? event.entityId, + event.metadata.tableName, + ); + } + + /** + * Process the change and send it to the Web3Adapter + */ + private async handleChange(entity: any, tableName: string): Promise { + // Check if this is a junction table + if (tableName === "message_read_status") return; + // @ts-ignore + const junctionInfo = JUNCTION_TABLE_MAP[tableName]; + if (junctionInfo) { + console.log("Processing junction table change:", tableName); + await this.handleJunctionTableChange(entity, junctionInfo); + return; + } + // Handle regular entity changes + const data = this.entityToPlain(entity); + if (!data.id) return; + + setTimeout(async () => { + try { + if (!this.adapter.lockedIds.includes(entity.id)) { + const envelope = await this.adapter.handleChange({ + data, + tableName: tableName.toLowerCase(), + }); + this.deliverWebhook(envelope); + } + } catch (error) { + console.error( + `Error processing change for ${tableName}:`, + error, + ); + } + }, 2_000); + } + + private async deliverWebhook(envelope: Record) { + console.log("sending envelope", envelope); + axios + .post( + new URL( + "/api/webhook", + process.env.PUBLIC_BLABSY_BASE_URL, + ).toString(), + envelope, + ) + .catch((e) => console.error(e)); + } + + /** + * Handle changes in junction tables by converting them to parent entity changes + */ + private async handleJunctionTableChange( + entity: any, + junctionInfo: { entity: string; idField: string }, + ): Promise { + try { + const parentId = entity[junctionInfo.idField]; + if (!parentId) { + console.error("No parent ID found in junction table change"); + return; + } + + // Get the parent entity repository + // GET THE REPOSITORY FROM ENTITY VIA THE MAIN THINGY AND THEN THIS + // PART IS TAKEN CARE OF, TEST MESSAGES & CHAT & STUFF TOMORROW + const repository = AppDataSource.getRepository(junctionInfo.entity); + const parentEntity = await repository.findOne({ + where: { id: parentId }, + relations: this.getRelationsForEntity(junctionInfo.entity), + }); + + if (!parentEntity) { + console.error(`Parent entity not found: ${parentId}`); + return; + } + + // Process the parent entity change + setTimeout(async () => { + try { + if (!this.adapter.lockedIds.includes(parentId)) { + const envelope = await this.adapter.handleChange({ + data: this.entityToPlain(parentEntity), + tableName: junctionInfo.entity.toLowerCase() + "s", + }); + this.deliverWebhook(envelope); + } + } catch (error) { + console.error( + `Error processing junction table change for ${junctionInfo.entity}:`, + error, + ); + } + }, 2_000); + } catch (error) { + console.error("Error handling junction table change:", error); + } + } + + /** + * Get the relations that should be loaded for each entity type + */ + private getRelationsForEntity(entityName: string): string[] { + switch (entityName) { + case "User": + return ["followers", "following", "posts", "comments", "chats"]; + case "Post": + return ["author", "likedBy", "comments"]; + case "Comment": + return ["author", "post", "likedBy"]; + case "Chat": + return ["participants", "messages"]; + default: + return []; + } + } + + /** + * Convert TypeORM entity to plain object + */ + private entityToPlain(entity: any): any { + if (!entity) return {}; + + // If it's already a plain object, return it + if (typeof entity !== "object" || entity === null) { + return entity; + } + + // Handle Date objects + if (entity instanceof Date) { + return entity.toISOString(); + } + + // Handle arrays + if (Array.isArray(entity)) { + return entity.map((item) => this.entityToPlain(item)); + } + + // Convert entity to plain object + const plain: Record = {}; + for (const [key, value] of Object.entries(entity)) { + // Skip private properties and methods + if (key.startsWith("_")) continue; + + // Handle nested objects and arrays + if (value && typeof value === "object") { + if (Array.isArray(value)) { + plain[key] = value.map((item) => this.entityToPlain(item)); + } else if (value instanceof Date) { + plain[key] = value.toISOString(); + } else { + plain[key] = this.entityToPlain(value); + } + } else { + plain[key] = value; + } + } + + return plain; + } +} diff --git a/platforms/pictique/package.json b/platforms/pictique/package.json index f35eb2d6..f8b21b88 100644 --- a/platforms/pictique/package.json +++ b/platforms/pictique/package.json @@ -51,6 +51,7 @@ }, "dependencies": { "-": "^0.0.1", + "@sveltejs/adapter-node": "^5.2.12", "D": "^1.0.0", "axios": "^1.6.7", "moment": "^2.30.1", diff --git a/platforms/pictique/src/lib/fragments/BottomNav/BottomNav.svelte b/platforms/pictique/src/lib/fragments/BottomNav/BottomNav.svelte index c0dff182..f7e8eaed 100644 --- a/platforms/pictique/src/lib/fragments/BottomNav/BottomNav.svelte +++ b/platforms/pictique/src/lib/fragments/BottomNav/BottomNav.svelte @@ -2,9 +2,9 @@ import { goto } from '$app/navigation'; import { page } from '$app/state'; import { Camera, CommentsTwo, Home, Search } from '$lib/icons'; - import { isNavigatingThroughNav, ownerId } from '$lib/store/store.svelte'; - import { uploadedImages } from '$lib/store/store.svelte'; - import { revokeImageUrls } from '$lib/utils'; + import { isNavigatingThroughNav } from '$lib/store/store.svelte'; + import { uploadedImages } from '$lib/store/store.svelte'; + import { getAuthId, revokeImageUrls } from '$lib/utils'; import type { HTMLAttributes } from 'svelte/elements'; interface IBottomNavProps extends HTMLAttributes { @@ -20,9 +20,9 @@ let previousTab = $state('home'); let _activeTab = $derived(page.url.pathname); let fullPath = $derived(page.url.pathname); - let imageInput: HTMLInputElement; - let images: FileList | null = $state(null); + let images: FileList | null = $state(null); + let ownerId: string | null = $state(null); const handleNavClick = (newTab: string) => { // activeTab = newTab; @@ -34,9 +34,9 @@ previousTab = newTab; if (newTab === 'profile') { goto(`/profile/${ownerId}`); - } else if (newTab === "post") { - uploadedImages.value = null; - imageInput.value = ""; + } else if (newTab === 'post') { + uploadedImages.value = null; + imageInput.value = ''; imageInput.click(); } else { goto(`/${newTab}`); @@ -44,23 +44,36 @@ }; $effect(() => { + ownerId = getAuthId(); activeTab = _activeTab.split('/').pop() ?? ''; - if (images && images.length > 0 && activeTab !== 'post' && previousTab === 'post' && !_activeTab.includes('post/audience')) { - if (uploadedImages.value) - revokeImageUrls(uploadedImages.value); - uploadedImages.value = Array.from(images).map(file => ({ - url: URL.createObjectURL(file), - alt: file.name - })); + if ( + images && + images.length > 0 && + activeTab !== 'post' && + previousTab === 'post' && + !_activeTab.includes('post/audience') + ) { + if (uploadedImages.value) revokeImageUrls(uploadedImages.value); + uploadedImages.value = Array.from(images).map((file) => ({ + url: URL.createObjectURL(file), + alt: file.name + })); images = null; // To prevent re-triggering the effect and thus making an infinite loop with /post route's effect when the length of uploadedImages goes to 0 - if (uploadedImages.value.length > 0) { - goto("/post"); - } + if (uploadedImages.value.length > 0) { + goto('/post'); + } } }); - + - { - if (!comment.isUpVoted) { - comment.upVotes++; - comment.isUpVoted = true; - comment.isDownVoted = false; - } - }} - > - - - {comment.upVotes} - { - if (!comment.isDownVoted) { - comment.upVotes--; - comment.isDownVoted = true; - comment.isUpVoted = false; - } - }} - > - - - - Reply - {comment.time} {#if comment?.replies?.length} diff --git a/platforms/pictique/src/lib/fragments/CreatePostModal/CreatePostModal.svelte b/platforms/pictique/src/lib/fragments/CreatePostModal/CreatePostModal.svelte index 67c14581..51366482 100644 --- a/platforms/pictique/src/lib/fragments/CreatePostModal/CreatePostModal.svelte +++ b/platforms/pictique/src/lib/fragments/CreatePostModal/CreatePostModal.svelte @@ -51,13 +51,14 @@ ✕ + {#each images as image, index} + removeImage(index)} + onclick={() => removeImage(index)} > ✕ @@ -87,14 +89,17 @@ {/if} - - - + + + Add Photo {#if files} diff --git a/platforms/pictique/src/lib/fragments/MessageInput/MessageInput.svelte b/platforms/pictique/src/lib/fragments/MessageInput/MessageInput.svelte index 200bd0b7..62bf1000 100644 --- a/platforms/pictique/src/lib/fragments/MessageInput/MessageInput.svelte +++ b/platforms/pictique/src/lib/fragments/MessageInput/MessageInput.svelte @@ -52,7 +52,7 @@ diff --git a/platforms/pictique/src/lib/fragments/Post/Post.svelte b/platforms/pictique/src/lib/fragments/Post/Post.svelte index 7e26dcb1..0b785104 100644 --- a/platforms/pictique/src/lib/fragments/Post/Post.svelte +++ b/platforms/pictique/src/lib/fragments/Post/Post.svelte @@ -12,14 +12,15 @@ } from '@hugeicons/core-free-icons'; import { HugeiconsIcon } from '@hugeicons/svelte'; import type { HTMLAttributes } from 'svelte/elements'; + import ActionMenu from '../ActionMenu/ActionMenu.svelte'; interface IPostProps extends HTMLAttributes { avatar: string; username: string; - userId: string; + userId?: string; imgUris: string[]; - caption: string; - count: { + text: string; + count?: { likes: number; comments: number; }; @@ -29,39 +30,49 @@ comment: () => void; }; time: string; + options?: Array<{ name: string; handler: () => void }>; } function pairAndJoinChunks(chunks: string[]): string[] { const result: string[] = []; + console.log('chunks', chunks); for (let i = 0; i < chunks.length; i += 2) { const dataPart = chunks[i]; const chunkPart = chunks[i + 1]; if (dataPart && chunkPart) { - result.push(dataPart + ',' + chunkPart); + if (dataPart.startsWith('data:')) { + result.push(dataPart + ',' + chunkPart); + } else { + result.push(dataPart); + result.push(chunkPart); + } } else { + if (!dataPart.startsWith('data:')) result.push(dataPart); console.warn(`Skipping incomplete pair at index ${i}`); } } + console.log('result', result); return result; } const { avatar, + userId, username, imgUris: uris, text, count, callback, time, + options, ...restProps }: IPostProps = $props(); - let imgUris = $derived.by(() => pairAndJoinChunks(uris)); - - let galleryRef: HTMLDivElement; + let imgUris = $derived(pairAndJoinChunks(uris)); + let galleryRef: HTMLDivElement | undefined = $state(); let currentIndex = $state(0); function scrollLeft() { @@ -101,12 +112,7 @@ > {username} - - - + {/if} {#if imgUris.length > 0} diff --git a/platforms/pictique/src/lib/fragments/Profile/Profile.svelte b/platforms/pictique/src/lib/fragments/Profile/Profile.svelte index 661c9f71..2a61ee9f 100644 --- a/platforms/pictique/src/lib/fragments/Profile/Profile.svelte +++ b/platforms/pictique/src/lib/fragments/Profile/Profile.svelte @@ -3,28 +3,36 @@ import type { userProfile, PostData } from '$lib/types'; import Post from '../Post/Post.svelte'; - export let variant: 'user' | 'other' = 'user'; - export let profileData: userProfile; - export let handleSinglePost: (post: PostData) => void; - export let handleFollow: () => Promise; - export let handleMessage: () => Promise; + let { + variant = 'user', + profileData, + handleSinglePost, + handleFollow, + handleMessage + }: { + variant: 'user' | 'other'; + profileData: userProfile; + handleSinglePost: (post: PostData) => void; + handleFollow: () => Promise; + handleMessage: () => Promise; + } = $props(); - {profileData.username} - {profileData.userBio} + {profileData?.handle} + {profileData?.description} {#if variant === 'other'} - Follow - Message + Follow + Message {/if} @@ -46,13 +54,13 @@ {#each profileData.posts.filter((e) => e.imgUris && e.imgUris.length > 0) as post} - + { try { diff --git a/platforms/pictique/src/lib/fragments/SettingsNavigationButton/SettingsNavigationButton.svelte b/platforms/pictique/src/lib/fragments/SettingsNavigationButton/SettingsNavigationButton.svelte index fda243ca..1461331e 100644 --- a/platforms/pictique/src/lib/fragments/SettingsNavigationButton/SettingsNavigationButton.svelte +++ b/platforms/pictique/src/lib/fragments/SettingsNavigationButton/SettingsNavigationButton.svelte @@ -38,7 +38,7 @@ diff --git a/platforms/pictique/src/lib/fragments/SideBar/SideBar.svelte b/platforms/pictique/src/lib/fragments/SideBar/SideBar.svelte index 51897fd9..b97317c7 100644 --- a/platforms/pictique/src/lib/fragments/SideBar/SideBar.svelte +++ b/platforms/pictique/src/lib/fragments/SideBar/SideBar.svelte @@ -4,8 +4,9 @@ import { goto } from '$app/navigation'; import { page } from '$app/state'; import Button from '$lib/ui/Button/Button.svelte'; - import { cn } from '$lib/utils'; - import { ownerId } from '$lib/store/store.svelte'; + import { cn, getAuthId } from '$lib/utils'; + + let ownerId: string | null = $state(null); interface ISideBarProps extends HTMLAttributes { activeTab?: string; @@ -20,6 +21,7 @@ }: ISideBarProps = $props(); $effect(() => { + ownerId = getAuthId(); const pathname = page.url.pathname; if (pathname.includes('/home')) { activeTab = 'home'; @@ -142,14 +144,13 @@ Settings - { activeTab = 'profile'; - goto(`/profile/${ownerId.value}`); + goto(`/profile/${ownerId}`); }} > diff --git a/platforms/pictique/src/lib/fragments/UploadedPostView/UploadedPostView.svelte b/platforms/pictique/src/lib/fragments/UploadedPostView/UploadedPostView.svelte index e3f22655..79f7a265 100644 --- a/platforms/pictique/src/lib/fragments/UploadedPostView/UploadedPostView.svelte +++ b/platforms/pictique/src/lib/fragments/UploadedPostView/UploadedPostView.svelte @@ -1,10 +1,9 @@ diff --git a/platforms/pictique/src/lib/utils/axios.ts b/platforms/pictique/src/lib/utils/axios.ts index 9c1585d8..275f9977 100644 --- a/platforms/pictique/src/lib/utils/axios.ts +++ b/platforms/pictique/src/lib/utils/axios.ts @@ -3,25 +3,44 @@ import { PUBLIC_PICTIQUE_BASE_URL } from '$env/static/public'; const TOKEN_KEY = 'pictique_auth_token'; +let headers: Record = { + 'Content-Type': 'application/json' +}; +if (getAuthToken()) { + headers.authorization = `Bearer ${getAuthToken()}`; +} + // Create axios instance with base configuration export const apiClient: AxiosInstance = axios.create({ baseURL: PUBLIC_PICTIQUE_BASE_URL, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY)}` - } + headers }); // Utility function to store auth token export const setAuthToken = (token: string): void => { localStorage.setItem(TOKEN_KEY, token); + window.location.href = '/home'; }; -export const getAuthToken = () => { +export function getAuthToken() { return localStorage.getItem(TOKEN_KEY); -}; +} // Utility function to remove auth token export const removeAuthToken = (): void => { localStorage.removeItem(TOKEN_KEY); }; + +// Utility function to store auth id +export const setAuthId = (id: string): void => { + localStorage.setItem('ownerId', id); +}; + +export const getAuthId = () => { + return localStorage.getItem('ownerId'); +}; + +// Utility function to remove auth token +export const removeAuthId = (): void => { + localStorage.removeItem('ownerId'); +}; diff --git a/platforms/pictique/src/routes/(auth)/auth/+page.svelte b/platforms/pictique/src/routes/(auth)/auth/+page.svelte index 5d7c49f6..092a077e 100644 --- a/platforms/pictique/src/routes/(auth)/auth/+page.svelte +++ b/platforms/pictique/src/routes/(auth)/auth/+page.svelte @@ -1,7 +1,7 @@ { openCreatePostModal(); }} @@ -85,7 +110,7 @@ : route.includes('profile') ? 'tertiary' : 'primary'} - {heading} + heading={$heading} isCallBackNeeded={route.includes('profile')} callback={() => alert('Ads')} options={[ @@ -136,7 +161,7 @@ + {/if} diff --git a/platforms/pictique/src/routes/(protected)/discover/+page.svelte b/platforms/pictique/src/routes/(protected)/discover/+page.svelte index b95f5748..5f587bcd 100644 --- a/platforms/pictique/src/routes/(protected)/discover/+page.svelte +++ b/platforms/pictique/src/routes/(protected)/discover/+page.svelte @@ -39,6 +39,10 @@ clearTimeout(debounceTimer); }; }); + + $effect(() => { + console.log($searchResults); + }) @@ -60,7 +64,7 @@ {#each $searchResults as user} handleFollow(user.id)} diff --git a/platforms/pictique/src/routes/(protected)/home/+page.svelte b/platforms/pictique/src/routes/(protected)/home/+page.svelte index 3e33b5e1..1f681f55 100644 --- a/platforms/pictique/src/routes/(protected)/home/+page.svelte +++ b/platforms/pictique/src/routes/(protected)/home/+page.svelte @@ -3,17 +3,23 @@ import { onMount } from 'svelte'; import type { CupertinoPane } from 'cupertino-pane'; import { Comment, MessageInput } from '$lib/fragments'; - import type { CommentType } from '$lib/types'; + import type { userProfile } from '$lib/types'; import { showComments } from '$lib/store/store.svelte'; import { posts, isLoading, error, fetchFeed, toggleLike } from '$lib/stores/posts'; - import { activePostId } from '$lib/stores/comments'; + import { activePostId, comments, createComment, fetchComments } from '$lib/stores/comments'; + import { apiClient, getAuthId } from '$lib/utils'; let listElement: HTMLElement; let drawer: CupertinoPane | undefined = $state(); let commentValue: string = $state(''); let commentInput: HTMLInputElement | undefined = $state(); - let _comments = $state([]); let activeReplyToId: string | null = $state(null); + let followError = $state(null); + let ownerId: string | null = $state(null); + let profile = $state(null); + let loading = $state(true); + let isCommentsLoading = $state(false); + let commentsError = $state(null); const onScroll = () => { if (listElement.scrollTop + listElement.clientHeight >= listElement.scrollHeight) { @@ -22,47 +28,62 @@ }; const handleSend = async () => { - const newComment = { - userImgSrc: 'https://www.gravatar.com/avatar/2c7d99fe281ecd3bcd65ab915bac6dd5?s=250', - name: 'You', - commentId: Date.now().toString(), - comment: commentValue, - isUpVoted: false, - isDownVoted: false, - upVotes: 0, - time: 'Just now', - replies: [] - }; + if (!$activePostId || !commentValue.trim()) return; - if (activeReplyToId) { - // Find the parent comment by id and push reply - const addReplyToComment = (commentsArray: CommentType[]) => { - for (const c of commentsArray) { - if (c.commentId === activeReplyToId) { - c.replies.push(newComment); - return true; - } else if (c.replies.length) { - if (addReplyToComment(c.replies)) return true; - } - } - return false; - }; - addReplyToComment(_comments); - } else { - // If no activeReplyToId, add as a new parent comment - _comments = [newComment, ..._comments]; + try { + await createComment($activePostId, commentValue); + commentValue = ''; + activeReplyToId = null; + } catch (err) { + console.error('Failed to create comment:', err); } - commentValue = ''; - activeReplyToId = null; }; + async function handleFollow(profileId: string) { + try { + await apiClient.post(`/api/users/${profileId}/follow`); + } catch (err) { + followError = err instanceof Error ? err.message : 'Failed to follow user'; + console.log(followError); + } + } + + async function fetchProfile() { + try { + loading = true; + const response = await apiClient.get(`/api/users/${ownerId}`); + profile = response.data; + console.log(JSON.stringify(profile)); + } catch (err) { + console.log(err instanceof Error ? err.message : 'Failed to load profile'); + } finally { + loading = false; + } + } + $effect(() => { listElement.addEventListener('scroll', onScroll); return () => listElement.removeEventListener('scroll', onScroll); }); + $effect(()=> { + if (showComments.value && activePostId) { + isCommentsLoading = true; + commentsError = null; + fetchComments($activePostId) + .catch((err) => { + commentsError = err.message; + }) + .finally(() => { + isCommentsLoading = false; + }); + } + }) + onMount(() => { + ownerId = getAuthId(); fetchFeed(); + fetchProfile(); }); @@ -78,6 +99,7 @@ { if (window.matchMedia('(max-width: 768px)').matches) { drawer?.present({ animate: true }); + showComments.value = true; + activePostId.set(post.id); } else { showComments.value = true; activePostId.set(post.id); @@ -101,6 +125,7 @@ }, menu: () => alert('menu') }} + options = {[{name: "Follow",handler: () => handleFollow(post.author.id)}]} /> {/each} @@ -109,26 +134,46 @@ - - {_comments.length} Comments - {#each _comments as comment} - - { - activeReplyToId = comment.commentId; - commentInput?.focus(); - }} - /> - - {/each} - - + {#if showComments.value} + + {$comments.length} Comments + {#if isCommentsLoading} + Loading comments... + {:else if commentsError} + {commentsError} + {:else} + {#each $comments as comment} + + { + activeReplyToId = comment.id; + commentInput?.focus(); + }} + /> + + {/each} + {/if} + + + {/if} diff --git a/platforms/pictique/src/routes/(protected)/messages/+page.svelte b/platforms/pictique/src/routes/(protected)/messages/+page.svelte index 1238578d..c58c5661 100644 --- a/platforms/pictique/src/routes/(protected)/messages/+page.svelte +++ b/platforms/pictique/src/routes/(protected)/messages/+page.svelte @@ -3,6 +3,8 @@ import { Message } from '$lib/fragments'; import { onMount } from 'svelte'; import { apiClient } from '$lib/utils/axios'; + import { heading } from '../../store'; + import Button from '$lib/ui/Button/Button.svelte'; let messages = $state([]); @@ -11,21 +13,21 @@ const { data: userData } = await apiClient.get('/api/users'); messages = data.chats.map((c) => { const members = c.participants.filter((u) => u.id !== userData.id); - console.log(members); const memberNames = members.map((m) => m.name ?? m.handle ?? m.ename); const avatar = members.length > 1 ? '/images/group.png' : members[0].avatarUrl; return { id: c.id, avatar, username: memberNames.join(', '), - unread: c.latestMessage.isRead, - text: c.latestMessage.text + unread: c.latestMessage ? c.latestMessage.isRead : false, + text: c.latestMessage?.text ?? 'No message yet' }; }); }); + {#if messages} {#each messages as message} goto(`/messages/${message.id}`)} + callback={() => { + heading.set(message.username); + goto(`/messages/${message.id}`); + }} /> {/each} + {:else} + + You have not started any conversations yet, find users and start a conversation with them. + await goto('/discover')}>Search User + + {/if} diff --git a/platforms/pictique/src/routes/(protected)/messages/[id]/+page.svelte b/platforms/pictique/src/routes/(protected)/messages/[id]/+page.svelte index e1ee2d8e..ca2529fb 100644 --- a/platforms/pictique/src/routes/(protected)/messages/[id]/+page.svelte +++ b/platforms/pictique/src/routes/(protected)/messages/[id]/+page.svelte @@ -39,6 +39,7 @@ eventSource.onmessage = function (e) { const data = JSON.parse(e.data); + console.log('messages', data); addMessages(data); // Use setTimeout to ensure DOM has updated setTimeout(scrollToBottom, 0); diff --git a/platforms/pictique/src/routes/(protected)/profile/+page.svelte b/platforms/pictique/src/routes/(protected)/profile/+page.svelte index 0fbba997..b396987d 100644 --- a/platforms/pictique/src/routes/(protected)/profile/+page.svelte +++ b/platforms/pictique/src/routes/(protected)/profile/+page.svelte @@ -1,2 +1,3 @@ - + diff --git a/platforms/pictique/src/routes/(protected)/profile/[id]/+page.svelte b/platforms/pictique/src/routes/(protected)/profile/[id]/+page.svelte index 2aeea589..063bb67b 100644 --- a/platforms/pictique/src/routes/(protected)/profile/[id]/+page.svelte +++ b/platforms/pictique/src/routes/(protected)/profile/[id]/+page.svelte @@ -2,16 +2,16 @@ import { goto } from '$app/navigation'; import { page } from '$app/state'; import { Profile } from '$lib/fragments'; - import { ownerId, selectedPost } from '$lib/store/store.svelte'; + import { selectedPost } from '$lib/store/store.svelte'; import type { userProfile, PostData } from '$lib/types'; - import { apiClient } from '$lib/utils/axios'; + import { apiClient, getAuthId } from '$lib/utils/axios'; import { onMount } from 'svelte'; - import Post from '../../../../lib/fragments/Post/Post.svelte'; let profileId = $derived(page.params.id); let profile = $state(null); let error = $state(null); let loading = $state(true); + let ownerId: string | null = $state(null); async function fetchProfile() { try { @@ -19,6 +19,7 @@ error = null; const response = await apiClient.get(`/api/users/${profileId}`); profile = response.data; + console.log(JSON.stringify(profile)); } catch (err) { error = err instanceof Error ? err.message : 'Failed to load profile'; } finally { @@ -38,7 +39,7 @@ async function handleMessage() { try { await apiClient.post(`/api/chats/`, { - name: profile.username, + name: profile?.username, participantIds: [profileId] }); goto('/messages'); @@ -52,6 +53,9 @@ selectedPost.value = post; goto('/profile/post'); } + $effect(()=> { + ownerId = getAuthId(); + }) onMount(fetchProfile); @@ -67,7 +71,7 @@ {:else if profile} handlePostClick(post)} {handleFollow} diff --git a/platforms/pictique/src/routes/(protected)/settings/+page.svelte b/platforms/pictique/src/routes/(protected)/settings/+page.svelte index 70433ebb..80b755ab 100644 --- a/platforms/pictique/src/routes/(protected)/settings/+page.svelte +++ b/platforms/pictique/src/routes/(protected)/settings/+page.svelte @@ -2,28 +2,60 @@ import { goto } from '$app/navigation'; import { page } from '$app/state'; import SettingsNavigationButton from '$lib/fragments/SettingsNavigationButton/SettingsNavigationButton.svelte'; + import type { userProfile } from '$lib/types'; + import { apiClient, getAuthId } from '$lib/utils'; import { DatabaseIcon, Logout01Icon, Notification02FreeIcons } from '@hugeicons/core-free-icons'; import { HugeiconsIcon } from '@hugeicons/svelte'; + import { onMount } from 'svelte'; let route = $derived(page.url.pathname); - let username: string = $state('_.ananyayaya._'); - let userEmail: string = $state('ananya@auvo.io'); - let userImage: string = $state('https://picsum.photos/200/300'); + let ownerId: string | null = $state(null); + + let profile = $state(null); + let error = $state(null); + let loading = $state(true); + + async function fetchProfile() { + try { + loading = true; + error = null; + const response = await apiClient.get(`/api/users/${ownerId}`); + profile = response.data; + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to load profile'; + } finally { + loading = false; + } + } + $effect(()=> { + ownerId = getAuthId(); + }) + onMount(fetchProfile) - goto(`/settings/account`)} profileSrc={userImage}> + {#if loading} + + Loading profile... + + {:else if error} + + {error} + + {:else if profile} + goto(`/settings/account`)} profileSrc={profile?.avatarUrl || 'https://picsum.photos/200/200'}> {#snippet children()} - {username} - {userEmail} + {profile?.handle || "Please add a username"} + {profile?.description || "please add a description"} {/snippet} + {/if} @@ -42,8 +74,12 @@ {/snippet} - - goto(`/settings/data-and-storage`)}> + + {#snippet leadingIcon()} (); + let saved = $state(false); function handleFileChange() { if (files && files[0]) { @@ -24,11 +27,17 @@ } async function saveProfileData() { - await apiClient.patch(`/api/users`, { - handle, - avatar: profileImageDataUrl, - name - }); + try { + await apiClient.patch(`/api/users/`, { + handle, + avatar: profileImageDataUrl, + name + }); + saved = true; + setTimeout(() => (saved = false), 3_000); + } catch (err) { + console.log(err instanceof Error ? err.message : 'please check the info again'); + } } $effect(() => { @@ -36,9 +45,20 @@ handleFileChange(); } }); + + onMount(async () => { + const { data } = await apiClient.get('/api/users'); + handle = data.handle; + name = data.name; + }); + {#if saved} + + Changes Saved! + + {/if} Change your profile picture @@ -17,5 +24,5 @@ {/snippet} - alert('logout')}>Logout - + Logout + \ No newline at end of file diff --git a/platforms/pictique/src/routes/+layout.svelte b/platforms/pictique/src/routes/+layout.svelte index d2583c88..fbd2bb6f 100644 --- a/platforms/pictique/src/routes/+layout.svelte +++ b/platforms/pictique/src/routes/+layout.svelte @@ -1,6 +1,6 @@
By continuing you agree to our Terms & Conditions and privacy policy.
+ By continuing you agree to our Terms & Conditions + + and + privacy policy. +
+ Already have a pre-verification code? click here +
Your eName is more than a name—it's your unique digital passport. One constant identifier that travels with you across the internet, connecting your real-world self to the digital universe.
+ Your eName is more than a name—it's your unique digital passport. One + constant identifier that travels with you across the internet, + connecting your real-world self to the digital universe. +
Enter Verification Code
+ Your eName is more than a name—it's your unique digital passport. + One constant identifier that travels with you across the internet, + connecting your real-world self to the digital universe. +
{comment.upVotes}
{comment.time}
{profileData.userBio}
{profileData?.description}
Loading profile...
{error}
{userEmail}
{profile?.description || "please add a description"}