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