forked from yvv/decision
- stores/collective.ts (état local-first, gabarits, import/export, seeds) + stores/decisions.ts (cycle de vie complet : capture→chemin→fenêtres→ sessions→cristallisation→épreuve du réel→révocation) - data/templates.ts : 7 gabarits (Page blanche observatoire-d'abord, 5 points de départ, Institution symétrique) - composables useFeed (13 sections du Fil en sélecteurs purs) + useSearch (index unique Cmd+K/Q0) - shell : layouts default/bare, app.vue mince, useMood v2 (Source/Margelle/ Nappe/Minuit), sceau 井 = logo, LdWorkspaceSelector avec finalité A1, LdAvatarStack (premier/second lieu), LdCountdown (suspension striée) - 338 tests vitest verts, npm run build zéro erreur Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
281 lines
8.9 KiB
TypeScript
281 lines
8.9 KiB
TypeScript
/**
|
|
* 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<T extends { archivedAt?: ISODate }>(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<T extends { updatedAt: ISODate }>(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<void> {
|
|
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<void> {
|
|
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<ImportResult> {
|
|
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<ImportResult> {
|
|
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<void> {
|
|
await deleteCollective(id)
|
|
this.index = this.index.filter(entry => entry.id !== id)
|
|
if (this.activeId === id) {
|
|
this.activeId = null
|
|
this.current = null
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 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<ImportResult> {
|
|
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)
|
|
},
|
|
},
|
|
})
|