Source: tree/visibilityEvents.js

// @ts-check
/**
 * Der **eine** Vertrag fuer Rechte-Events.
 *
 * Die Rechte liegen in der `Visible`-Tabelle. Vier Stellen schreiben dorthin:
 * `visibilityList`, `listRebuildAccess`, `personListRebuildAccess` und
 * `matchObjects.addVisibility`. Drei davon meldeten ihre Aenderung schon — jede
 * mit einer eigenen Kopie derselben Schleife —, die vierte (der haeufige
 * Personen-Roster: „Person bekommt Job") meldete **nichts**. Deshalb stand die
 * Projektion auf einem toten Ast: die Zeile entstand, das Event fehlte.
 *
 * Hier steht die Meldung einmal, damit die vier Pfade nicht auseinanderlaufen.
 *
 * **Vertrag** (so liest `rag-sync` ihn, siehe [057 §15/§16]):
 *
 *     /add|remove/{ObjectType}/{level}/{hex(Visible.UID)}
 *     data: [hex(Visible.UIDUser), ...]
 *
 * `ObjectType` ist der Typ des Objekts, **an dem** die Zeile haengt
 * (`ObjectBase.Type` von `Visible.UID`) — nicht der Typ der Person und nicht der
 * Typ des Filters. Ein Event traegt alle Personen eines Levels; die Level werden
 * getrennt gemeldet (`admin`, `changeable`, `visible`), weil sie im Bot
 * unterschiedlich starke Grants sind.
 *
 * @import {EventLogConnection} from '../utils/events.js'
 */

import { query, HEX2uuid } from '@commtool/sql-query'
import { publishEvent } from '../utils/events.js'

/**
 * Die Organisation fuer ein Event-Payload, wenn der Aufrufer keine mitgibt.
 *
 * Der Aufrufer weiss sie in aller Regel (`action.UIDroot`, `req.session.root`)
 * und reicht sie durch — hier steht nur der Rueckfall. Ohne ihn traegt das Event
 * die Null-Organisation, obwohl die Zeile am richtigen Objekt landet; genau
 * dieser Fehler war schon einmal da (Commit `0738a87`).
 *
 * Der Import ist dynamisch, damit die Baum-Ebene nicht zyklisch auf die
 * Router-Ebene zeigt (`organizationUtils` zieht `Router/orga/service.js`).
 *
 * @param {Buffer|Buffer[]|null} target - das Objekt (oder die Objekte), an dem die Zeile haengt
 * @returns {Promise<string|null>}
 */
export const resolveVisibilityOrganization = async (target) => {
    const uids = Array.isArray(target) ? target : (target ? [target] : [])
    if (uids.length === 0) return null
    try {
        const { getOrganizationForObject } = await import('../utils/organizationUtils.js')
        return await getOrganizationForObject(uids[0])
    } catch {
        return null
    }
}

/**
 * Eine Sichtbarkeits-Zeile mit dem Typ des Objekts, an dem sie haengt.
 *
 * `UID` und `UIDUser` sind **kanonische Strings** (`UUID-…`), keine Rohbuffer:
 * die Normalisierung passiert einmal an der Grenze, in {@link loadVisibleScopes}
 * (`cast: ['UUID']`). Wer die Zeilen von Hand baut (die Rebuild-Pfade in
 * `rebuildList.js`), muss dieselbe Form liefern — `asUuid` toleriert Buffer
 * weiterhin, damit ein einzelner Aufrufer das nicht erzwingt.
 *
 * @typedef {Object} VisibleScope
 * @property {string} UID   - das Objekt, an dem das Recht haengt
 * @property {string} Type       - das Level: `visible` | `changeable` | `admin`
 * @property {string} UIDUser - die Person, die das Recht hat
 * @property {string} ObjectType - `ObjectBase.Type` von `UID`
 */

