Source: tree/familyAddress.js

// @ts-check

/**
 * @typedef {Object} AddressItem
 * @property {string} type - Address type (e.g., 'family', 'personal', 'secondary')
 * @property {string} [road] - Street name
 * @property {string} [houseNumber] - House number
 * @property {string} [postcode] - Postal code
 * @property {string} [countryCode] - Country code
 */

/**
 * @typedef {Object} EmailItem
 * @property {string} type - Email type (e.g., 'family', 'personal', 'secondary')
 * @property {string} [email] - Email address
 */

/**
 * @typedef {Object} PhoneItem
 * @property {string} type - Phone type (e.g., 'family', 'personal', 'secondary')
 * @property {string} [number] - Phone number
 */

/**
 * @typedef {Object} AccountItem
 * @property {string} type - Account type (e.g., 'family', 'familyFees', 'personal')
 * @property {string} [IBAN] - Bank account IBAN
 */

/**
 * @typedef {Object} FamilyMemberObject
 * @property {Buffer} UID - Member unique identifier
 * @property {'person'|'extern'|'family'} Type - Object type (required)
 * @property {Object} Data - Member data object (required)
 * @property {AddressItem[]} [Data.address] - Address array
 * @property {EmailItem[]} [Data.email] - Email array
 * @property {PhoneItem[]} [Data.phone] - Phone array
 * @property {AccountItem[]} [Data.accounts] - Account array
 * @property {string[]} [Touched] - Names of the shared fields the caller actually
 *   supplied (from the write patch). Only those are reconciled with removal —
 *   see `adjustMemberData`. Omit for full-database sources (then nothing is removed).
 */

import {query} from '@commtool/sql-query'
import { publishChangeEvent } from '../Router/person/personHelpers.js'
import { addUpdateEntry } from '../server.ws.js';
import _ from 'lodash'
import { keysEqual } from '../utils/keyCompare.js';
import { errorLoggerUpdate } from '../utils/requestLogger.js';
import { decryptIbans, decryptAccount, accountsEqual } from '../utils/crypto.js';
import { getConfig } from '../utils/compileTemplates.js';
// handle potential family address changes

function difference(object, base) {
	function changes(object, base) {
		return _.transform(object, function(result, value, key) {
			if (!_.isEqual(value, base[key])) {
				result[key] = (_.isObject(value) && _.isObject(base[key])) ? changes(value, base[key]) : value;
			}
		});
	}
	//console.log(changes(object, base));
}

/**
 * Kontaktfelder, deren Eintraege mit der Familie geteilt werden.
 */
const SHARED_FIELDS = ['address', 'email', 'phone']

/**
 * Konten gehoeren dazu wie Adresse/E-Mail/Telefon: ein geteiltes Konto, das die
 * Quelle nicht mehr fuehrt, verschwindet bei den Geschwistern. Nur der Vergleich
 * laeuft anders — IBANs liegen verschluesselt vor, siehe `accountsEqual`.
 */
const ACCOUNT_FIELD = 'accounts'
const RECONCILED_FIELDS = [...SHARED_FIELDS, ACCOUNT_FIELD]

/**
 * Die geteilten Felder, die eine Schreiboperation tatsaechlich geliefert hat.
 *
 * Grundlage ist der PATCH (`req.body`), nicht die gemergten Daten: nur so ist
 * „Feld war nicht Teil dieses Speicherns" von „Feld wurde geleert" zu trennen. Die
 * gemergten Daten tragen den alten Wert immer weiter, ein Loeschen sieht dort aus
 * wie „unveraendert".
 *
 * @param {Object} source - Patch der Schreiboperation
 * @returns {string[]}
 */
export const sharedFieldsTouched = (source) =>
    RECONCILED_FIELDS.filter((field) => !!source && Object.prototype.hasOwnProperty.call(source, field))

