import { ref } from "vue"; import { defineStore } from "pinia"; import * as api from "@/api/rulebooks"; import type { Rulebook, RulebookTopic, Rule, RuleHeader } from "@/api/rulebooks"; import { useToastStore } from "@/stores/toast"; export const useRulebooksStore = defineStore("rulebooks", () => { const rulebooks = ref([]); const topicsByRulebook = ref>({}); const rulesByTopic = ref>({}); const currentRule = ref(null); const rulesDue = ref([]); // Kept so a verify re-reads the sweep with the SAME filters the operator is // looking at — re-fetching unfiltered would silently widen the list under // them at the moment they acted on it. const lastSweepOpts = ref<{ olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean }>({}); const loading = ref(false); async function fetchRulebooks() { loading.value = true; try { rulebooks.value = await api.listRulebooks(); } catch (e) { useToastStore().show("Failed to load rulebooks", "error"); throw e; } finally { loading.value = false; } } async function fetchTopics(rulebookId: number) { try { topicsByRulebook.value[rulebookId] = await api.listTopics(rulebookId); } catch (e) { useToastStore().show("Failed to load topics", "error"); throw e; } } async function fetchRules(topicId: number) { try { const rules = await api.listRules({ topic_id: topicId }); rulesByTopic.value[topicId] = rules.map(toHeader); } catch (e) { useToastStore().show("Failed to load rules", "error"); throw e; } } async function fetchRule(id: number) { currentRule.value = await api.getRule(id); } async function createRulebook(data: { title: string; description?: string }) { const rb = await api.createRulebook(data); rulebooks.value.push(rb); return rb; } async function updateRulebook(id: number, data: Partial>) { const rb = await api.updateRulebook(id, data); const idx = rulebooks.value.findIndex((r) => r.id === id); if (idx >= 0) rulebooks.value[idx] = rb; return rb; } async function toggleAlwaysOn(id: number) { const current = rulebooks.value.find((r) => r.id === id); if (!current) return; return updateRulebook(id, { always_on: !current.always_on }); } async function deleteRulebook(id: number) { await api.deleteRulebook(id); rulebooks.value = rulebooks.value.filter((r) => r.id !== id); delete topicsByRulebook.value[id]; } async function createTopic(rulebookId: number, data: { title: string; description?: string }) { const topic = await api.createTopic(rulebookId, data); if (!topicsByRulebook.value[rulebookId]) topicsByRulebook.value[rulebookId] = []; topicsByRulebook.value[rulebookId].push(topic); return topic; } async function updateTopic(id: number, data: Partial>) { const topic = await api.updateTopic(id, data); for (const rbId of Object.keys(topicsByRulebook.value)) { const list = topicsByRulebook.value[Number(rbId)]; const idx = list.findIndex((t) => t.id === id); if (idx >= 0) list[idx] = topic; } return topic; } async function deleteTopic(id: number) { await api.deleteTopic(id); for (const rbId of Object.keys(topicsByRulebook.value)) { topicsByRulebook.value[Number(rbId)] = topicsByRulebook.value[Number(rbId)].filter((t) => t.id !== id); } delete rulesByTopic.value[id]; } /** * A list row built from a full rule. The row shape is the server's * rule_brief, so every field it carries has to be mirrored here or the two * disagree the moment a list is patched locally instead of re-fetched. */ function toHeader(rule: Rule): api.RuleHeader { return { id: rule.id, title: rule.title, statement: rule.statement, topic_id: rule.topic_id, tier: rule.tier, updated_at: rule.updated_at, when_to_apply: rule.when_to_apply || undefined, arose_from_id: rule.arose_from_id ?? undefined, // Mirrors services.rulebooks.last_verified_label: present ONLY when the // rule carries a check, and "never" rather than absent when it has one // nobody has run. Computed here so a row just written looks identical to // the same row re-fetched, instead of losing its chip until a reload. last_verified: rule.verify_with ? (rule.verified_at ? rule.verified_at.slice(0, 10) : "never") : undefined, }; } async function createRule(topicId: number, data: Partial & { title: string; statement: string }) { const rule = await api.createRule(topicId, data); if (!rulesByTopic.value[topicId]) rulesByTopic.value[topicId] = []; rulesByTopic.value[topicId].push(toHeader(rule)); return rule; } async function updateRule(id: number, data: Partial) { const rule = await api.updateRule(id, data); if (currentRule.value?.id === id) currentRule.value = rule; for (const tid of Object.keys(rulesByTopic.value)) { const list = rulesByTopic.value[Number(tid)]; const idx = list.findIndex((r) => r.id === id); if (idx >= 0) list[idx] = toHeader(rule); } return rule; } async function relateRules( fromRuleId: number, data: { to_rule_id: number; kind: api.RuleRelationKind; note?: string }, ) { await api.relateRules(fromRuleId, data); // Re-read rather than patching locally: the edge reads from BOTH ends, so // the far rule's relations changed too and a local splice would show only // half of what just happened. await fetchRule(fromRuleId); } async function unrelateRules(relationId: number, refreshRuleId: number) { await api.unrelateRules(relationId); await fetchRule(refreshRuleId); } /** The staleness sweep: rules asserting a fact, oldest verification first. */ async function fetchRulesDue(opts: { olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean; } = {}) { loading.value = true; lastSweepOpts.value = opts; try { const data = await api.listRulesDueForVerification(opts); rulesDue.value = data.rules; } finally { loading.value = false; } } /** * Record that a rule's check was RUN, and what it said. * * A pass re-sorts the row to the back of the sweep, so the list is re-read * rather than patched: the whole point of this surface is an ORDER, and a * locally-mutated row would sit in its old position claiming a new date. * A failure writes nothing server-side and the row keeps its place — also * correct, and also what a re-read shows. */ async function verifyRule(id: number, stillTrue: boolean) { const rule = await api.markRuleVerified(id, stillTrue); if (currentRule.value?.id === id) currentRule.value = rule; for (const tid of Object.keys(rulesByTopic.value)) { const list = rulesByTopic.value[Number(tid)]; const idx = list.findIndex((r) => r.id === id); if (idx >= 0) list[idx] = toHeader(rule); } if (rulesDue.value.length) await fetchRulesDue(lastSweepOpts.value); return rule; } async function deleteRule(id: number) { await api.deleteRule(id); if (currentRule.value?.id === id) currentRule.value = null; for (const tid of Object.keys(rulesByTopic.value)) { rulesByTopic.value[Number(tid)] = rulesByTopic.value[Number(tid)].filter((r) => r.id !== id); } } return { rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading, fetchRulebooks, fetchTopics, fetchRules, fetchRule, createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook, createTopic, updateTopic, deleteTopic, createRule, updateRule, deleteRule, relateRules, unrelateRules, fetchRulesDue, verifyRule, }; });