diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts new file mode 100644 index 0000000..77685a6 --- /dev/null +++ b/frontend/src/api/rulebooks.ts @@ -0,0 +1,135 @@ +import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client"; + +export interface Rulebook { + id: number; + owner_user_id: number; + title: string; + description: string; + created_at: string | null; + updated_at: string | null; +} + +export interface RulebookTopic { + id: number; + rulebook_id: number; + title: string; + description: string; + order_index: number; + created_at: string | null; + updated_at: string | null; +} + +export interface Rule { + id: number; + topic_id: number; + title: string; + statement: string; + why: string; + how_to_apply: string; + order_index: number; + created_at: string | null; + updated_at: string | null; +} + +export interface RuleHeader { + id: number; + title: string; + statement: string; + topic_id: number; +} + +export interface ApplicableRules { + rules: { + id: number; + title: string; + statement: string; + topic_title: string; + rulebook_title: string; + }[]; + truncated: boolean; + subscribed_rulebooks: { id: number; title: string }[]; +} + +// ── Rulebooks ─────────────────────────────────────────────────────── + +export async function listRulebooks(): Promise { + const data = await apiGet<{ rulebooks: Rulebook[] }>("/api/rulebooks"); + return data.rulebooks; +} + +export async function getRulebook(id: number): Promise { + return apiGet(`/api/rulebooks/${id}`); +} + +export async function createRulebook(data: { title: string; description?: string }): Promise { + return apiPost("/api/rulebooks", data); +} + +export async function updateRulebook(id: number, data: Partial<{ title: string; description: string }>): Promise { + return apiPatch(`/api/rulebooks/${id}`, data); +} + +export async function deleteRulebook(id: number): Promise { + return apiDelete(`/api/rulebooks/${id}`); +} + +// ── Topics ───────────────────────────────────────────────────────── + +export async function listTopics(rulebookId: number): Promise { + const data = await apiGet<{ topics: RulebookTopic[] }>(`/api/rulebooks/${rulebookId}/topics`); + return data.topics; +} + +export async function createTopic(rulebookId: number, data: { title: string; description?: string; order_index?: number }): Promise { + return apiPost(`/api/rulebooks/${rulebookId}/topics`, data); +} + +export async function updateTopic(id: number, data: Partial<{ title: string; description: string; order_index: number }>): Promise { + return apiPatch(`/api/rulebook-topics/${id}`, data); +} + +export async function deleteTopic(id: number): Promise { + return apiDelete(`/api/rulebook-topics/${id}`); +} + +// ── Rules ────────────────────────────────────────────────────────── + +export async function listRules(filters: { rulebook_id?: number; topic_id?: number; project_id?: number } = {}): Promise { + const params = new URLSearchParams(); + if (filters.rulebook_id) params.set("rulebook_id", String(filters.rulebook_id)); + if (filters.topic_id) params.set("topic_id", String(filters.topic_id)); + if (filters.project_id) params.set("project_id", String(filters.project_id)); + const qs = params.toString(); + const data = await apiGet<{ rules: Rule[] }>(`/api/rules${qs ? `?${qs}` : ""}`); + return data.rules; +} + +export async function getRule(id: number): Promise { + return apiGet(`/api/rules/${id}`); +} + +export async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string; order_index?: number }): Promise { + return apiPost(`/api/rulebook-topics/${topicId}/rules`, data); +} + +export async function updateRule(id: number, data: Partial<{ title: string; statement: string; why: string; how_to_apply: string; order_index: number }>): Promise { + return apiPatch(`/api/rules/${id}`, data); +} + +export async function deleteRule(id: number): Promise { + return apiDelete(`/api/rules/${id}`); +} + +// ── Subscriptions ────────────────────────────────────────────────── + +export async function subscribeProject(projectId: number, rulebookId: number): Promise { + await apiPost(`/api/projects/${projectId}/rulebook-subscriptions`, { rulebook_id: rulebookId }); +} + +export async function unsubscribeProject(projectId: number, rulebookId: number): Promise { + return apiDelete(`/api/projects/${projectId}/rulebook-subscriptions/${rulebookId}`); +} + +export async function getProjectApplicableRules(projectId: number): Promise { + return apiGet(`/api/projects/${projectId}/rules`); +} diff --git a/frontend/src/stores/rulebooks.ts b/frontend/src/stores/rulebooks.ts new file mode 100644 index 0000000..61d2b88 --- /dev/null +++ b/frontend/src/stores/rulebooks.ts @@ -0,0 +1,128 @@ +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 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((r) => ({ + id: r.id, title: r.title, statement: r.statement, topic_id: r.topic_id, + })); + } 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 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]; + } + + async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string }) { + const rule = await api.createRule(topicId, data); + if (!rulesByTopic.value[topicId]) rulesByTopic.value[topicId] = []; + rulesByTopic.value[topicId].push({ id: rule.id, title: rule.title, statement: rule.statement, topic_id: rule.topic_id }); + 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] = { id: rule.id, title: rule.title, statement: rule.statement, topic_id: rule.topic_id }; + } + 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, loading, + fetchRulebooks, fetchTopics, fetchRules, fetchRule, + createRulebook, updateRulebook, deleteRulebook, + createTopic, updateTopic, deleteTopic, + createRule, updateRule, deleteRule, + }; +});