/**
 * Die geteilten Felder, die eine Schreiboperation WIRKLICH veraendert hat.
 *
 * Praesenz allein genuegt nicht: ein Aufrufer kann das ganze Objekt schicken, obwohl
 * der geteilte Teil unveraendert ist. Nur bei einer echten Aenderung darf bei den
 * Geschwistern abgeglichen — also auch entfernt — werden.
 *
 * Konten werden entschluesselt verglichen (`accountsEqual`): `IBANencrypted` traegt
 * einen zufaelligen Salt, ein Vergleich der Rohdaten schluege deshalb immer fehl.
 *
 * @param {Object} patch - Was die Operation geliefert hat
 * @param {Object} baseline - Was vorher gespeichert war
 * @returns {string[]}
 */
export const sharedFieldsChanged = (patch, baseline) =>
    sharedFieldsTouched(patch).filter((field) =>
        field === ACCOUNT_FIELD
            ? !accountsEqual(patch?.[field], baseline?.[field])
            : JSON.stringify(patch?.[field] ?? null) !== JSON.stringify(baseline?.[field] ?? null)
    )

/** Ein geteilter Eintrag ist 'family' (Adresse/E-Mail/Telefon) bzw. 'familyFees' (Konto). */
const isFamilyEntry = (entry) =>
    !!entry && typeof entry === 'object' && (entry.type === 'family' || entry.type === 'familyFees')

/**
 * Schluessel, an dem ein geteilter Eintrag wiedererkannt wird. Ein leerer
 * Rueckgabewert heisst „kein Schluessel" — solche Eintraege werden nur angehaengt,
 * nie zugeordnet, damit zwei leere Eintraege nicht miteinander verschmelzen.
 */
const sharedKeyOf = {
    address: (a) => {
        const parts = [a.road, a.houseNumber, a.postcode, a.countryCode].map((v) => v ?? '')
        return parts.every((p) => p === '') ? null : parts.join('|')
    },
    email: (e) => (e.email ? String(e.email).trim().toLowerCase() : null),
    phone: (p) => (p.number ? String(p.number).replace(/\D/g, '') : null),
}

/** Altwert-Skalar -> Eintrag, falls ein Feld noch als einfacher Wert gespeichert ist. */
const scalarEntry = (field, value) =>
    field === 'email'
        ? { email: String(value) }
        : field === 'phone'
            ? { number: String(value) }
            : { road: String(value) }

/**
 * Die geteilten Eintraege EINES Feldes in der Quelle.
 *
 * Bei person/extern traegt das Feld auch die eigenen Eintraege des Mitglieds — hier
 * zaehlen nur die geteilten. Bei einer Familie ist das Feld (sofern gesetzt) selbst
 * der geteilte Satz; historisch als Einzelobjekt oder Skalar.
 *
 * @param {FamilyMemberObject} object
 * @param {string} field
 * @returns {Array<Object>}
 */
const familyEntriesOf = (object, field) => {
    const value = object?.Data?.[field]
    if (!value) return []
    const fromFamilyObject = object.Type === 'family' || object.Type === 'familyFees'
    return (Array.isArray(value) ? value : [value])
        .filter(Boolean)
        .map((entry) => {
            if (typeof entry === 'object') return entry
            // Skalar: in einem Familienobjekt immer geteilt, in einem Mitglied koennen
            // nur Familienfelder gemeint sein (der Altwert war der geteilte Wert).
            return { ...scalarEntry(field, entry), type: 'family' }
        })
        .filter((entry) => fromFamilyObject || isFamilyEntry(entry))
        .map((entry) => ({ ...entry, type: 'family' }))
}

/**
 * Schluessel, an dem ein Konto wiedererkannt wird: die IBAN. `IBANshow` ist beim
 * verschluesselten Konto maskiert (XXXXXX), taugt aber als Rueckfall, wenn gar keine
 * IBAN vorliegt. Ein leerer Rueckgabewert heisst „kein Schluessel" — solche Konten
 * werden nie zugeordnet, damit zwei leere Eintraege nicht verschmelzen.
 *
 * Entschluesselt wird nur eine KOPIE, ausschliesslich fuer den Schluessel: der
 * Eintrag selbst bleibt in der Form, in der er vorliegt. Sonst wanderte die IBAN
 * im Klartext in die Ablage (siehe `familyAccountsOf` und `adjustMemberData`).
 */
const accountKey = (account) => {
    const key = decryptAccount(account)?.IBAN || account?.IBANshow
    return key ? String(key).replace(/\s+/g, '').toUpperCase() : null
}

