Files
decision/maquette/frontend/app/engine/impact.ts
YvvandClaude Opus 5.5 67ddee28b2 v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- 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>
2026-10-21 02:04:42 +02:00

102 lines
3.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 }))
}