CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 42s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m32s
CI & Build / Build & push image (push) Successful in 31s
CI 7059's typecheck, two errors on one line: `apiDelete` returns `Promise<void>` and takes no type argument. I had given it the response body's shape, which it discards. Matched to how snippets delete, rather than adding a second delete helper to carry the batch id — no caller has wanted it, and the second helper would be the duplication rather than the feature. Everything else in the UI batch typechecked clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
147 lines
5.5 KiB
TypeScript
147 lines
5.5 KiB
TypeScript
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<LessonListResponse> {
|
|
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<LessonListResponse>(`/api/lessons${suffix}`);
|
|
}
|
|
|
|
export function getLesson(id: number): Promise<Lesson> {
|
|
return apiGet<Lesson>(`/api/lessons/${id}`);
|
|
}
|
|
|
|
export function createLesson(payload: LessonPayload): Promise<Lesson> {
|
|
return apiPost<Lesson>("/api/lessons", payload);
|
|
}
|
|
|
|
export function updateLesson(
|
|
id: number,
|
|
payload: LessonPayload,
|
|
): Promise<Lesson> {
|
|
return apiPatch<Lesson>(`/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<void> {
|
|
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}`,
|
|
);
|
|
}
|