diff --git a/.cursor/rules/development/development_guide.mdc b/.cursor/rules/development/development_guide.mdc index a8065a31..a15190ae 100644 --- a/.cursor/rules/development/development_guide.mdc +++ b/.cursor/rules/development/development_guide.mdc @@ -2,7 +2,7 @@ globs: **/src/**/* alwaysApply: false --- -✅ use system date command to timestamp all interactions with accurate date and +✅ use system date command to timestamp all documentation with accurate date and time ✅ remove whitespace at the end of lines ✅ use npm run lint-fix to check for warnings diff --git a/.husky/pre-commit b/.husky/pre-commit index 9d7ede0a..b31dbcc0 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -9,6 +9,10 @@ echo "🔍 Running pre-commit hooks..." # Run lint-fix first echo "📝 Running lint-fix..." + +# Capture git status before lint-fix to detect changes +git_status_before=$(git status --porcelain) + npm run lint-fix || { echo echo "❌ Linting failed. Please fix the issues and try again." @@ -18,6 +22,36 @@ npm run lint-fix || { exit 1 } +# Check if lint-fix made any changes +git_status_after=$(git status --porcelain) + +if [ "$git_status_before" != "$git_status_after" ]; then + echo + echo "⚠️ lint-fix made changes to your files!" + echo "📋 Changes detected:" + git diff --name-only + echo + echo "❓ What would you like to do?" + echo " [c] Continue commit without the new changes" + echo " [a] Abort commit (recommended - review and stage the changes)" + echo + printf "Choose [c/a]: " + # The `< /dev/tty` is necessary to make read work in git's non-interactive shell + read choice < /dev/tty + + case $choice in + [Cc]* ) + echo "✅ Continuing commit without lint-fix changes..." + sleep 3 + ;; + [Aa]* | * ) + echo "🛑 Commit aborted. Please review the changes made by lint-fix." + echo "💡 You can stage the changes with 'git add .' and commit again." + exit 1 + ;; + esac +fi + # Then run Build Architecture Guard #echo "🏗️ Running Build Architecture Guard..." diff --git a/BUILDING.md b/BUILDING.md index 1ac4ae9d..1dd322e0 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -1158,10 +1158,10 @@ If you need to build manually or want to understand the individual steps: export GEM_PATH=$shortened_path ``` -##### 1. Bump the version in package.json, then here +##### 1. Bump the version in package.json for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version; ```bash - cd ios/App && xcrun agvtool new-version 40 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.0.7;/g" App.xcodeproj/project.pbxproj && cd - + cd ios/App && xcrun agvtool new-version 46 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.1;/g" App.xcodeproj/project.pbxproj && cd - # Unfortunately this edits Info.plist directly. #xcrun agvtool new-marketing-version 0.4.5 ``` @@ -1318,8 +1318,8 @@ The recommended way to build for Android is using the automated build script: ##### 1. Bump the version in package.json, then here: android/app/build.gradle ```bash - perl -p -i -e 's/versionCode .*/versionCode 40/g' android/app/build.gradle - perl -p -i -e 's/versionName .*/versionName "1.0.7"/g' android/app/build.gradle + perl -p -i -e 's/versionCode .*/versionCode 46/g' android/app/build.gradle + perl -p -i -e 's/versionName .*/versionName "1.1.1"/g' android/app/build.gradle ``` ##### 2. Build diff --git a/CHANGELOG.md b/CHANGELOG.md index 641ff920..ff6bd9b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.1.1] - 2025.11.03 + +### Added +- Meeting onboarding via prompts +- Emojis on gift feed +- Starred projects with notification + + ## [1.0.7] - 2025.08.18 ### Fixed diff --git a/android/app/build.gradle b/android/app/build.gradle index 4bb5486a..d37bbd98 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -31,8 +31,8 @@ android { applicationId "app.timesafari.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 41 - versionName "1.0.8" + versionCode 46 + versionName "1.1.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 66e82f41..c68a3087 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -403,7 +403,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 41; + CURRENT_PROJECT_VERSION = 46; DEVELOPMENT_TEAM = GM3FS5JQPH; ENABLE_APP_SANDBOX = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -413,7 +413,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.8; + MARKETING_VERSION = 1.1.1; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -430,7 +430,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 41; + CURRENT_PROJECT_VERSION = 46; DEVELOPMENT_TEAM = GM3FS5JQPH; ENABLE_APP_SANDBOX = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -440,7 +440,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.8; + MARKETING_VERSION = 1.1.1; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; diff --git a/package-lock.json b/package-lock.json index 914004eb..106e3223 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "timesafari", - "version": "1.1.1-beta", + "version": "1.1.2-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "timesafari", - "version": "1.1.1-beta", + "version": "1.1.2-beta", "dependencies": { "@capacitor-community/electron": "^5.0.1", "@capacitor-community/sqlite": "6.0.2", diff --git a/package.json b/package.json index a9587886..f4ef2136 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timesafari", - "version": "1.1.1-beta", + "version": "1.1.2-beta", "description": "Time Safari Application", "author": { "name": "Time Safari Team" diff --git a/src/assets/styles/tailwind.css b/src/assets/styles/tailwind.css index 396d6b96..f4e5b371 100644 --- a/src/assets/styles/tailwind.css +++ b/src/assets/styles/tailwind.css @@ -38,7 +38,7 @@ } .dialog { - @apply bg-white p-4 rounded-lg w-full max-w-lg max-h-[calc(100vh-3rem)] overflow-y-auto; + @apply bg-white px-4 py-6 rounded-lg w-full max-w-lg max-h-[90%] overflow-y-auto; } /* Markdown content styling to restore list elements */ diff --git a/src/components/BulkMembersDialog.vue b/src/components/BulkMembersDialog.vue new file mode 100644 index 00000000..412ade19 --- /dev/null +++ b/src/components/BulkMembersDialog.vue @@ -0,0 +1,506 @@ + + + diff --git a/src/components/FeedFilters.vue b/src/components/FeedFilters.vue index 91a0db6b..a69aaf0e 100644 --- a/src/components/FeedFilters.vue +++ b/src/components/FeedFilters.vue @@ -211,8 +211,6 @@ export default class FeedFilters extends Vue { } - diff --git a/src/components/MembersList.vue b/src/components/MembersList.vue index e26613bf..d1b10567 100644 --- a/src/components/MembersList.vue +++ b/src/components/MembersList.vue @@ -1,197 +1,256 @@ diff --git a/src/constants/notifications.ts b/src/constants/notifications.ts index 5cc75bd4..33d36e5d 100644 --- a/src/constants/notifications.ts +++ b/src/constants/notifications.ts @@ -510,14 +510,6 @@ export const NOTIFY_REGISTER_CONTACT = { text: "Do you want to register them?", }; -// Used in: ContactsView.vue (showOnboardMeetingDialog method - complex modal for onboarding meeting) -export const NOTIFY_ONBOARDING_MEETING = { - title: "Onboarding Meeting", - text: "Would you like to start a new meeting?", - yesText: "Start New Meeting", - noText: "Join Existing Meeting", -}; - // TestView.vue specific constants // Used in: TestView.vue (executeSql method - SQL error handling) export const NOTIFY_SQL_ERROR = { diff --git a/src/interfaces/common.ts b/src/interfaces/common.ts index f1f172e2..ec5226a7 100644 --- a/src/interfaces/common.ts +++ b/src/interfaces/common.ts @@ -70,15 +70,6 @@ export interface AxiosErrorResponse { [key: string]: unknown; } -export interface UserInfo { - did: string; - name: string; - publicEncKey: string; - registered: boolean; - profileImageUrl?: string; - nextPublicEncKeyHash?: string; -} - export interface CreateAndSubmitClaimResult { success: boolean; embeddedRecordError?: string; diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index c4b12191..8197df86 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -4,3 +4,4 @@ export * from "./common"; export * from "./deepLinks"; export * from "./limits"; export * from "./records"; +export * from "./user"; diff --git a/src/interfaces/user.ts b/src/interfaces/user.ts index a79d6a9c..c290dcb6 100644 --- a/src/interfaces/user.ts +++ b/src/interfaces/user.ts @@ -6,3 +6,12 @@ export interface UserInfo { profileImageUrl?: string; nextPublicEncKeyHash?: string; } + +export interface MemberData { + did: string; + name: string; + isContact: boolean; + member: { + memberId: string; + }; +} diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts index 08a65934..36bfa223 100644 --- a/src/libs/endorserServer.ts +++ b/src/libs/endorserServer.ts @@ -42,9 +42,6 @@ import { PlanActionClaim, RegisterActionClaim, TenureClaim, -} from "../interfaces/claims"; - -import { GenericCredWrapper, GenericVerifiableCredential, AxiosErrorResponse, @@ -55,14 +52,12 @@ import { QuantitativeValue, KeyMetaWithPrivate, KeyMetaMaybeWithPrivate, -} from "../interfaces/common"; -import { OfferSummaryRecord, OfferToPlanSummaryRecord, PlanSummaryAndPreviousClaim, PlanSummaryRecord, -} from "../interfaces/records"; -import { logger } from "../utils/logger"; +} from "../interfaces"; +import { logger, safeStringify } from "../utils/logger"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { APP_SERVER } from "@/constants/app"; import { SOMEONE_UNNAMED } from "@/constants/entities"; @@ -702,7 +697,7 @@ export function serverMessageForUser(error: unknown): string | undefined { export function errorStringForLog(error: unknown) { let stringifiedError = "" + error; try { - stringifiedError = JSON.stringify(error); + stringifiedError = safeStringify(error); } catch (e) { // can happen with Dexie, eg: // TypeError: Converting circular structure to JSON @@ -714,7 +709,7 @@ export function errorStringForLog(error: unknown) { if (error && typeof error === "object" && "response" in error) { const err = error as AxiosErrorResponse; - const errorResponseText = JSON.stringify(err.response); + const errorResponseText = safeStringify(err.response); // for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions) if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) { // add error.response stuff @@ -724,7 +719,7 @@ export function errorStringForLog(error: unknown) { R.equals(err.config, err.response.config) ) { // but exclude "config" because it's already in there - const newErrorResponseText = JSON.stringify( + const newErrorResponseText = safeStringify( R.omit(["config"] as never[], err.response), ); fullError += @@ -1662,30 +1657,35 @@ export async function register( message?: string; }>(url, { jwtEncoded: vcJwt }); - if (resp.data?.success?.handleId) { - return { success: true }; - } else if (resp.data?.success?.embeddedRecordError) { + if (resp.data?.success?.embeddedRecordError) { let message = "There was some problem with the registration and so it may not be complete."; if (typeof resp.data.success.embeddedRecordError === "string") { message += " " + resp.data.success.embeddedRecordError; } return { error: message }; + } else if (resp.data?.success?.handleId) { + return { success: true }; } else { - logger.error("Registration error:", JSON.stringify(resp.data)); - return { error: "Got a server error when registering." }; + logger.error("Registration non-thrown error:", JSON.stringify(resp.data)); + return { + error: + (resp.data?.error as { message?: string })?.message || + (resp.data?.error as string) || + "Got a server error when registering.", + }; } } catch (error: unknown) { if (error && typeof error === "object") { const err = error as AxiosErrorResponse; const errorMessage = - err.message || - (err.response?.data && - typeof err.response.data === "object" && - "message" in err.response.data - ? (err.response.data as { message: string }).message - : undefined); - logger.error("Registration error:", errorMessage || JSON.stringify(err)); + err.response?.data?.error?.message || + err.response?.data?.error || + err.message; + logger.error( + "Registration thrown error:", + errorMessage || JSON.stringify(err), + ); return { error: errorMessage || "Got a server error when registering." }; } return { error: "Got a server error when registering." }; diff --git a/src/libs/fontawesome.ts b/src/libs/fontawesome.ts index efd8ff03..947833e6 100644 --- a/src/libs/fontawesome.ts +++ b/src/libs/fontawesome.ts @@ -29,6 +29,7 @@ import { faCircle, faCircleCheck, faCircleInfo, + faCircleMinus, faCirclePlus, faCircleQuestion, faCircleRight, @@ -37,6 +38,7 @@ import { faCoins, faComment, faCopy, + faCrown, faDollar, faDownload, faEllipsis, @@ -58,6 +60,7 @@ import { faHand, faHandHoldingDollar, faHandHoldingHeart, + faHourglassHalf, faHouseChimney, faImage, faImagePortrait, @@ -123,6 +126,7 @@ library.add( faCircle, faCircleCheck, faCircleInfo, + faCircleMinus, faCirclePlus, faCircleQuestion, faCircleRight, @@ -131,6 +135,7 @@ library.add( faCoins, faComment, faCopy, + faCrown, faDollar, faDownload, faEllipsis, @@ -152,6 +157,7 @@ library.add( faHand, faHandHoldingDollar, faHandHoldingHeart, + faHourglassHalf, faHouseChimney, faImage, faImagePortrait, diff --git a/src/libs/util.ts b/src/libs/util.ts index 72dbf164..4790714d 100644 --- a/src/libs/util.ts +++ b/src/libs/util.ts @@ -988,11 +988,6 @@ export async function importFromMnemonic( ): Promise { const mne: string = mnemonic.trim().toLowerCase(); - // Check if this is Test User #0 - const TEST_USER_0_MNEMONIC = - "rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage"; - const isTestUser0 = mne === TEST_USER_0_MNEMONIC; - // Derive address and keys from mnemonic const [address, privateHex, publicHex] = deriveAddress(mne, derivationPath); @@ -1007,90 +1002,6 @@ export async function importFromMnemonic( // Save the new identity await saveNewIdentity(newId, mne, derivationPath); - - // Set up Test User #0 specific settings - if (isTestUser0) { - // Set up Test User #0 specific settings with enhanced error handling - const platformService = await getPlatformService(); - - try { - // First, ensure the DID-specific settings record exists - await platformService.insertNewDidIntoSettings(newId.did); - - // Then update with Test User #0 specific settings - await platformService.updateDidSpecificSettings(newId.did, { - firstName: "User Zero", - isRegistered: true, - }); - - // Verify the settings were saved correctly - const verificationResult = await platformService.dbQuery( - "SELECT firstName, isRegistered FROM settings WHERE accountDid = ?", - [newId.did], - ); - - if (verificationResult?.values?.length) { - const settings = verificationResult.values[0]; - const firstName = settings[0]; - const isRegistered = settings[1]; - - logger.debug( - "[importFromMnemonic] Test User #0 settings verification", - { - did: newId.did, - firstName, - isRegistered, - expectedFirstName: "User Zero", - expectedIsRegistered: true, - }, - ); - - // If settings weren't saved correctly, try individual updates - if (firstName !== "User Zero" || isRegistered !== 1) { - logger.warn( - "[importFromMnemonic] Test User #0 settings not saved correctly, retrying with individual updates", - ); - - await platformService.dbExec( - "UPDATE settings SET firstName = ? WHERE accountDid = ?", - ["User Zero", newId.did], - ); - - await platformService.dbExec( - "UPDATE settings SET isRegistered = ? WHERE accountDid = ?", - [1, newId.did], - ); - - // Verify again - const retryResult = await platformService.dbQuery( - "SELECT firstName, isRegistered FROM settings WHERE accountDid = ?", - [newId.did], - ); - - if (retryResult?.values?.length) { - const retrySettings = retryResult.values[0]; - logger.debug( - "[importFromMnemonic] Test User #0 settings after retry", - { - firstName: retrySettings[0], - isRegistered: retrySettings[1], - }, - ); - } - } - } else { - logger.error( - "[importFromMnemonic] Failed to verify Test User #0 settings - no record found", - ); - } - } catch (error) { - logger.error( - "[importFromMnemonic] Error setting up Test User #0 settings:", - error, - ); - // Don't throw - allow the import to continue even if settings fail - } - } } /** diff --git a/src/services/platforms/BaseDatabaseService.ts b/src/services/platforms/BaseDatabaseService.ts new file mode 100644 index 00000000..9f995c13 --- /dev/null +++ b/src/services/platforms/BaseDatabaseService.ts @@ -0,0 +1,297 @@ +/** + * @fileoverview Base Database Service for Platform Services + * @author Matthew Raymer + * + * This abstract base class provides common database operations that are + * identical across all platform implementations. It eliminates code + * duplication and ensures consistency in database operations. + * + * Key Features: + * - Common database utility methods + * - Consistent settings management + * - Active identity management + * - Abstract methods for platform-specific database operations + * + * Architecture: + * - Abstract base class with common implementations + * - Platform services extend this class + * - Platform-specific database operations remain abstract + * + * @since 1.1.1-beta + */ + +import { logger } from "../../utils/logger"; +import { QueryExecResult } from "@/interfaces/database"; + +/** + * Abstract base class for platform-specific database services. + * + * This class provides common database operations that are identical + * across all platform implementations (Web, Capacitor, Electron). + * Platform-specific services extend this class and implement the + * abstract database operation methods. + * + * Common Operations: + * - Settings management (update, retrieve, insert) + * - Active identity management + * - Database utility methods + * + * @abstract + * @example + * ```typescript + * export class WebPlatformService extends BaseDatabaseService { + * async dbQuery(sql: string, params?: unknown[]): Promise { + * // Web-specific implementation + * } + * } + * ``` + */ +export abstract class BaseDatabaseService { + /** + * Generate an INSERT statement for a model object. + * + * Creates a parameterized INSERT statement with placeholders for + * all properties in the model object. This ensures safe SQL + * execution and prevents SQL injection. + * + * @param model - Object containing the data to insert + * @param tableName - Name of the target table + * @returns Object containing the SQL statement and parameters + * + * @example + * ```typescript + * const { sql, params } = this.generateInsertStatement( + * { name: 'John', age: 30 }, + * 'users' + * ); + * // sql: "INSERT INTO users (name, age) VALUES (?, ?)" + * // params: ['John', 30] + * ``` + */ + generateInsertStatement( + model: Record, + tableName: string, + ): { sql: string; params: unknown[] } { + const keys = Object.keys(model); + const placeholders = keys.map(() => "?").join(", "); + const sql = `INSERT INTO ${tableName} (${keys.join(", ")}) VALUES (${placeholders})`; + const params = keys.map((key) => model[key]); + return { sql, params }; + } + + /** + * Update default settings for the currently active account. + * + * Retrieves the active DID from the active_identity table and updates + * the corresponding settings record. This ensures settings are always + * updated for the correct account. + * + * @param settings - Object containing the settings to update + * @returns Promise that resolves when settings are updated + * + * @throws {Error} If no active DID is found or database operation fails + * + * @example + * ```typescript + * await this.updateDefaultSettings({ + * theme: 'dark', + * notifications: true + * }); + * ``` + */ + async updateDefaultSettings( + settings: Record, + ): Promise { + // Get current active DID and update that identity's settings + const activeIdentity = await this.getActiveIdentity(); + const activeDid = activeIdentity.activeDid; + + if (!activeDid) { + logger.warn( + "[BaseDatabaseService] No active DID found, cannot update default settings", + ); + return; + } + + const keys = Object.keys(settings); + const setClause = keys.map((key) => `${key} = ?`).join(", "); + const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`; + const params = [...keys.map((key) => settings[key]), activeDid]; + await this.dbExec(sql, params); + } + + /** + * Update the active DID in the active_identity table. + * + * Sets the active DID and updates the lastUpdated timestamp. + * This is used when switching between different accounts/identities. + * + * @param did - The DID to set as active + * @returns Promise that resolves when the update is complete + * + * @example + * ```typescript + * await this.updateActiveDid('did:example:123'); + * ``` + */ + async updateActiveDid(did: string): Promise { + await this.dbExec( + "UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1", + [did], + ); + } + + /** + * Get the currently active DID from the active_identity table. + * + * Retrieves the active DID that represents the currently selected + * account/identity. This is used throughout the application to + * ensure operations are performed on the correct account. + * + * @returns Promise resolving to object containing the active DID + * + * @example + * ```typescript + * const { activeDid } = await this.getActiveIdentity(); + * console.log('Current active DID:', activeDid); + * ``` + */ + async getActiveIdentity(): Promise<{ activeDid: string }> { + const result = (await this.dbQuery( + "SELECT activeDid FROM active_identity WHERE id = 1", + )) as QueryExecResult; + return { + activeDid: (result?.values?.[0]?.[0] as string) || "", + }; + } + + /** + * Insert a new DID into the settings table with default values. + * + * Creates a new settings record for a DID with default configuration + * values. Uses INSERT OR REPLACE to handle cases where settings + * already exist for the DID. + * + * @param did - The DID to create settings for + * @returns Promise that resolves when settings are created + * + * @example + * ```typescript + * await this.insertNewDidIntoSettings('did:example:123'); + * ``` + */ + async insertNewDidIntoSettings(did: string): Promise { + // Import constants dynamically to avoid circular dependencies + const { DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER } = + await import("@/constants/app"); + + // Use INSERT OR REPLACE to handle case where settings already exist for this DID + // This prevents duplicate accountDid entries and ensures data integrity + await this.dbExec( + "INSERT OR REPLACE INTO settings (accountDid, finishedOnboarding, apiServer, partnerApiServer) VALUES (?, ?, ?, ?)", + [did, false, DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER], + ); + } + + /** + * Update settings for a specific DID. + * + * Updates settings for a particular DID rather than the active one. + * This is useful for bulk operations or when managing multiple accounts. + * + * @param did - The DID to update settings for + * @param settings - Object containing the settings to update + * @returns Promise that resolves when settings are updated + * + * @example + * ```typescript + * await this.updateDidSpecificSettings('did:example:123', { + * theme: 'light', + * notifications: false + * }); + * ``` + */ + async updateDidSpecificSettings( + did: string, + settings: Record, + ): Promise { + const keys = Object.keys(settings); + const setClause = keys.map((key) => `${key} = ?`).join(", "); + const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`; + const params = [...keys.map((key) => settings[key]), did]; + await this.dbExec(sql, params); + } + + /** + * Retrieve settings for the currently active account. + * + * Gets the active DID and retrieves all settings for that account. + * Excludes the 'id' column from the returned settings object. + * + * @returns Promise resolving to settings object or null if no active DID + * + * @example + * ```typescript + * const settings = await this.retrieveSettingsForActiveAccount(); + * if (settings) { + * console.log('Theme:', settings.theme); + * console.log('Notifications:', settings.notifications); + * } + * ``` + */ + async retrieveSettingsForActiveAccount(): Promise | null> { + // Get current active DID from active_identity table + const activeIdentity = await this.getActiveIdentity(); + const activeDid = activeIdentity.activeDid; + + if (!activeDid) { + return null; + } + + const result = (await this.dbQuery( + "SELECT * FROM settings WHERE accountDid = ?", + [activeDid], + )) as QueryExecResult; + if (result?.values?.[0]) { + // Convert the row to an object + const row = result.values[0]; + const columns = result.columns || []; + const settings: Record = {}; + + columns.forEach((column: string, index: number) => { + if (column !== "id") { + // Exclude the id column + settings[column] = row[index]; + } + }); + + return settings; + } + return null; + } + + // Abstract methods that must be implemented by platform-specific services + + /** + * Execute a database query (SELECT operations). + * + * @abstract + * @param sql - SQL query string + * @param params - Optional parameters for prepared statements + * @returns Promise resolving to query results + */ + abstract dbQuery(sql: string, params?: unknown[]): Promise; + + /** + * Execute a database statement (INSERT, UPDATE, DELETE operations). + * + * @abstract + * @param sql - SQL statement string + * @param params - Optional parameters for prepared statements + * @returns Promise resolving to execution results + */ + abstract dbExec(sql: string, params?: unknown[]): Promise; +} diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts index 2db74656..51fb9ce5 100644 --- a/src/services/platforms/CapacitorPlatformService.ts +++ b/src/services/platforms/CapacitorPlatformService.ts @@ -22,6 +22,7 @@ import { PlatformCapabilities, } from "../PlatformService"; import { logger } from "../../utils/logger"; +import { BaseDatabaseService } from "./BaseDatabaseService"; interface QueuedOperation { type: "run" | "query" | "rawQuery"; @@ -39,7 +40,10 @@ interface QueuedOperation { * - Platform-specific features * - SQLite database operations */ -export class CapacitorPlatformService implements PlatformService { +export class CapacitorPlatformService + extends BaseDatabaseService + implements PlatformService +{ /** Current camera direction */ private currentDirection: CameraDirection = CameraDirection.Rear; @@ -52,6 +56,7 @@ export class CapacitorPlatformService implements PlatformService { private isProcessingQueue: boolean = false; constructor() { + super(); this.sqlite = new SQLiteConnection(CapacitorSQLite); } @@ -1328,79 +1333,8 @@ export class CapacitorPlatformService implements PlatformService { // --- PWA/Web-only methods (no-op for Capacitor) --- public registerServiceWorker(): void {} - // Database utility methods - generateInsertStatement( - model: Record, - tableName: string, - ): { sql: string; params: unknown[] } { - const keys = Object.keys(model); - const placeholders = keys.map(() => "?").join(", "); - const sql = `INSERT INTO ${tableName} (${keys.join(", ")}) VALUES (${placeholders})`; - const params = keys.map((key) => model[key]); - return { sql, params }; - } - - async updateDefaultSettings( - settings: Record, - ): Promise { - const keys = Object.keys(settings); - const setClause = keys.map((key) => `${key} = ?`).join(", "); - const sql = `UPDATE settings SET ${setClause} WHERE id = 1`; - const params = keys.map((key) => settings[key]); - await this.dbExec(sql, params); - } - - async updateActiveDid(did: string): Promise { - await this.dbExec( - "UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1", - [did], - ); - } - - async insertNewDidIntoSettings(did: string): Promise { - // Import constants dynamically to avoid circular dependencies - const { DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER } = - await import("@/constants/app"); - - // Use INSERT OR REPLACE to handle case where settings already exist for this DID - // This prevents duplicate accountDid entries and ensures data integrity - await this.dbExec( - "INSERT OR REPLACE INTO settings (accountDid, finishedOnboarding, apiServer, partnerApiServer) VALUES (?, ?, ?, ?)", - [did, false, DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER], - ); - } - - async updateDidSpecificSettings( - did: string, - settings: Record, - ): Promise { - const keys = Object.keys(settings); - const setClause = keys.map((key) => `${key} = ?`).join(", "); - const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`; - const params = [...keys.map((key) => settings[key]), did]; - await this.dbExec(sql, params); - } - - async retrieveSettingsForActiveAccount(): Promise | null> { - const result = await this.dbQuery("SELECT * FROM settings WHERE id = 1"); - if (result?.values?.[0]) { - // Convert the row to an object - const row = result.values[0]; - const columns = result.columns || []; - const settings: Record = {}; - - columns.forEach((column, index) => { - if (column !== "id") { - // Exclude the id column - settings[column] = row[index]; - } - }); - - return settings; - } - return null; - } + // Database utility methods - inherited from BaseDatabaseService + // generateInsertStatement, updateDefaultSettings, updateActiveDid, + // getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings, + // retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService } diff --git a/src/services/platforms/WebPlatformService.ts b/src/services/platforms/WebPlatformService.ts index b5b25622..da573837 100644 --- a/src/services/platforms/WebPlatformService.ts +++ b/src/services/platforms/WebPlatformService.ts @@ -5,6 +5,7 @@ import { } from "../PlatformService"; import { logger } from "../../utils/logger"; import { QueryExecResult } from "@/interfaces/database"; +import { BaseDatabaseService } from "./BaseDatabaseService"; // Dynamic import of initBackend to prevent worker context errors import type { WorkerRequest, @@ -29,7 +30,10 @@ import type { * Note: File system operations are not available in the web platform * due to browser security restrictions. These methods throw appropriate errors. */ -export class WebPlatformService implements PlatformService { +export class WebPlatformService + extends BaseDatabaseService + implements PlatformService +{ private static instanceCount = 0; // Debug counter private worker: Worker | null = null; private workerReady = false; @@ -46,6 +50,7 @@ export class WebPlatformService implements PlatformService { private readonly messageTimeout = 30000; // 30 seconds constructor() { + super(); WebPlatformService.instanceCount++; logger.debug("[WebPlatformService] Initializing web platform service"); @@ -668,105 +673,8 @@ export class WebPlatformService implements PlatformService { // SharedArrayBuffer initialization is handled by initBackend call in initializeWorker } - // Database utility methods - generateInsertStatement( - model: Record, - tableName: string, - ): { sql: string; params: unknown[] } { - const keys = Object.keys(model); - const placeholders = keys.map(() => "?").join(", "); - const sql = `INSERT INTO ${tableName} (${keys.join(", ")}) VALUES (${placeholders})`; - const params = keys.map((key) => model[key]); - return { sql, params }; - } - - async updateDefaultSettings( - settings: Record, - ): Promise { - // Get current active DID and update that identity's settings - const activeIdentity = await this.getActiveIdentity(); - const activeDid = activeIdentity.activeDid; - - if (!activeDid) { - logger.warn( - "[WebPlatformService] No active DID found, cannot update default settings", - ); - return; - } - - const keys = Object.keys(settings); - const setClause = keys.map((key) => `${key} = ?`).join(", "); - const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`; - const params = [...keys.map((key) => settings[key]), activeDid]; - await this.dbExec(sql, params); - } - - async updateActiveDid(did: string): Promise { - await this.dbExec( - "INSERT OR REPLACE INTO active_identity (id, activeDid, lastUpdated) VALUES (1, ?, ?)", - [did, new Date().toISOString()], - ); - } - - async getActiveIdentity(): Promise<{ activeDid: string }> { - const result = await this.dbQuery( - "SELECT activeDid FROM active_identity WHERE id = 1", - ); - return { - activeDid: (result?.values?.[0]?.[0] as string) || "", - }; - } - - async insertNewDidIntoSettings(did: string): Promise { - // Import constants dynamically to avoid circular dependencies - const { DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER } = - await import("@/constants/app"); - - // Use INSERT OR REPLACE to handle case where settings already exist for this DID - // This prevents duplicate accountDid entries and ensures data integrity - await this.dbExec( - "INSERT OR REPLACE INTO settings (accountDid, finishedOnboarding, apiServer, partnerApiServer) VALUES (?, ?, ?, ?)", - [did, false, DEFAULT_ENDORSER_API_SERVER, DEFAULT_PARTNER_API_SERVER], - ); - } - - async updateDidSpecificSettings( - did: string, - settings: Record, - ): Promise { - const keys = Object.keys(settings); - const setClause = keys.map((key) => `${key} = ?`).join(", "); - const sql = `UPDATE settings SET ${setClause} WHERE accountDid = ?`; - const params = [...keys.map((key) => settings[key]), did]; - // Log update operation for debugging - logger.debug( - "[WebPlatformService] updateDidSpecificSettings", - sql, - JSON.stringify(params, null, 2), - ); - await this.dbExec(sql, params); - } - - async retrieveSettingsForActiveAccount(): Promise | null> { - const result = await this.dbQuery("SELECT * FROM settings WHERE id = 1"); - if (result?.values?.[0]) { - // Convert the row to an object - const row = result.values[0]; - const columns = result.columns || []; - const settings: Record = {}; - - columns.forEach((column, index) => { - if (column !== "id") { - // Exclude the id column - settings[column] = row[index]; - } - }); - - return settings; - } - return null; - } + // Database utility methods - inherited from BaseDatabaseService + // generateInsertStatement, updateDefaultSettings, updateActiveDid, + // getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings, + // retrieveSettingsForActiveAccount are all inherited from BaseDatabaseService } diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index e7f698a3..9b7efd3e 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -1488,18 +1488,21 @@ export default class AccountViewView extends Vue { status?: number; }; }; - logger.error("[Server Limits] Error retrieving limits:", { - error: error instanceof Error ? error.message : String(error), - did: did, - apiServer: this.apiServer, - imageServer: this.DEFAULT_IMAGE_API_SERVER, - partnerApiServer: this.partnerApiServer, - errorCode: axiosError?.response?.data?.error?.code, - errorMessage: axiosError?.response?.data?.error?.message, - httpStatus: axiosError?.response?.status, - needsUserMigration: true, - timestamp: new Date().toISOString(), - }); + logger.warn( + "[Server Limits] Error retrieving limits, expected for unregistered users:", + { + error: error instanceof Error ? error.message : String(error), + did: did, + apiServer: this.apiServer, + imageServer: this.DEFAULT_IMAGE_API_SERVER, + partnerApiServer: this.partnerApiServer, + errorCode: axiosError?.response?.data?.error?.code, + errorMessage: axiosError?.response?.data?.error?.message, + httpStatus: axiosError?.response?.status, + needsUserMigration: true, + timestamp: new Date().toISOString(), + }, + ); // this.notify.error(this.limitsMessage, TIMEOUTS.STANDARD); } finally { diff --git a/src/views/ContactEditView.vue b/src/views/ContactEditView.vue index 51687b5b..a3ec73ce 100644 --- a/src/views/ContactEditView.vue +++ b/src/views/ContactEditView.vue @@ -346,9 +346,7 @@ export default class ContactEditView extends Vue { // Notify success and redirect this.notify.success(NOTIFY_CONTACT_SAVED.message, TIMEOUTS.STANDARD); - (this.$router as Router).push({ - path: "/did/" + encodeURIComponent(this.contact?.did || ""), - }); + this.$router.back(); } } diff --git a/src/views/ContactsView.vue b/src/views/ContactsView.vue index e31cb708..eebd8049 100644 --- a/src/views/ContactsView.vue +++ b/src/views/ContactsView.vue @@ -171,9 +171,11 @@ import { CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI, CONTACT_URL_PATH_ENDORSER_CH_OLD, } from "../libs/endorserServer"; -import { GiveSummaryRecord } from "@/interfaces/records"; -import { UserInfo } from "@/interfaces/common"; -import { VerifiableCredential } from "@/interfaces/claims-result"; +import { + GiveSummaryRecord, + UserInfo, + VerifiableCredential, +} from "@/interfaces"; import * as libsUtil from "../libs/util"; import { generateSaveAndActivateIdentity, diff --git a/src/views/DIDView.vue b/src/views/DIDView.vue index f6acf31c..8d67961c 100644 --- a/src/views/DIDView.vue +++ b/src/views/DIDView.vue @@ -12,20 +12,20 @@ - - + - - + @@ -476,7 +476,7 @@ export default class DIDView extends Vue { * Navigation helper methods */ goBack() { - this.$router.go(-1); + this.$router.back(); } /** diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 9f087d85..718a731f 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -706,7 +706,7 @@ export default class HomeView extends Vue { }; logger.warn( - "[HomeView Settings Trace] ⚠️ Registration check failed", + "[HomeView Settings Trace] ⚠️ Registration check failed, expected for unregistered users.", { error: errorMessage, did: this.activeDid, diff --git a/src/views/OnboardMeetingListView.vue b/src/views/OnboardMeetingListView.vue index 452b64a9..3c9aef71 100644 --- a/src/views/OnboardMeetingListView.vue +++ b/src/views/OnboardMeetingListView.vue @@ -77,7 +77,7 @@ v-if="meetings.length === 0 && !isRegistered" class="text-center text-gray-500 py-8" > - No onboarding meetings available + No onboarding meetings are available

