From 0bd0e7c332f28e1a221d721a8d5d551d0580ebb4 Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Mon, 4 Aug 2025 04:41:54 +0000 Subject: [PATCH] Fix contact methods JSON string/array duality in PlatformServiceMixin - Add ContactMaybeWithJsonStrings type usage for internal database operations - Implement $normalizeContacts() method to handle both JSON string and array formats - Update $contacts(), $getContact(), and $getAllContacts() to use normalization - Fix $updateContact() to properly convert contactMethods arrays to JSON strings - Add validation to filter out malformed contact method objects - Update ContactEditView to handle malformed data gracefully Resolves issue where contactMethods could be stored as JSON strings in database but expected as arrays in components, causing "Cannot create property 'label' on number '0'" errors. --- src/db/tables/contacts.ts | 11 +++ src/utils/PlatformServiceMixin.ts | 118 +++++++++++++++++++++++------- src/views/ContactEditView.vue | 16 +++- 3 files changed, 117 insertions(+), 28 deletions(-) diff --git a/src/db/tables/contacts.ts b/src/db/tables/contacts.ts index 3dfa6e1b..cfb88798 100644 --- a/src/db/tables/contacts.ts +++ b/src/db/tables/contacts.ts @@ -32,6 +32,17 @@ export type ContactWithJsonStrings = Omit & { contactMethods?: string; }; +/** + * This is for those cases (eg. with a DB) where field values may be all primitives or may be JSON values. + * See src/db/databaseUtil.ts parseJsonField for more details. + * + * This is so that we can reuse most of the type and don't have to maintain another copy. + * Another approach uses typescript conditionals: https://chatgpt.com/share/6855cdc3-ab5c-8007-8525-726612016eb2 + */ +export type ContactMaybeWithJsonStrings = Omit & { + contactMethods?: string | Array; +}; + export const ContactSchema = { contacts: "&did, name", // no need to key by other things }; diff --git a/src/utils/PlatformServiceMixin.ts b/src/utils/PlatformServiceMixin.ts index 6baa8cc6..47a943c0 100644 --- a/src/utils/PlatformServiceMixin.ts +++ b/src/utils/PlatformServiceMixin.ts @@ -50,7 +50,7 @@ import { type SettingsWithJsonStrings, } from "@/db/tables/settings"; import { logger } from "@/utils/logger"; -import { Contact } from "@/db/tables/contacts"; +import { Contact, ContactMaybeWithJsonStrings } from "@/db/tables/contacts"; import { Account } from "@/db/tables/accounts"; import { Temp } from "@/db/tables/temp"; import { QueryExecResult, DatabaseExecResult } from "@/interfaces/database"; @@ -642,15 +642,81 @@ export const PlatformServiceMixin = { // CACHED SPECIALIZED SHORTCUTS (massive performance boost) // ================================================= + /** + * Normalize contact data by parsing JSON strings into proper objects + * Handles the contactMethods field which can be either a JSON string or an array + * @param rawContacts Raw contact data from database + * @returns Normalized Contact[] array + */ + $normalizeContacts(rawContacts: ContactMaybeWithJsonStrings[]): Contact[] { + return rawContacts.map((contact) => { + // Create a new contact object with proper typing + const normalizedContact: Contact = { + did: contact.did, + iViewContent: contact.iViewContent, + name: contact.name, + nextPubKeyHashB64: contact.nextPubKeyHashB64, + notes: contact.notes, + profileImageUrl: contact.profileImageUrl, + publicKeyBase64: contact.publicKeyBase64, + seesMe: contact.seesMe, + registered: contact.registered, + }; + + // Handle contactMethods field which can be a JSON string or an array + if (contact.contactMethods !== undefined) { + if (typeof contact.contactMethods === "string") { + // Parse JSON string into array + normalizedContact.contactMethods = this._parseJsonField( + contact.contactMethods, + [], + ); + } else if (Array.isArray(contact.contactMethods)) { + // Validate that each item in the array is a proper ContactMethod object + normalizedContact.contactMethods = contact.contactMethods.filter( + (method) => { + const isValid = + method && + typeof method === "object" && + typeof method.label === "string" && + typeof method.type === "string" && + typeof method.value === "string"; + + if (!isValid && method !== undefined) { + console.warn( + "[ContactNormalization] Invalid contact method:", + method, + ); + } + + return isValid; + }, + ); + } else { + // Invalid data, use empty array + normalizedContact.contactMethods = []; + } + } else { + // No contactMethods, use empty array + normalizedContact.contactMethods = []; + } + + return normalizedContact; + }); + }, + /** * Load all contacts (always fresh) - $contacts() * Always fetches fresh data from database for consistency - * @returns Promise Array of contact objects + * Handles JSON string/object duality for contactMethods field + * @returns Promise Array of normalized contact objects */ async $contacts(): Promise { - return (await this.$query( + const rawContacts = (await this.$query( "SELECT * FROM contacts ORDER BY name", - )) as Contact[]; + )) as ContactMaybeWithJsonStrings[]; + + return this.$normalizeContacts(rawContacts); }, /** @@ -1026,7 +1092,13 @@ export const PlatformServiceMixin = { Object.entries(changes).forEach(([key, value]) => { if (value !== undefined) { setParts.push(`${key} = ?`); - params.push(value); + + // Handle contactMethods field - convert array to JSON string + if (key === "contactMethods" && Array.isArray(value)) { + params.push(JSON.stringify(value)); + } else { + params.push(value); + } } }); @@ -1048,45 +1120,36 @@ export const PlatformServiceMixin = { /** * Get all contacts as typed objects - $getAllContacts() * Eliminates verbose query + mapping patterns - * @returns Promise Array of contact objects + * Handles JSON string/object duality for contactMethods field + * @returns Promise Array of normalized contact objects */ async $getAllContacts(): Promise { - const results = await this.$dbQuery( - "SELECT did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl FROM contacts ORDER BY name", - ); + const rawContacts = (await this.$query( + "SELECT * FROM contacts ORDER BY name", + )) as ContactMaybeWithJsonStrings[]; - return this.$mapResults(results, (row: unknown[]) => ({ - did: row[0] as string, - name: row[1] as string, - publicKeyBase64: row[2] as string, - seesMe: Boolean(row[3]), - registered: Boolean(row[4]), - nextPubKeyHashB64: row[5] as string, - profileImageUrl: row[6] as string, - })); + return this.$normalizeContacts(rawContacts); }, /** * Get single contact by DID - $getContact() * Eliminates verbose single contact query patterns + * Handles JSON string/object duality for contactMethods field * @param did Contact DID to retrieve - * @returns Promise Contact object or null if not found + * @returns Promise Normalized contact object or null if not found */ async $getContact(did: string): Promise { - const results = await this.$dbQuery( + const rawContacts = (await this.$query( "SELECT * FROM contacts WHERE did = ?", [did], - ); + )) as ContactMaybeWithJsonStrings[]; - if (!results || !results.values || results.values.length === 0) { + if (rawContacts.length === 0) { return null; } - const contactData = this._mapColumnsToValues( - results.columns, - results.values, - ); - return contactData.length > 0 ? (contactData[0] as Contact) : null; + const normalizedContacts = this.$normalizeContacts(rawContacts); + return normalizedContacts[0]; }, /** @@ -1681,6 +1744,7 @@ declare module "@vue/runtime-core" { $contactCount(): Promise; $settings(defaults?: Settings): Promise; $accountSettings(did?: string, defaults?: Settings): Promise; + $normalizeContacts(rawContacts: ContactMaybeWithJsonStrings[]): Contact[]; // Settings update shortcuts (eliminate 90% boilerplate) $saveSettings(changes: Partial): Promise; diff --git a/src/views/ContactEditView.vue b/src/views/ContactEditView.vue index 5b6f63a5..2c4cebfd 100644 --- a/src/views/ContactEditView.vue +++ b/src/views/ContactEditView.vue @@ -239,7 +239,21 @@ export default class ContactEditView extends Vue { this.contact = contact; this.contactName = contact.name || ""; this.contactNotes = contact.notes || ""; - this.contactMethods = contact.contactMethods || []; + + // Ensure contactMethods is a valid array of ContactMethod objects + if (Array.isArray(contact.contactMethods)) { + this.contactMethods = contact.contactMethods.filter((method) => { + return ( + method && + typeof method === "object" && + typeof method.label === "string" && + typeof method.type === "string" && + typeof method.value === "string" + ); + }); + } else { + this.contactMethods = []; + } } else { this.notify.error( `${NOTIFY_CONTACT_NOT_FOUND.message} ${contactDid}`,