Browse Source

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.
get-get-hash
Matthew Raymer 1 week ago
parent
commit
0bd0e7c332
  1. 11
      src/db/tables/contacts.ts
  2. 118
      src/utils/PlatformServiceMixin.ts
  3. 16
      src/views/ContactEditView.vue

11
src/db/tables/contacts.ts

@ -32,6 +32,17 @@ export type ContactWithJsonStrings = Omit<Contact, "contactMethods"> & {
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<Contact, "contactMethods"> & {
contactMethods?: string | Array<ContactMethod>;
};
export const ContactSchema = {
contacts: "&did, name", // no need to key by other things
};

118
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<Contact[]> Array of contact objects
* Handles JSON string/object duality for contactMethods field
* @returns Promise<Contact[]> Array of normalized contact objects
*/
async $contacts(): Promise<Contact[]> {
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<Contact[]> Array of contact objects
* Handles JSON string/object duality for contactMethods field
* @returns Promise<Contact[]> Array of normalized contact objects
*/
async $getAllContacts(): Promise<Contact[]> {
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 | null> Contact object or null if not found
* @returns Promise<Contact | null> Normalized contact object or null if not found
*/
async $getContact(did: string): Promise<Contact | null> {
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<number>;
$settings(defaults?: Settings): Promise<Settings>;
$accountSettings(did?: string, defaults?: Settings): Promise<Settings>;
$normalizeContacts(rawContacts: ContactMaybeWithJsonStrings[]): Contact[];
// Settings update shortcuts (eliminate 90% boilerplate)
$saveSettings(changes: Partial<Settings>): Promise<boolean>;

16
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}`,

Loading…
Cancel
Save