/** * Collective store — the tenant: which collective is active, its full local * state (CollectiveState = Bundle minus export metadata), and the bundle * lifecycle (create from template, import, export, seeds, delete). * * EXPLICIT imports only (no Nuxt auto-imports): the store must run under * plain vitest + createPinia, and the future FastAPI adapter swaps * data/persistence without touching any screen. * * Getter shortcuts (people, decisions, …) filter soft-deleted rows * (!archivedAt) — screens never see archived entities; sync keeps them. */ import { defineStore } from 'pinia' import type { Advice, Assent, Circle, Clause, ClauseVersion, CollectiveSettings, CollectiveTemplate, Concern, Decision, Id, ISODate, Mandate, Objection, Person, Protocol, TextDoc, VoteSession, Vote, } from '../types/domain' import type { CollectiveState, ImportResult } from '../data/persistence' import { deleteCollective, getActiveCollectiveId, importBundle, listCollectiveIds, loadState, saveStateDebounced, saveStateNow, setActiveCollectiveId, toBundle, } from '../data/persistence' import { hasConsentProtocol, resolveSettings } from '../engine' import { buildTemplateBundle } from '../data/templates' import type { TemplateId } from '../data/templates' /** Re-exported for screens: the shape of the active collective's state. */ export type { CollectiveState } from '../data/persistence' export interface CollectiveIndexEntry { id: Id slug: string name: string color: string icon: string template: CollectiveTemplate } export interface CreateFromTemplateOptions { name: string slug: string color: string icon: string meName: string memberNames: string[] } export type SeedName = 'duniter-g1' | 'atelier-du-canal' interface CollectiveStoreState { current: CollectiveState | null activeId: Id | null index: CollectiveIndexEntry[] ready: boolean } function live(rows: T[] | undefined): T[] { return (rows ?? []).filter(row => !row.archivedAt) } export const useCollectiveStore = defineStore('collective', { state: (): CollectiveStoreState => ({ current: null, activeId: null, index: [], ready: false, }), getters: { /** The local profile — v2 single-seat. */ me(state): Person | null { return state.current?.people.find(p => p.isMe && !p.archivedAt) ?? null }, /** Resolved Pact settings — the Pact IS the settings store. */ settings(state): CollectiveSettings | null { if (!state.current) return null const pactDocId = state.current.collective.pactDocId // Cast: Vue's UnwrapRef over the recursive Json type (settingValue) // explodes TS instantiation depth; the runtime shape is identical. const pactClauses = state.current.clauses.filter( c => c.docId === pactDocId && !c.archivedAt, ) as unknown as Clause[] const versions = state.current.versions as unknown as ClauseVersion[] return resolveSettings(pactClauses, versions) }, hasConsent(): boolean { const settings = this.settings return settings !== null && hasConsentProtocol(settings) }, pactDoc(state): TextDoc | null { if (!state.current) return null return ( state.current.docs.find( d => d.id === state.current!.collective.pactDocId && !d.archivedAt, ) ?? null ) }, // ── Non-archived shortcuts — screens never see archived rows ── people(state): Person[] { return live(state.current?.people) }, circles(state): Circle[] { return live(state.current?.circles) }, decisions(state): Decision[] { return live(state.current?.decisions) }, mandates(state): Mandate[] { return live(state.current?.mandates) }, docs(state): TextDoc[] { return live(state.current?.docs) }, clauses(state): Clause[] { return live(state.current?.clauses) }, versions(state): ClauseVersion[] { // Cast: same recursive-Json unwrap issue as the settings getter. return live(state.current?.versions as unknown as ClauseVersion[] | undefined) }, protocols(state): Protocol[] { return live(state.current?.protocols) }, sessions(state): VoteSession[] { return live(state.current?.sessions) }, votes(state): Vote[] { return live(state.current?.votes) }, concerns(state): Concern[] { return live(state.current?.concerns) }, objections(state): Objection[] { return live(state.current?.objections) }, advices(state): Advice[] { return live(state.current?.advices) }, assents(state): Assent[] { return live(state.current?.assents) }, }, actions: { newId(): Id { return crypto.randomUUID() }, now(): ISODate { return new Date().toISOString() }, /** LWW clock: bump updatedAt, return the entity. */ stamp(entity: T): T { entity.updatedAt = this.now() return entity }, /** Debounced save of the active collective — every mutation ends here. */ persist(): void { if (this.current) saveStateDebounced(this.current as unknown as CollectiveState) }, /** Load the index of local collectives + the active one. */ async init(): Promise { const ids = await listCollectiveIds() const index: CollectiveIndexEntry[] = [] for (const id of ids) { const state = await loadState(id) if (!state) continue const c = state.collective index.push({ id: c.id, slug: c.slug, name: c.name, color: c.color, icon: c.icon, template: c.template, }) } this.index = index const activeId = await getActiveCollectiveId() if (activeId && ids.includes(activeId)) { await this.switchTo(activeId) } this.ready = true }, async switchTo(id: Id): Promise { const state = await loadState(id) if (!state) return // Cast: assigning into the reactive slot re-triggers the recursive-Json // UnwrapRef explosion (see the settings getter) — same shape at runtime. this.current = state as unknown as typeof this.current this.activeId = id await setActiveCollectiveId(id) }, /** Create a collective from one of the seven templates — SAME path as an import. */ async createFromTemplate(id: TemplateId, opts: CreateFromTemplateOptions): Promise { const bundle = buildTemplateBundle(id, { ...opts, now: this.now(), newId: () => this.newId(), }) return await this.importJson(JSON.stringify(bundle), true) }, /** * Import a bundle (user file or seed). Collision on the collective id is * REFUSED with a French issue — never a silent overwrite, never an id * suffix (the id is the sync identity of the collective). */ async importJson(json: string, asSeed = false): Promise { const result = await importBundle(json, { asSeed }) if (!result.state) return result if (result.collided) { return { issues: [ ...result.issues, { level: 'error', message: 'Ce collectif existe déjà sur cette machine — supprime-le d\'abord si tu veux le réimporter.', }, ], collided: true, } } await saveStateNow(result.state) const c = result.state.collective this.index.push({ id: c.id, slug: c.slug, name: c.name, color: c.color, icon: c.icon, template: c.template, }) await this.switchTo(c.id) return result }, /** Export the active collective as an indented schemaVersion-2 bundle. */ exportJson(): string | null { if (!this.current) return null return JSON.stringify(toBundle(this.current as unknown as CollectiveState, this.now()), null, 2) }, async removeCollective(id: Id): Promise { await deleteCollective(id) this.index = this.index.filter(entry => entry.id !== id) if (this.activeId === id) { this.activeId = null this.current = null // Ne jamais laisser la personne devant un accueil orphelin : s'il // reste un collectif sur la machine, il devient l'actif. const next = this.index[0] if (next) await this.switchTo(next.id) } }, /** * Import a packaged seed (Duniter Ğ1 / Atelier du Canal) through the SAME * path as a user import. The JSON files are produced by separate tooling: * a missing file fails SOFTLY with a French issue, never a crash. */ async loadSeed(name: SeedName): Promise { let bundle: unknown try { bundle = name === 'duniter-g1' ? (await import('../data/seeds/duniter-g1.bundle.json')).default : (await import('../data/seeds/atelier-du-canal.bundle.json')).default } catch { return { issues: [ { level: 'error', message: `Le jeu de démonstration « ${name} » n'est pas disponible dans cette version.`, }, ], collided: false, } } return await this.importJson(JSON.stringify(bundle), true) }, }, })