/**
 * Die geteilten Konten der Quelle — in der Form, in der sie vorliegen.
 *
 * Bewusst NICHT entschluesselt: was hier durchgereicht wird, wird beim Geschwister
 * gespeichert. Ein verschluesseltes Konto bleibt deshalb verschluesselt. Fuer den
 * Abgleich braucht es die IBAN nur als Schluessel, und den bildet `accountKey`.
 *
 * Bei person/extern traegt `accounts` auch die eigenen Konten; geteilt sind nur
 * family/familyFees. Bei einem Familienobjekt ist der Satz selbst der geteilte.
 *
 * @param {FamilyMemberObject} object
 * @returns {Array<Object>}
 */
const familyAccountsOf = (object) => {
    const accounts = object?.Data?.accounts
    if (!Array.isArray(accounts)) return []
    const onlyShared = object?.Type === 'person' || object?.Type === 'extern'
    return accounts
        .filter(Boolean)
        .filter((account) => !onlyShared || isFamilyEntry(account))
}

/**
 * Gleicht die geteilten Konten auf einem Mitglied ab — dieselbe Regel wie
 * `reconcileSharedField`, nur mit der entschluesselten IBAN als Schluessel.
 *
 * Eigene Konten bleiben unberuehrt. Mit `remove` fallen die geteilten Konten weg, die
 * es in der Quelle nicht mehr gibt — das ist der Fall „geloescht" bzw. „auf privat
 * umgestuft", der laut UI fuer die ganze Familie gilt. Ein geteiltes Konto wird
 * insbesondere NICHT still auf einen anderen Typ herabgestuft.
 *
 * Uebernommen wird jeweils der Eintrag so, wie er vorliegt — Klartext-IBANs werden
 * weder eingefuehrt noch entfernt.
 *
 * @param {Object} data - Member.Data des Mitglieds (wird veraendert)
 * @param {Array<Object>} desired
 * @param {boolean} remove
 */
const reconcileAccounts = (data, desired, remove) => {
    const current = Array.isArray(data.accounts) ? data.accounts : []
    // Ein Mitglied ohne Konten bekommt auch keins: sonst entstuende ein leeres
    // `accounts`-Feld, das nur einen ueberfluessigen Schreibvorgang ausloest.
    if (current.length === 0 && desired.length === 0) return

    const used = new Set()
    const result = []
    for (const entry of current) {
        if (!isFamilyEntry(entry)) {
            result.push(entry)
            continue
        }
        const key = accountKey(entry)
        const match = key ? desired.findIndex((d, i) => !used.has(i) && accountKey(d) === key) : -1
        if (match >= 0) {
            used.add(match)
            result.push({ ...desired[match] })
        } else if (!remove) {
            result.push(entry)
        }
        // remove && kein Treffer -> das geteilte Konto ist verschwunden und faellt weg
    }
    desired.forEach((entry, i) => {
        if (!used.has(i)) result.push({ ...entry })
    })
    data.accounts = result
}

/**
 * Gleicht EIN geteiltes Feld auf einem Mitglied ab.
 *
 * `desired` sind die geteilten Eintraege der Quelle — sie sind der gewuenschte
 * geteilte Satz. Fehlende werden ergaenzt, geaenderte aktualisiert. Mit `remove`
 * werden zusaetzlich die geteilten Eintraege entfernt, die es in der Quelle nicht
 * mehr gibt: das ist der Fall „geloescht" bzw. „herabgestuft", der laut UI fuer die
 * ganze Familie gilt. Eintraege mit eigenem Typ bleiben immer unberuehrt — ein
 * geteilter Eintrag wird insbesondere NICHT still herabgestuft.
 *
 * @param {Object} data - Member.Data des Mitglieds (wird veraendert)
 * @param {string} field
 * @param {Array<Object>} desired
 * @param {boolean} remove
 */