diff --git a/src/views/OnboardMeetingSetupView.vue b/src/views/OnboardMeetingSetupView.vue index e70148f5..33d345f0 100644 --- a/src/views/OnboardMeetingSetupView.vue +++ b/src/views/OnboardMeetingSetupView.vue @@ -473,6 +473,7 @@ export default class OnboardMeetingView extends Vue { ); return; } + const password: string = this.newOrUpdatedMeetingInputs.password; // create content with user's name & DID encrypted with password const content = { @@ -482,7 +483,7 @@ export default class OnboardMeetingView extends Vue { }; const encryptedContent = await encryptMessage( JSON.stringify(content), - this.newOrUpdatedMeetingInputs.password, + password, ); const headers = await getHeaders(this.activeDid); @@ -505,6 +506,11 @@ export default class OnboardMeetingView extends Vue { this.newOrUpdatedMeetingInputs = null; this.notify.success(NOTIFY_MEETING_CREATED.message, TIMEOUTS.STANDARD); + // redirect to the same page with the password parameter set + this.$router.push({ + name: "onboard-meeting-setup", + query: { password: password }, + }); } else { throw { response: response }; } diff --git a/test-playwright/testUtils.ts b/test-playwright/testUtils.ts index 67923ba1..321f9343 100644 --- a/test-playwright/testUtils.ts +++ b/test-playwright/testUtils.ts @@ -49,6 +49,10 @@ export async function importUserFromAccount(page: Page, id?: string): Promise { await expect( page.locator("#sectionUsageLimits").getByText("Checking") ).toBeHidden(); + + // PHASE 1 FIX: Wait for registration check to complete and update UI elements + // This ensures that components like InviteOneView have the correct isRegistered status + await waitForRegistrationStatusToSettle(page); + return did; } @@ -337,3 +346,78 @@ export function getElementWaitTimeout(): number { export function getPageLoadTimeout(): number { return getAdaptiveTimeout(30000, 1.4); } + +/** + * PHASE 1 FIX: Wait for registration status to settle + * + * This function addresses the timing issue where: + * 1. User imports identity → Database shows isRegistered: false + * 2. HomeView loads → Starts async registration check + * 3. Other views load → Use cached isRegistered: false + * 4. Async check completes → Updates database to isRegistered: true + * 5. But other views don't re-check → Plus buttons don't appear + * + * This function waits for the async registration check to complete + * without interfering with test navigation. + */ +export async function waitForRegistrationStatusToSettle(page: Page): Promise { + try { + // Wait for the initial registration check to complete + // This is indicated by the "Checking" text disappearing from usage limits + await expect( + page.locator("#sectionUsageLimits").getByText("Checking") + ).toBeHidden({ timeout: 15000 }); + + // Before navigating back to the page, we'll trigger a registration check + // by navigating to home and waiting for the registration process to complete + + const currentUrl = page.url(); + + // Navigate to home to trigger the registration check + await page.goto('./'); + await page.waitForLoadState('networkidle'); + + // Wait for the registration check to complete by monitoring the usage limits section + // This ensures the async registration check has finished + await page.waitForFunction(() => { + const usageLimits = document.querySelector('#sectionUsageLimits'); + if (!usageLimits) return true; // No usage limits section, assume ready + + // Check if the "Checking..." spinner is gone + const checkingSpinner = usageLimits.querySelector('.fa-spin'); + if (checkingSpinner) return false; // Still loading + + // Check if we have actual content (not just the spinner) + const hasContent = usageLimits.querySelector('p') || usageLimits.querySelector('button'); + return hasContent !== null; // Has actual content, not just spinner + }, { timeout: 10000 }); + + // Also navigate to account page to ensure activeDid is set and usage limits are loaded + await page.goto('./account'); + await page.waitForLoadState('networkidle'); + + // Wait for the usage limits section to be visible and loaded + await page.waitForFunction(() => { + const usageLimits = document.querySelector('#sectionUsageLimits'); + if (!usageLimits) return false; // Section should exist on account page + + // Check if the "Checking..." spinner is gone + const checkingSpinner = usageLimits.querySelector('.fa-spin'); + if (checkingSpinner) return false; // Still loading + + // Check if we have actual content (not just the spinner) + const hasContent = usageLimits.querySelector('p') || usageLimits.querySelector('button'); + return hasContent !== null; // Has actual content, not just spinner + }, { timeout: 15000 }); + + // Navigate back to the original page if it wasn't home + if (!currentUrl.includes('/')) { + await page.goto(currentUrl); + await page.waitForLoadState('networkidle'); + } + + } catch (error) { + // Registration status check timed out, continuing anyway + // This may indicate the user is not registered or there's a server issue + } +}