/**
 * Laedt die Sichtbarkeits-Zeilen zu einer Menge von Objekten.
 *
 * Bewusst nur die uebergebenen Objekte: der Aufrufer weiss, welche Zeilen sein
 * Lauf ueberhaupt anfassen kann, und alles andere waere teurer, nicht genauer.
 *
 * `cast: ['UUID']` ist hier **der Punkt der Normalisierung**: die Spalten
 * `Visible.UID` und `Visible.UIDUser` sind `binary(16)` und kaemen sonst als
 * Rohbuffer an. Jeder Vergleich muesste sie dann erneut umwandeln — genau das
 * hat den Server blockiert (ein `asUuid` pro Paarvergleich, siehe
 * {@link publishVisibilityDiff}). Einmal hier umgewandelt, sind die Zeilen
 * ueberall im Weiteren direkt vergleichbar.
 *
 * @param {Buffer[]} uids - die Objekte, deren Zeilen gelesen werden
 * @param {EventLogConnection} [connection] - optional eine Transaktions-Verbindung
 * @returns {Promise<VisibleScope[]>}
 */
export const loadVisibleScopes = async (uids, connection = null) => {
    if (!uids || uids.length === 0) return []
    // Duplikate raus: dieselbe Zeile kann durch mehrere toBeAdded-Eintraege
    // betroffen sein.
    const unique = [...new Map(uids.map(u => [u.toString('hex'), u])).values()]
    /** @type {VisibleScope[]} */
    const rows = await query(
        `SELECT Visible.UID, Visible.Type, Visible.UIDUser, ObjectBase.Type AS ObjectType
           FROM Visible
           INNER JOIN ObjectBase ON (ObjectBase.UID = Visible.UID)
          WHERE Visible.UID IN (?)`,
        [unique],
        { connection, cast: ['UUID'] },
    )
    return rows
}

/**
 * UID als Schluessel — **ohne** `Buffer.equals`.
 *
 * Zwei Gruende, beide schon einmal teuer:
 *
 * 1. `listRebuildAccess` setzt `UID` aus seinem Parameter zusammen, nicht aus
 *    der Abfrage: dort steht mal ein Buffer (Aufrufer `UUID2hex`), mal ein
 *    String (Queue-Action). Ein `.equals` auf dem String wirft — und weil der
 *    Aufruf im `try` der Rebuild-Funktion steht, verschwaende der Wurf die
 *    **ganze** Meldung (genau der Fehler war schon da: der Personen-Rebuild
 *    meldete nichts, weil `Visible.UIDUser` fehlte).
 * 2. `binary(16)` in MariaDB traegt die ersten acht Bytes eines `UUID-…` in
 *    geswappter Reihenfolge: `dacaa7a1-94b7-11f1-…` liegt als
 *    `11f194b7dacaa7a1…` in der Zeile. `toString('hex')` ergaebe also einen
 *    Hex-String, der **nicht** die UID ist — im Event-Key stuende eine UUID,
 *    die es nicht gibt, und die Projektion haengte ihre Grants an ein
 *    Phantom-Objekt. Deshalb `HEX2uuid` (dasselbe Paar wie in `sql-query`).
 *
 * Verglichen wird die kanonische Form `UUID-…`, nicht der Rohbuffer.
 *
 * @param {unknown} value
 * @returns {string} `UUID-…`, oder der Rohwert, wenn er nicht deutbar ist
 */
const asUuid = (value) => {
    if (Buffer.isBuffer(value)) return HEX2uuid(value)
    const raw = String(value ?? '').trim()
    if (!raw) return ''
    if (/^uuid-[0-9a-f]{8}-/i.test(raw)) return `UUID-${raw.slice(5).toLowerCase()}`
    const hex = raw.replace(/-/g, '').toLowerCase()
    if (hex.length !== 32) return raw
    return HEX2uuid(Buffer.from(hex, 'hex'))
}