const reconcileSharedField = (data, field, desired, remove) => {
    const key = sharedKeyOf[field]
    let current
    if (Array.isArray(data[field])) {
        current = data[field].filter(Boolean)
    } else if (data[field]) {
        current = typeof data[field] === 'object'
            ? [data[field]]
            : [{ type: 'personal', ...scalarEntry(field, data[field]) }]
    } else {
        current = []
    }

    const used = new Set()
    const result = []
    for (const entry of current) {
        if (!isFamilyEntry(entry)) {
            result.push(entry)
            continue
        }
        const entryKey = key(entry)
        const match = entryKey ? desired.findIndex((d, i) => !used.has(i) && key(d) === entryKey) : -1
        if (match >= 0) {
            used.add(match)
            result.push({ ...desired[match], type: 'family' })
        } else if (!remove) {
            result.push(entry)
        }
        // remove && kein Treffer -> der geteilte Eintrag ist verschwunden und faellt weg
    }
    desired.forEach((entry, i) => {
        if (!used.has(i)) result.push({ ...entry, type: 'family' })
    })
    data[field] = result
}

/**
 * Adjusts member data with family-shared information (address, email, phone, accounts)
 *
 * @param {FamilyMemberObject} member - The family member to update
 * @param {FamilyMemberObject} object - The source object containing family data
 * @param {string} organization - Organization UID for event publishing
 * @param {string[]} [touched] - Shared fields the caller supplied. When set, those
 *   fields are reconciled against the source: shared entries the source no longer
 *   carries are REMOVED. When omitted, the sync stays purely additive (nothing is
 *   ever removed) — that keeps full-database callers harmless.
 * @returns {Promise<Object|undefined>} Updated member data or undefined
 */
const adjustMemberData=async(member,object,organization,touched)=>
{
    try {
        const remove = Array.isArray(touched)
        const fields = remove ? touched.filter((f) => RECONCILED_FIELDS.includes(f)) : RECONCILED_FIELDS
        if(!member?.Data)
            return
        const origData=_.cloneDeep(member.Data)

        // Konten liegen verschluesselt vor — der Abgleich braucht die IBAN im Klartext.
        // Den entschluesselt `accountKey` selbst, und zwar auf einer Kopie: `member.Data`
        // bleibt in der gespeicherten Form, sonst stuende die IBAN nach jedem
        // Familien-Sync im Klartext in `Member.Data` (und in `myDiff` / Redis).
        for (const field of fields)
        {
            if (field === ACCOUNT_FIELD)
                reconcileAccounts(member.Data, familyAccountsOf(object), remove)
            else
                reconcileSharedField(member.Data, field, familyEntriesOf(object, field), remove)
        }
    //difference(member.Data,origData)
        const [equal,myDiff]= keysEqual(origData,member.Data)
        if(!equal)
        {
            query(`UPDATE Member SET Data=? WHERE UID=?`,[JSON.stringify(member.Data),member.UID])
            publishChangeEvent({Type:member.Type,UID:member.UID},myDiff,Date.now()/1000,organization)
        }
        // Nach aussen (WebSocket und Antwort des Aufrufers) geht die lesbare Fassung:
        // die Oberflaeche zeigt die volle IBAN, die Ablage behaelt die verschluesselte.
        const outgoing=_.cloneDeep(member.Data)
        decryptIbans(outgoing)
        if(!equal)
            addUpdateEntry(member.UID,{data:outgoing})
        return outgoing
    }
    catch(e)
    {
        errorLoggerUpdate(e)
    }



}

/**
 * Synchronizes family-shared data (address, email, phone, accounts) across all family members
 * 
 * When a person/extern/family object is updated with family data, this function propagates
 * those changes to all other members of the same family. Handles:
 * - Family addresses
 * - Family email addresses
 * - Family phone numbers
 * - Family accounts (including familyFees accounts)
 *
 * Only the fields listed in `object.Touched` are reconciled (i.e. shared entries the
 * source no longer carries are removed there). Without `Touched` the sync is purely
 * additive, as it always was.
 * 
 * @param {FamilyMemberObject} object - The source object with updated family data (requires: UID, Type, Data properties)
 * @param {string} organization - Organization UID for multi-tenant context and event publishing
 * @returns {Promise<void>}
 */
