/** * 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 { const members = new Set() 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() // 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 })) }