diff --git a/src/components/Modals/ContactFormModal.jsx b/src/components/Modals/ContactFormModal.jsx index 0bb53108d..90f0367ee 100644 --- a/src/components/Modals/ContactFormModal.jsx +++ b/src/components/Modals/ContactFormModal.jsx @@ -67,7 +67,7 @@ const ContactFormModal = () => { try { await createOrUpdateContact({ client, - isUpdated: !!currentContact, + oldContact: currentContact, formData, selectedGroup }) diff --git a/src/components/Modals/ContactFormModal.spec.jsx b/src/components/Modals/ContactFormModal.spec.jsx index 6338613c3..3ad130652 100644 --- a/src/components/Modals/ContactFormModal.spec.jsx +++ b/src/components/Modals/ContactFormModal.spec.jsx @@ -141,7 +141,7 @@ describe('ContactFormModal component', () => { expect(createOrUpdateContact).toBeCalledWith({ client: expect.anything(), - isUpdated: false, + oldContact: undefined, formData: expected, selectedGroup: expect.anything() }) @@ -224,7 +224,13 @@ describe('ContactFormModal component', () => { expect(createOrUpdateContact).toHaveBeenCalledWith({ client: expect.anything(), - isUpdated: true, + oldContact: { + _id: 'ID', + name: { + familyName: 'John', + givenName: 'Doe' + } + }, formData: expected, selectedGroup: expect.anything() }) diff --git a/src/connections/allContacts.js b/src/connections/allContacts.js index 921faa402..0cbc0c93a 100644 --- a/src/connections/allContacts.js +++ b/src/connections/allContacts.js @@ -4,6 +4,10 @@ import uniqWith from 'lodash/uniqWith' import minilog from 'cozy-minilog' +import { + getRelatedRelationshipsToUpdate, + updateRelatedRelationships +} from './relatedRelationships' import { addGroupToContact } from '../helpers/contacts' import { DOCTYPE_CONTACTS } from '../helpers/doctypes' import { hasSelectedGroup } from '../helpers/groups' @@ -77,14 +81,14 @@ export const deleteTrashedContacts = async client => { /** * @param {Object} params * @param {import('cozy-client/types/CozyClient').default} params.client - CozyClient - * @param {boolean} params.isUpdated - Is the contact updated + * @param {import('cozy-client/types/types').ContactDocument} params.oldContact - Is the contact updated * @param {Object} params.formData - Contact data * @param {Object} params.selectedGroup - Selected group * @returns {Promise} - Contact */ export const createOrUpdateContact = async ({ client, - isUpdated, + oldContact, formData, selectedGroup }) => { @@ -93,9 +97,31 @@ export const createOrUpdateContact = async ({ if (hasSelectedGroup(selectedGroup)) { updatedContact = addGroupToContact(updatedContact, selectedGroup) } - return isUpdated - ? client.save(updatedContact) - : client.create(DOCTYPE_CONTACTS, updatedContact) + + const relatedRelationships = getRelatedRelationshipsToUpdate( + oldContact, + updatedContact + ) + + const { data: originalContact } = oldContact + ? await client.save(updatedContact) + : await client.create(DOCTYPE_CONTACTS, updatedContact) + + if (relatedRelationships) { + try { + const relatedContactsToSaved = await updateRelatedRelationships({ + client, + relatedRelationships, + originalContactId: originalContact._id + }) + + client.saveAll(relatedContactsToSaved) + } catch (error) { + log.error('Error updating related contacts', error) + } + } + + return originalContact } /** diff --git a/src/connections/relatedRelationships.js b/src/connections/relatedRelationships.js new file mode 100644 index 000000000..b87c22f98 --- /dev/null +++ b/src/connections/relatedRelationships.js @@ -0,0 +1,263 @@ +import difference from 'lodash/difference' +import isEqual from 'lodash/isEqual' + +import { + getHasManyItems, + setHasManyItem, + removeHasManyItem +} from 'cozy-client/dist/associations/HasMany' +import minilog from 'cozy-minilog' + +import { DOCTYPE_CONTACTS } from '../helpers/doctypes' +import { buildContactsQueryById } from '../queries/queries' + +const log = minilog('connections/relatedRelationships') + +const RELATED_RELATION_TYPES_MAPPING = { + parent: 'child', + child: 'parent', + sibling: 'sibling', + spouse: 'spouse', + coResident: 'coResident', + friend: 'friend', + colleague: 'colleague', + coWorker: 'coWorker', + acquaintance: 'acquaintance', + helper: 'recipient', + recipient: 'helper', + related: 'related' // default value +} + +/** + * @param {string[]} relationTypes - Relation types + */ +const getRelationTypesMapping = relationTypes => { + return relationTypes.map(relationType => { + return RELATED_RELATION_TYPES_MAPPING[relationType] + }) +} + +/** + * @param {import('cozy-client/types/types').IOCozyContact} oldContact - Old contact + * @param {import('cozy-client/types/types').IOCozyContact} updatedContact - Updated contact + * @returns {boolean} - True if related relationships are the same + */ +export const isSameRelatedRelationships = (oldContact, updatedContact) => { + return isEqual( + getHasManyItems(oldContact, 'related'), + getHasManyItems(updatedContact, 'related') + ) +} + +/** + * @param {import('cozy-client/types/types').IOCozyContact} oldContact - Old contact + * @param {import('cozy-client/types/types').IOCozyContact} updatedContact - Updated contact + * @returns {import('../types').RelatedRelationshipsToUpdate[]|null} - Related relationships to update + */ +export const getRelatedRelationshipsToUpdate = (oldContact, updatedContact) => { + const isSameRelatedContactRelationships = isSameRelatedRelationships( + oldContact, + updatedContact + ) + if (isSameRelatedContactRelationships) { + return null + } + + const newRelatedRel = getHasManyItems(updatedContact, 'related') + const oldRelatedRel = getHasManyItems(oldContact, 'related') + const relatedRelToUpdate = [] + + // Find updated or deleted related contacts + oldRelatedRel.forEach(oldRel => { + const newRel = newRelatedRel.find(newRel => newRel._id === oldRel._id) + + if (!newRel) { + relatedRelToUpdate.push({ + type: 'DELETE', + relation: oldRel + }) + } else { + const oldRelationTypes = oldRel.metadata.relationTypes + const newRelationTypes = newRel.metadata.relationTypes + + const addedRelationTypes = difference(newRelationTypes, oldRelationTypes) + const removedRelationTypes = difference( + oldRelationTypes, + newRelationTypes + ) + + if (addedRelationTypes.length > 0 || removedRelationTypes.length > 0) { + relatedRelToUpdate.push({ + type: 'UPDATE', + relation: newRel + }) + } + } + }) + + // Find new related contacts + newRelatedRel.forEach(newRel => { + const oldRel = oldRelatedRel.find(oldRel => oldRel._id === newRel._id) + if (!oldRel) { + relatedRelToUpdate.push({ + type: 'CREATE', + relation: newRel + }) + } + }) + + if (relatedRelToUpdate.length === 0) { + return null + } + + return relatedRelToUpdate +} + +/** + * Make the new related object converted for the related contact + * @param {import('cozy-client/types/types').IOCozyContact} originalContactRelation - Original contact relation + * @param {string} originalContactId - Original contact id + */ +export const makeRelationMapping = ( + originalContactRelation, + originalContactId +) => { + const relationTypesMapping = getRelationTypesMapping( + originalContactRelation.metadata.relationTypes + ) + return { + _id: originalContactId, + _type: DOCTYPE_CONTACTS, + metadata: { + relationTypes: relationTypesMapping + } + } +} + +/** + * @param {Object} params + * @param {import('cozy-client/types/CozyClient').default} params.client - CozyClient + * @param {import('../types').RelatedRelationships} params.relation - Related relationships to update + * @param {string} params.originalContactId - Original contact id + * @returns {Promise} - Related contacts + */ +export const createRelatedContact = async ({ + client, + relation, + originalContactId +}) => { + try { + const contactsQueryById = buildContactsQueryById(relation._id) + const { data: relatedContact } = await client.query( + contactsQueryById.definition() + ) + const relationConverted = makeRelationMapping(relation, originalContactId) + const updatedRelatedContact = setHasManyItem( + relatedContact, + 'related', + originalContactId, + relationConverted + ) + + return updatedRelatedContact + } catch (error) { + log.error('Error creating related contact', error) + } +} + +/** + * @param {Object} params + * @param {import('cozy-client/types/CozyClient').default} params.client - CozyClient + * @param {import('../types').RelatedRelationships} params.relation - Related relationships to update + * @param {string} params.originalContactId - Original contact id + * @returns {Promise} - Related contacts + */ +export const updateRelatedContact = async ({ + client, + relation, + originalContactId +}) => { + try { + const contactsQueryById = buildContactsQueryById(relation._id) + const { data: relatedContact } = await client.query( + contactsQueryById.definition() + ) + const relationConverted = makeRelationMapping(relation, originalContactId) + const updatedRelatedContact = setHasManyItem( + relatedContact, + 'related', + originalContactId, + relationConverted + ) + + return updatedRelatedContact + } catch (error) { + log.error('Error updating related contact', error) + } +} + +/** + * @param {Object} params + * @param {import('cozy-client/types/CozyClient').default} params.client - CozyClient + * @param {import('../types').RelatedRelationships} params.relation - Related relationships to update + * @param {string} params.originalContactId - Original contact id + * @returns {Promise} - Related contacts + */ +export const deleteRelatedContact = async ({ + client, + relation, + originalContactId +}) => { + try { + const contactsQueryById = buildContactsQueryById(relation._id) + const { data: relatedContact } = await client.query( + contactsQueryById.definition() + ) + const updatedRelatedContact = removeHasManyItem( + relatedContact, + 'related', + originalContactId + ) + + return updatedRelatedContact + } catch (error) { + log.error('Error deleting related contact', error) + } +} + +/** + * @param {Object} params + * @param {import('cozy-client/types/CozyClient').default} params.client - CozyClient + * @param {import('../types').RelatedRelationshipsToUpdate[]} params.relatedRelationships - Related relationships to update + * @param {import('cozy-client/types/types').IOCozyContact} params.originalContact - Original contact + */ +export const updateRelatedRelationships = async ({ + client, + relatedRelationships, + originalContactId +}) => { + return Promise.all( + relatedRelationships.map(relatedRel => { + switch (relatedRel.type) { + case 'CREATE': + return createRelatedContact({ + client, + relation: relatedRel.relation, + originalContactId + }) + case 'UPDATE': + return updateRelatedContact({ + client, + relation: relatedRel.relation, + originalContactId + }) + case 'DELETE': + return deleteRelatedContact({ + client, + relation: relatedRel.relation, + originalContactId + }) + } + }) + ) +} diff --git a/src/connections/relatedRelationships.spec.js b/src/connections/relatedRelationships.spec.js new file mode 100644 index 000000000..4f9511237 --- /dev/null +++ b/src/connections/relatedRelationships.spec.js @@ -0,0 +1,205 @@ +import { + getRelatedRelationshipsToUpdate, + makeRelationMapping, + isSameRelatedRelationships +} from './relatedRelationships' +import { DOCTYPE_CONTACTS } from '../helpers/doctypes' + +describe('relatedRelationships', () => { + describe('getRelatedRelationshipsToUpdate', () => { + it('should return "null" if oldContact and updatedContact don\'t have related relationships', () => { + const oldContact = { + _id: 'contact0', + name: 'Alice' + } + const updatedContact = { + _id: 'contact0', + name: 'Alice' + } + + expect( + getRelatedRelationshipsToUpdate(oldContact, updatedContact) + ).toBeNull() + }) + + it('should return "null" if the related relationships of oldContact and updatedContact have not changed.', () => { + const oldContact = { + _id: 'contact0', + name: 'Alice', + relationships: { + related: { + data: [{ _id: 'related0', metadata: { relationTypes: ['spouse'] } }] + } + } + } + const updatedContact = { + _id: 'contact0', + name: 'Alice', + relationships: { + related: { + data: [{ _id: 'related0', metadata: { relationTypes: ['spouse'] } }] + } + } + } + + expect( + getRelatedRelationshipsToUpdate(oldContact, updatedContact) + ).toBeNull() + }) + + it('should return new related relationships to update', () => { + const oldContact = { + relationships: { + related: { + data: [{ _id: 'related0', metadata: { relationTypes: ['friend'] } }] + } + } + } + const updatedContact = { + relationships: { + related: { + data: [ + { _id: 'related0', metadata: { relationTypes: ['friend'] } }, + { _id: 'related1', metadata: { relationTypes: ['spouse'] } } + ] + } + } + } + + expect( + getRelatedRelationshipsToUpdate(oldContact, updatedContact) + ).toEqual([ + { + relation: { + _id: 'related1', + metadata: { relationTypes: ['spouse'] } + }, + type: 'CREATE' + } + ]) + }) + + it('should return updated related relationships to update', () => { + const oldContact = { + relationships: { + related: { + data: [{ _id: 'related0', metadata: { relationTypes: ['friend'] } }] + } + } + } + const updatedContact = { + relationships: { + related: { + data: [{ _id: 'related0', metadata: { relationTypes: ['spouse'] } }] + } + } + } + + expect( + getRelatedRelationshipsToUpdate(oldContact, updatedContact) + ).toEqual([ + { + relation: { + _id: 'related0', + metadata: { relationTypes: ['spouse'] } + }, + type: 'UPDATE' + } + ]) + }) + + it('should return removed related relationships to update', () => { + const oldContact = { + relationships: { + related: { + data: [ + { _id: 'related0', metadata: { relationTypes: ['friend'] } }, + { _id: 'related1', metadata: { relationTypes: ['spouse'] } } + ] + } + } + } + const updatedContact = { + relationships: { + related: { + data: [{ _id: 'related0', metadata: { relationTypes: ['friend'] } }] + } + } + } + + expect( + getRelatedRelationshipsToUpdate(oldContact, updatedContact) + ).toEqual([ + { + relation: { + _id: 'related1', + metadata: { relationTypes: ['spouse'] } + }, + type: 'DELETE' + } + ]) + }) + }) + describe('isSameRelatedRelationships', () => { + it('should return true if related relationships are the same', () => { + const oldContact = { + _id: 'contact0', + name: 'Alice', + relationships: { + related: { + data: [{ _id: 'related0' }] + } + } + } + const updatedContact = { + _id: 'contact0', + name: 'Bob', + relationships: { + related: { + data: [{ _id: 'related0' }] + } + } + } + + expect(isSameRelatedRelationships(oldContact, updatedContact)).toBe(true) + }) + + it('should return false if related relationships are different', () => { + const oldContact = { + relationships: { + related: { + data: [{ _id: 'related0' }] + } + } + } + const updatedContact = { + relationships: { + related: { + data: [{ _id: 'related1' }] + } + } + } + + expect(isSameRelatedRelationships(oldContact, updatedContact)).toBe(false) + }) + }) + describe('makeRelationMapping', () => { + it('should return a mapping of relation types', () => { + const originalContactId = 'relatedO' + const originalContactRelation = { + _id: 'related1', + metadata: { relationTypes: ['parent'] } + } + + expect( + makeRelationMapping(originalContactRelation, originalContactId) + ).toEqual({ + _id: originalContactId, + _type: DOCTYPE_CONTACTS, + metadata: { + relationTypes: ['child'] + } + }) + }) + }) +}) diff --git a/src/types.js b/src/types.js index f8fa7d600..33770dedb 100644 --- a/src/types.js +++ b/src/types.js @@ -46,4 +46,22 @@ * @property {string} relatedContactLabel - Object with the type of the related contact stringified (e.g. '{\"type\":\"spouse\"}') */ +/** + * @typedef {Object} RelatedRelationshipsMetadata + * @property {string[]} relationTypes - The relation types of the related contact + */ + +/** + * @typedef {Object} RelatedRelationships + * @property {string} _id - The id of the related contact + * @property {string} _type - The doctype of the related contact + * @property {relatedRelationshipsMetadata} metadata - The metadata of the related contact + */ + +/** + * @typedef {Object} RelatedRelationshipsToUpdate + * @property {'CREATE'|'UPDATE'|'DELETE'} type - The action to update the related relationships + * @property {RelatedRelationships} relation - The doctype of the related contact + */ + export default {}