diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts index 1c52447..2c3eb8c 100644 --- a/frontend/src/api/rulebooks.ts +++ b/frontend/src/api/rulebooks.ts @@ -1,5 +1,24 @@ import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client"; +/** How a rule reaches a session (milestone 307). */ +export type RuleTier = "always_on" | "conditional"; + +/** + * A typed edge between two rules. Each kind exists because its absence forced + * a workaround: merging two rules into one row, writing an override as a + * near-copy, or leaving a local addendum with nothing to say it is one. + */ +export type RuleRelationKind = "co_surfaces" | "overrides" | "elaborates"; + +export interface RuleRelation { + id: number; + kind: RuleRelationKind; + /** The rule at the OTHER end. */ + rule_id: number; + direction: "outgoing" | "incoming"; + note: string; +} + export interface Rulebook { id: number; owner_user_id: number; @@ -26,35 +45,53 @@ export interface Rule { project_id: number | null; title: string; statement: string; + /** WHEN this rule fires — the trigger, not the instruction. */ + when_to_apply: string; + /** + * always_on preloads into every session; conditional is reachable and + * surfaced when its trigger fires. A rule with no tier set behaves as + * always_on, which is how every rule behaved before this existed. + */ + tier: RuleTier; why: string; how_to_apply: string; + /** The note or task that caused this rule, if one was recorded. */ + arose_from_id: number | null; order_index: number; created_at: string | null; updated_at: string | null; + /** Present only when the rule has them (the server omits empty keys). */ + systems?: { id: number; name: string }[]; + relations?: RuleRelation[]; } +/** + * A rule as a LIST ROW — services.rulebooks.rule_brief's output. Carries the + * age deliberately: a rule written before the capability it duplicates is + * otherwise indistinguishable, at a glance, from one still doing work. + */ export interface RuleHeader { id: number; title: string; statement: string; topic_id: number | null; + tier: RuleTier; + /** A date (YYYY-MM-DD), not a timestamp. */ + updated_at: string | null; + when_to_apply?: string; + arose_from_id?: number; } export interface ApplicableRules { - rules: { - id: number; - title: string; - statement: string; - topic_id: number; + // Both lists are rule_brief's output — the SAME builder, so they are + // described the same way here rather than as two hand-written shapes that + // drift from it and from each other (which is what the server side had). + rules: (RuleHeader & { topic_title: string; rulebook_id: number; rulebook_title: string; - }[]; - project_rules: { - id: number; - title: string; - statement: string; - }[]; + })[]; + project_rules: RuleHeader[]; suppressed_rules: { id: number; title: string; @@ -133,14 +170,39 @@ 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 { +/** The fields both write paths accept. `system_ids` REPLACES a rule's areas. */ +export interface RuleWrite { + title: string; + statement: string; + when_to_apply: string; + tier: RuleTier; + why: string; + how_to_apply: string; + order_index: number; + system_ids: number[]; + arose_from_id: number | null; +} + +export async function createRule(topicId: number, data: Partial & { title: string; statement: string }): 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 { +export async function updateRule(id: number, data: Partial): Promise { return apiPatch(`/api/rules/${id}`, data); } +/** Draw a typed edge from one rule to another. Idempotent. */ +export async function relateRules( + fromRuleId: number, + data: { to_rule_id: number; kind: RuleRelationKind; note?: string }, +): Promise<{ id: number }> { + return apiPost(`/api/rules/${fromRuleId}/relations`, data); +} + +export async function unrelateRules(relationId: number): Promise { + return apiDelete(`/api/rule-relations/${relationId}`); +} + export async function deleteRule(id: number): Promise { return apiDelete(`/api/rules/${id}`); } @@ -161,7 +223,7 @@ export async function getProjectApplicableRules(projectId: number): Promise & { statement: string }, ): Promise { return apiPost(`/api/projects/${projectId}/rules`, data); } diff --git a/frontend/src/components/rules/ProjectRulesTab.vue b/frontend/src/components/rules/ProjectRulesTab.vue index cd989b0..8fdd3fb 100644 --- a/frontend/src/components/rules/ProjectRulesTab.vue +++ b/frontend/src/components/rules/ProjectRulesTab.vue @@ -27,7 +27,10 @@ const expandedRuleIds = ref>(new Set()); const ruleDetails = ref>({}); const showProjectRuleForm = ref(false); -const newProjectRule = ref({ title: "", statement: "", why: "", how_to_apply: "" }); +const newProjectRule = ref({ + title: "", statement: "", why: "", how_to_apply: "", + when_to_apply: "", tier: "always_on" as "always_on" | "conditional", +}); async function load() { applicable.value = await getProjectApplicableRules(props.projectId); @@ -90,14 +93,20 @@ interface RulebookGroup { function groupByRulebookAndTopic(rules: ApplicableRules["rules"]): RulebookGroup[] { const byRulebook = new Map(); for (const r of rules) { + // A rule carries topic_id XOR project_id. Only rulebook-scoped rules reach + // this list, so a null topic would be a server-side contradiction — skip + // it rather than widen the group's type to accommodate a case that means + // something is wrong upstream. + if (r.topic_id === null) continue; + const topicId = r.topic_id; let rb = byRulebook.get(r.rulebook_id); if (!rb) { rb = { rulebook_id: r.rulebook_id, rulebook_title: r.rulebook_title, topics: [] }; byRulebook.set(r.rulebook_id, rb); } - let topic = rb.topics.find((t) => t.topic_id === r.topic_id); + let topic = rb.topics.find((t) => t.topic_id === topicId); if (!topic) { - topic = { topic_id: r.topic_id, topic_title: r.topic_title, rules: [] }; + topic = { topic_id: topicId, topic_title: r.topic_title, rules: [] }; rb.topics.push(topic); } topic.rules.push(r); @@ -113,8 +122,13 @@ async function submitProjectRule() { title: newProjectRule.value.title.trim() || undefined, why: newProjectRule.value.why.trim() || undefined, how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined, + when_to_apply: newProjectRule.value.when_to_apply.trim() || undefined, + tier: newProjectRule.value.tier, }); - newProjectRule.value = { title: "", statement: "", why: "", how_to_apply: "" }; + newProjectRule.value = { + title: "", statement: "", why: "", how_to_apply: "", + when_to_apply: "", tier: "always_on", + }; showProjectRuleForm.value = false; await load(); } @@ -219,6 +233,24 @@ watch(() => props.projectId, load); placeholder="Statement (required) — the actionable instruction, 1-2 sentences" rows="2" > + +
+ + + + Conditional if you had to name a system, an artifact or a moment to state the trigger. + +