export const familyAddress=async (object, organization)=>
{
    try {
        let result
        //get all family members
        if(object.Type==='person' || object.Type==='extern')
            result =await query(`SELECT Family.UID, Family.Data, Members.Data AS MemberData, Members.UID AS UIDmember,MObject.Type
                FROM Member AS Family
                INNER JOIN Links ON (Links.UIDTarget =Family.UID )
                INNER JOIN Links AS FamLinks ON (  FamLinks.UIDTarget=Family.UID AND FamLinks.Type IN ('family','familyFees'))
                INNER JOIN Member AS Members ON (FamLinks.UID=Members.UID)
                INNER JOIN ObjectBase AS MObject ON (MObject.UID=Members.UID)
                WHERE Links.UID=? AND Links.Type IN ('family','familyFees') 
            `,[object.UID])
        else if(object.Type==='family')
        {
            result =await query(`SELECT Family.UID, Family.Data, Members.Data AS MemberData, Members.UID AS UIDmember,MObject.Type
                FROM Member AS Family
                LEFT JOIN ObjectBase ON (Family.UID=ObjectBase.UID)
                INNER JOIN Links ON (Links.UIDTarget =Family.UID )
                INNER JOIN Links AS FamLinks ON (  FamLinks.UIDTarget=Family.UID AND FamLinks.Type IN ('family','familyFees'))
                INNER JOIN Member AS Members ON (FamLinks.UID=Members.UID)
                INNER JOIN ObjectBase AS MObject ON (MObject.UID=Members.UID)
                WHERE Links.UID=? AND Links.Type IN ('family','familyFees') AND ObjectBase.UID IS NULL
            `,[object.UID])
        }
        else
            return
        if(result.length>0)
        {

            const family= result.map(m=>({UID:m.UIDmember,Type:m.Type,Data:JSON.parse(m.MemberData)}))
            
            for (const member of family)
            {
                await adjustMemberData(member,object,organization,object.Touched)
            }

        }
    }
    catch(e)
    {
        errorLoggerUpdate(e)
    }


}

/**
 * Adds family updates to a new member when they join a family
 * 
 * Synchronizes family data (address, email, phone, accounts) from existing family members
 * to the newly added family member.
 * 
 * @param {Buffer} UIDnewMember - UID of the new family member
 * @param {Buffer} UIDfamily - UID of the family
 * @param {string} [organization] - Optional organization UID for event publishing
 * @returns {Promise<Object|undefined>} Updated member data or original data if no family members exist
 */
export const addFamilyUpdate=async (UIDnewMember,UIDfamily,organization)=>
{
    try {
        // get an old member of the family
        const result=await query(`SELECT ObjectBase.UID,ObjectBase.Type,Member.Data FROM Links
            INNER JOIN ObjectBase ON (ObjectBase.UID=Links.UID AND Links.Type IN ('family','familyFees'))
            INNER JOIN Member ON (Member.UID=ObjectBase.UID)
            WHERE Links.UID<>? AND Links.UIDTarget=?`,[UIDnewMember,UIDfamily],
            {cast:['json']})
        const [memberObject]=await query(`SELECT Member.UID,Member.Data,ObjectBase.Type 
            FROM Member
            INNER JOIN ObjectBase ON (ObjectBase.UID=Member.UID)
            WHERE Member.UID=?`,
            [UIDnewMember],{cast:['json']})
        if(result.length>0)
        {
            // Beitritt/Wechsel: hier wird abgeglichen (alle geteilten Felder), damit
            // die Daten der VORIGEN Familie nicht haengenbleiben.
            return await adjustMemberData(memberObject,result[0],organization,RECONCILED_FIELDS)
        }
        else
        {
            // Kein anderes Mitglied — genau der Zustand, den `createOrChangeFamily`
            // hinterlaesst. Frueher wurde hier nur zurueckgegeben, deshalb raeumte ein
            // Umzug in eine frische Familie NICHTS auf. Ohne Quelle (= leere Familie)
            // bleiben nur die personeneigenen Eintraege stehen.
            const newData=await adjustMemberData(memberObject,{Type:'family',Data:{}},organization,RECONCILED_FIELDS)
            return newData ?? memberObject?.Data
        }
    }
    catch(e)
    {
        errorLoggerUpdate(e)
    }

}