/**
 * Der Schluessel einer Zeile fuer den Vergleich: `(UID, UIDUser, Level)` in
 * kanonischer Form.
 *
 * Bewusst **einmal pro Zeile** berechnet. Vorher stand `asUuid` im innersten
 * Praedikat des Diffs, damit rechnete **jedes** `(a, b)`-Paar beide Seiten neu
 * aus — Regex und `HEX2uuid` inklusive. Das war O(n*m) mit teurer Konstante: auf
 * der Dev-Datenbank (`Visible`: 1.085.068 Zeilen) hat ein **einziger** Aufruf den
 * Server stundenlang auf 100 % CPU gehalten und die Event-Loop blockiert.
 *
 * `JSON.stringify` statt einer Verkettung mit Trennzeichen, damit der Schluessel
 * auch fuer nicht deutbare Rohwerte kollisionsfrei bleibt — der Vergleich muss
 * exakt derselbe bleiben wie vorher.
 *
 * @param {VisibleScope} row
 * @returns {string}
 */
const scopeKey = (row) => JSON.stringify([asUuid(row.UID), asUuid(row.UIDUser), row.Type])

/**
 * Vergleicht zwei Staende und meldet die Differenz.
 *
 * Verglichen wird das Paar `(UID, UIDUser)` **inklusive** Level: eine Zeile, die
 * von `visible` auf `changeable` wandert, ist kein `add` auf ein leeres Blatt,
 * sondern ein `add` auf ein bereits gemeldetes — der Bot fuehrt den staerkeren
 * Stand. Ein `remove` fuer ein Level, das die Zeile nie hatte, entsteht dadurch
 * nicht.
 *
 * Reihenfolge: erst `remove`, dann `add` — ein Herabstufen (Loeschen und neu
 * Anlegen in einem Lauf) kommt so in der richtigen Ordnung an.
 *
 * Laufzeit: **O(n+m)**. Die kanonischen Schluessel werden je Zeile genau einmal
 * gebildet ({@link scopeKey}) und ueber zwei `Set` verglichen, statt jedes Paar
 * erneut zu normalisieren.
 *
 * @param {VisibleScope[]} before - Stand vor dem Lauf
 * @param {VisibleScope[]} after  - Stand nach dem Lauf
 * @param {string|Buffer|null} organization - Organisation fuer das Event-Payload
 * @returns {void}
 */
export const publishVisibilityDiff = (before, after, organization = null) => {
    const beforeKeys = new Set(before.map(scopeKey))
    const afterKeys = new Set(after.map(scopeKey))

    const removed = before.filter(b => !afterKeys.has(scopeKey(b)))
    const added = after.filter(a => !beforeKeys.has(scopeKey(a)))

    publishLevels('remove', removed, organization)
    publishLevels('add', added, organization)
}

/**
 * Fasst Zeilen zu einem Event je `(ObjectType, Level, Object)` zusammen.
 *
 * @param {'add'|'remove'} verb
 * @param {VisibleScope[]} rows
 * @param {string|Buffer|null} organization
 * @returns {void}
 */
const publishLevels = (verb, rows, organization) => {
    if (rows.length === 0) return
    // Ein Event je Ziel und Level, mit allen Personen als data — dieselbe Form,
    // die `listRebuildAccess` und `personListRebuildAccess` schon gesendet haben.
    /** @type {Map<string, {targetType: string, level: string, targetUid: string, users: string[]}>} */
    const grouped = new Map()
    for (const row of rows) {
        const targetType = row.ObjectType
        const level = row.Type
        const targetUid = asUuid(row.UID)
        const id = `${targetType}|${level}|${targetUid}`
        const entry = grouped.get(id) || { targetType, level, targetUid, users: [] }
        entry.users.push(asUuid(row.UIDUser))
        grouped.set(id, entry)
    }
    const myOrganization = Buffer.isBuffer(organization) ? HEX2uuid(organization) : organization
    for (const { targetType, level, targetUid, users } of grouped.values()) {
        publishEvent(`/${verb}/${targetType}/${level}/${targetUid}`, { organization: myOrganization, data: users })
    }
}