- crates/ld-engine : formule inertielle (parité avec la maquette, garde C), coquille / changement de fond, assistant d'édition à règles, composition d'un document sous vote permanent (adoptions, aperçus, rivales). - crates/trustin-access : qui voit, qui ouvre, qui gère les accès. - crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de connect.monnaie-libre.fr), invitations, entrée en vigueur automatique, signalements de contradiction, traductions éditoriales, publication git, taille de la toile lue sur la chaîne. - seeds/acte-engagement-certification.json : 33 articles, fr + en + es ; en vigueur : préambule, E1, E2 (seed a minima). - maquette/ : frontend, backend et docker v2 déplacés tels quels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
102 lines
3.9 KiB
TypeScript
102 lines
3.9 KiB
TypeScript
/**
|
||
* Perimeter engine — who is concerned by THIS decision.
|
||
*
|
||
* Pure functions — no store, no I/O. The store assembles the inputs and
|
||
* persists the result as Concern rows (origin 'computed').
|
||
*
|
||
* The computed union (BLUEPRINT-V2.md « Périmètres et inclusion ») :
|
||
* members of the scoped circles ∪ persons named by the author
|
||
* ∪ holders of active mandates whose domain intersects the scoped circles.
|
||
* Every inclusion carries its human-readable French reason — auditable in
|
||
* one tap, never « la machine t'a désigné ».
|
||
*
|
||
* MEMBERSHIP CHOICE (v2, documented): circle membership is NOMINATIVE per
|
||
* circle (Circle.memberIds is the ONLY membership rule). Circle nesting
|
||
* (parentCircleId, « couches d'oignon ») does NOT propagate membership:
|
||
* a parent circle does NOT automatically include the members of its child
|
||
* circles, nor the reverse. Nesting serves ONE gesture — widening the
|
||
* perimeter by one notch (« élargir d'un cran ») — which explicitly adds
|
||
* the parent circle to the scope; the computation never infers it.
|
||
*/
|
||
|
||
import type { Circle, Decision, Id, Mandate } from '~/types/domain'
|
||
|
||
export interface ConcernedEntry {
|
||
personId: Id
|
||
/** French reason shown on tap (becomes Concern.reason). */
|
||
reason: string
|
||
}
|
||
|
||
/**
|
||
* Flatten circle ids into the set of their DIRECT nominative members.
|
||
*
|
||
* NON-PROPAGATION (see module header): only Circle.memberIds of the listed
|
||
* circles count. Members of child (or parent) circles are NOT included —
|
||
* widening is a scope gesture, never an automatic inference.
|
||
* Unknown circle ids are ignored (robustness over crash).
|
||
*/
|
||
export function expandCircleMembers(circleIds: Id[], circles: Circle[]): Set<Id> {
|
||
const members = new Set<Id>()
|
||
for (const circleId of circleIds) {
|
||
const circle = circles.find(c => c.id === circleId)
|
||
if (!circle) continue
|
||
for (const personId of circle.memberIds) members.add(personId)
|
||
}
|
||
return members
|
||
}
|
||
|
||
/**
|
||
* Compute the concerned persons of a decision scope, each with its reason.
|
||
*
|
||
* Deduplication: first reason wins, with source priority
|
||
* circle membership > named by author > mandate holder.
|
||
* The author is EXCLUDED from the list (they decide, they are not
|
||
* « concerned » by their own perimeter).
|
||
*
|
||
* Mandates count only when status 'active' and when their domain.circleIds
|
||
* INTERSECTS the scoped circles (any shared circle is enough — a mandate
|
||
* holder is concerned as soon as the decision touches their domain).
|
||
*/
|
||
export function computeConcerned(
|
||
scope: Decision['scope'],
|
||
circles: Circle[],
|
||
mandates: Mandate[],
|
||
authorId: Id,
|
||
): ConcernedEntry[] {
|
||
// Map preserves insertion order; first reason wins (priority by pass order).
|
||
const concerned = new Map<Id, string>()
|
||
|
||
// 1. Members of the scoped circles (highest priority reason).
|
||
for (const circleId of scope.circleIds) {
|
||
const circle = circles.find(c => c.id === circleId)
|
||
if (!circle) continue
|
||
for (const personId of circle.memberIds) {
|
||
if (personId === authorId) continue
|
||
if (!concerned.has(personId)) {
|
||
concerned.set(personId, `membre du cercle ${circle.name}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. Persons named by the author.
|
||
for (const personId of scope.personIds) {
|
||
if (personId === authorId) continue
|
||
if (!concerned.has(personId)) {
|
||
concerned.set(personId, 'nommé·e par l\'auteur')
|
||
}
|
||
}
|
||
|
||
// 3. Holders of active mandates whose domain intersects the scoped circles.
|
||
const scopedCircleIds = new Set(scope.circleIds)
|
||
for (const mandate of mandates) {
|
||
if (mandate.status !== 'active') continue
|
||
if (!mandate.domain.circleIds.some(id => scopedCircleIds.has(id))) continue
|
||
if (mandate.holderId === authorId) continue
|
||
if (!concerned.has(mandate.holderId)) {
|
||
concerned.set(mandate.holderId, `titulaire du mandat ${mandate.title}`)
|
||
}
|
||
}
|
||
|
||
return [...concerned].map(([personId, reason]) => ({ personId, reason }))
|
||
}
|