import type { RecordUsage } from "@/types/usage"; import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client"; /** A lesson: a transferable insight, retrievable by the SITUATION it applies * to rather than by its topic. * * The fields mirror what the backend composes and reads back * (`services/lessons.py::lesson_to_dict`), not the stored row. `title` and * `body` are DERIVED — the service builds them from `what`, `when_to_apply` * and `insight` — so an editor sends the three parts and never the document. * That is the whole design: the trigger ends up in the title and again at the * head of the body, which is what makes a lesson rank on when it applies. */ export interface Lesson { id: number; /** The composed document title, `{what} — {when_to_apply}`. Read-only. */ title: string; /** The composed body. Read-only — edit `insight` instead. */ body: string; /** The claim itself, as you would say it. */ what: string; /** WHEN this applies — the situation, in the words it presents itself in. * The entire retrieval story: a lesson without one saves, reads correctly * and never surfaces, so both doors refuse an empty one. */ when_to_apply: string; /** The body with the composed lines stripped — what an edit form binds to, * so saving doesn't accumulate a copy of the trigger line per save. */ insight: string; /** Ids of the records that taught this — issues, tasks or notes. */ learned_from: number[]; /** The same sources RESOLVED, sent by the detail route only. A bare "#4181" * on a page tells a reader nothing about whether it is worth opening, and * the provenance is the point of a lesson — one that loses its incidents * loses its evidence. A source that has been deleted drops out rather than * rendering a link to nothing. */ learned_from_records?: { id: number; title: string; note_type: string; is_task: boolean; task_kind: string | null; status: string | null; }[]; tags: string[]; note_type: string; /** Where it was LEARNED. Kept as a fact, but not a limit on where it can be * found: a lesson is retrievable from every project (milestone 385 step 3). */ project_id: number | null; permission?: string; created_at: string | null; updated_at: string | null; systems?: { id: number; name: string }[]; usage?: RecordUsage; /** Set when another user owns this record. */ shared?: boolean; owner?: string | null; } /** A row in the browse listing — the trigger travels with it, because a list * of lessons without their triggers is a list of claims with the half that * says when each one matters left off. */ export interface LessonListRow { id: number; title: string; tags: string[]; when_to_apply?: string; snippet?: string; shared?: boolean; owner?: string | null; } export interface LessonListResponse { lessons: LessonListRow[]; total: number; } /** What the create/update forms send. `what` and `when_to_apply` are required * on create; every field is optional on update, and the service re-composes * the whole document from the merged set — so a partial save can never leave * the title and body disagreeing about the trigger. */ export interface LessonPayload { what?: string; when_to_apply?: string; insight?: string; learned_from?: number[]; tags?: string[]; project_id?: number | null; system_ids?: number[]; /** Deliberate override of the near-duplicate gate, once the writer has seen * the warning. Two lessons under one trigger compete for one reserved slot, * so a duplicate displaces rather than merely clutters. */ force?: boolean; } export function listLessons(params: { q?: string; tag?: string; project_id?: number; limit?: number; offset?: number; } = {}): Promise { const qs = new URLSearchParams(); if (params.q) qs.set("q", params.q); if (params.tag) qs.set("tag", params.tag); if (params.project_id) qs.set("project_id", String(params.project_id)); if (params.limit != null) qs.set("limit", String(params.limit)); if (params.offset != null) qs.set("offset", String(params.offset)); const suffix = qs.toString() ? `?${qs}` : ""; return apiGet(`/api/lessons${suffix}`); } export function getLesson(id: number): Promise { return apiGet(`/api/lessons/${id}`); } export function createLesson(payload: LessonPayload): Promise { return apiPost("/api/lessons", payload); } export function updateLesson( id: number, payload: LessonPayload, ): Promise { return apiPatch(`/api/lessons/${id}`, payload); } /** Trash, not erase — recoverable. `apiDelete` discards the body, which is the * established shape here (snippets delete the same way): the batch id is in * the response, but no caller has needed it and inventing a second delete * helper to carry it would be the duplication, not the feature. */ export function deleteLesson(id: number): Promise { return apiDelete(`/api/lessons/${id}`); } /** The lessons drawn FROM one record — the reverse of `learned_from`. * * The direction that gets forgotten, and arguably the more useful one: a * reader opening an old issue wants to know what was learned from it, and * without this the relation is only navigable from the lesson's side. */ export function lessonsTaughtBy( recordId: number, ): Promise<{ lessons: Lesson[]; taught_by: number }> { return apiGet<{ lessons: Lesson[]; taught_by: number }>( `/api/lessons/taught-by/${recordId}`, ); }