// @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)
}
}