learnhouse-mcp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +564 -0
- package/package.json +35 -0
- package/src/apply_aesthetics.ts +46 -0
- package/src/client.ts +480 -0
- package/src/index.ts +532 -0
- package/src/test-api.ts +185 -0
- package/src/types.ts +243 -0
- package/tsconfig.json +17 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LearnHouse API Client
|
|
3
|
+
*
|
|
4
|
+
* A TypeScript client for interacting with the LearnHouse LMS API.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
LearnHouseConfig,
|
|
9
|
+
LoginResponse,
|
|
10
|
+
User,
|
|
11
|
+
Organization,
|
|
12
|
+
Course,
|
|
13
|
+
CourseCreate,
|
|
14
|
+
CourseUpdate,
|
|
15
|
+
FullCourse,
|
|
16
|
+
Chapter,
|
|
17
|
+
ChapterCreate,
|
|
18
|
+
ChapterUpdate,
|
|
19
|
+
Activity,
|
|
20
|
+
ActivityCreate,
|
|
21
|
+
ActivityUpdate,
|
|
22
|
+
Collection,
|
|
23
|
+
CollectionCreate,
|
|
24
|
+
TrailProgress,
|
|
25
|
+
ActivityStatus,
|
|
26
|
+
SearchResult,
|
|
27
|
+
ApiError,
|
|
28
|
+
} from "./types.js";
|
|
29
|
+
|
|
30
|
+
export class LearnHouseClient {
|
|
31
|
+
private config: LearnHouseConfig;
|
|
32
|
+
private accessToken?: string;
|
|
33
|
+
|
|
34
|
+
constructor(config: LearnHouseConfig) {
|
|
35
|
+
this.config = {
|
|
36
|
+
...config,
|
|
37
|
+
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
|
38
|
+
};
|
|
39
|
+
this.accessToken = config.accessToken;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ============================================================
|
|
43
|
+
// HTTP Helpers
|
|
44
|
+
// ============================================================
|
|
45
|
+
|
|
46
|
+
private async request<T>(
|
|
47
|
+
method: string,
|
|
48
|
+
path: string,
|
|
49
|
+
options: {
|
|
50
|
+
body?: unknown;
|
|
51
|
+
formData?: FormData;
|
|
52
|
+
query?: Record<string, string | number | boolean | undefined>;
|
|
53
|
+
} = {}
|
|
54
|
+
): Promise<T> {
|
|
55
|
+
const url = new URL(`${this.config.baseUrl}/api/v1${path}`);
|
|
56
|
+
|
|
57
|
+
// Add query parameters
|
|
58
|
+
if (options.query) {
|
|
59
|
+
Object.entries(options.query).forEach(([key, value]) => {
|
|
60
|
+
if (value !== undefined) {
|
|
61
|
+
url.searchParams.set(key, String(value));
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const headers: Record<string, string> = {};
|
|
67
|
+
|
|
68
|
+
if (this.accessToken) {
|
|
69
|
+
headers["Authorization"] = `Bearer ${this.accessToken}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let body: string | FormData | undefined;
|
|
73
|
+
|
|
74
|
+
if (options.formData) {
|
|
75
|
+
body = options.formData;
|
|
76
|
+
// Don't set Content-Type for FormData - browser will set it with boundary
|
|
77
|
+
} else if (options.body) {
|
|
78
|
+
headers["Content-Type"] = "application/json";
|
|
79
|
+
body = JSON.stringify(options.body);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const response = await fetch(url.toString(), {
|
|
83
|
+
method,
|
|
84
|
+
headers,
|
|
85
|
+
body,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
const error = await response.json().catch(() => ({
|
|
90
|
+
detail: `HTTP ${response.status}: ${response.statusText}`,
|
|
91
|
+
})) as ApiError;
|
|
92
|
+
throw new Error(error.detail || `Request failed: ${response.status}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Handle empty responses
|
|
96
|
+
const text = await response.text();
|
|
97
|
+
if (!text) {
|
|
98
|
+
return {} as T;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return JSON.parse(text) as T;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private get<T>(path: string, query?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
|
105
|
+
return this.request<T>("GET", path, { query });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private post<T>(path: string, body?: unknown, query?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
|
109
|
+
return this.request<T>("POST", path, { body, query });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private put<T>(path: string, body?: unknown): Promise<T> {
|
|
113
|
+
return this.request<T>("PUT", path, { body });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private delete<T>(path: string): Promise<T> {
|
|
117
|
+
return this.request<T>("DELETE", path);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private postForm<T>(path: string, formData: FormData, query?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
|
121
|
+
return this.request<T>("POST", path, { formData, query });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private putForm<T>(path: string, formData: FormData, query?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
|
125
|
+
return this.request<T>("PUT", path, { formData, query });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ============================================================
|
|
129
|
+
// Authentication
|
|
130
|
+
// ============================================================
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Login with email and password
|
|
134
|
+
*/
|
|
135
|
+
async login(email: string, password: string): Promise<LoginResponse> {
|
|
136
|
+
const formData = new URLSearchParams();
|
|
137
|
+
formData.set("username", email);
|
|
138
|
+
formData.set("password", password);
|
|
139
|
+
|
|
140
|
+
const response = await fetch(`${this.config.baseUrl}/api/v1/auth/login`, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: {
|
|
143
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
144
|
+
},
|
|
145
|
+
body: formData.toString(),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (!response.ok) {
|
|
149
|
+
const error = await response.json().catch(() => ({ detail: "Login failed" })) as { detail?: string };
|
|
150
|
+
throw new Error(error.detail || "Login failed");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const result = await response.json() as LoginResponse;
|
|
154
|
+
this.accessToken = result.tokens.access_token;
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Get current access token
|
|
160
|
+
*/
|
|
161
|
+
getAccessToken(): string | undefined {
|
|
162
|
+
return this.accessToken;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Set access token manually
|
|
167
|
+
*/
|
|
168
|
+
setAccessToken(token: string): void {
|
|
169
|
+
this.accessToken = token;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get current authenticated user
|
|
174
|
+
*/
|
|
175
|
+
async getCurrentUser(): Promise<User> {
|
|
176
|
+
return this.get<User>("/users/profile");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ============================================================
|
|
180
|
+
// Organizations
|
|
181
|
+
// ============================================================
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* List all organizations
|
|
185
|
+
*/
|
|
186
|
+
async listOrganizations(): Promise<Organization[]> {
|
|
187
|
+
return this.get<Organization[]>("/orgs/");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Get organization by ID
|
|
192
|
+
*/
|
|
193
|
+
async getOrganization(orgId: number): Promise<Organization> {
|
|
194
|
+
return this.get<Organization>(`/orgs/${orgId}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get organization by slug
|
|
199
|
+
*/
|
|
200
|
+
async getOrganizationBySlug(slug: string): Promise<Organization> {
|
|
201
|
+
return this.get<Organization>(`/orgs/slug/${slug}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ============================================================
|
|
205
|
+
// Courses
|
|
206
|
+
// ============================================================
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* List courses for the configured organization
|
|
210
|
+
*/
|
|
211
|
+
async listCourses(page: number = 1, limit: number = 50): Promise<Course[]> {
|
|
212
|
+
const org = await this.getOrganization(this.config.orgId);
|
|
213
|
+
return this.get<Course[]>(`/courses/org_slug/${org.slug}/page/${page}/limit/${limit}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Get course by UUID
|
|
218
|
+
*/
|
|
219
|
+
async getCourse(courseUuid: string): Promise<Course> {
|
|
220
|
+
return this.get<Course>(`/courses/${courseUuid}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Get course by numeric ID
|
|
225
|
+
*/
|
|
226
|
+
async getCourseById(courseId: number): Promise<Course> {
|
|
227
|
+
return this.get<Course>(`/courses/id/${courseId}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Get course with full metadata (chapters & activities)
|
|
232
|
+
*/
|
|
233
|
+
async getCourseMeta(courseUuid: string, withUnpublished: boolean = false): Promise<FullCourse> {
|
|
234
|
+
return this.get<FullCourse>(`/courses/${courseUuid}/meta`, {
|
|
235
|
+
with_unpublished_activities: withUnpublished,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Create a new course
|
|
241
|
+
*/
|
|
242
|
+
async createCourse(course: CourseCreate): Promise<Course> {
|
|
243
|
+
const formData = new FormData();
|
|
244
|
+
formData.set("name", course.name);
|
|
245
|
+
formData.set("description", course.description);
|
|
246
|
+
formData.set("public", String(course.public ?? true));
|
|
247
|
+
formData.set("about", course.about ?? course.description);
|
|
248
|
+
|
|
249
|
+
if (course.learnings) {
|
|
250
|
+
formData.set("learnings", JSON.stringify(course.learnings));
|
|
251
|
+
}
|
|
252
|
+
if (course.tags) {
|
|
253
|
+
formData.set("tags", JSON.stringify(course.tags));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return this.postForm<Course>(`/courses/`, formData, { org_id: this.config.orgId });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Update a course
|
|
261
|
+
*/
|
|
262
|
+
async updateCourse(courseUuid: string, updates: CourseUpdate): Promise<Course> {
|
|
263
|
+
return this.put<Course>(`/courses/${courseUuid}`, updates);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Upload a course thumbnail
|
|
268
|
+
*/
|
|
269
|
+
async uploadCourseThumbnail(courseUuid: string, imageBuffer: Buffer, fileName: string, type: "IMAGE" | "VIDEO" = "IMAGE"): Promise<Course> {
|
|
270
|
+
const formData = new FormData();
|
|
271
|
+
const blob = new Blob([imageBuffer], { type: "image/png" });
|
|
272
|
+
formData.append("thumbnail", blob, fileName);
|
|
273
|
+
formData.append("thumbnail_type", type);
|
|
274
|
+
|
|
275
|
+
return this.putForm<Course>(`/courses/${courseUuid}/thumbnail`, formData);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Delete a course
|
|
280
|
+
*/
|
|
281
|
+
async deleteCourse(courseUuid: string): Promise<void> {
|
|
282
|
+
await this.delete(`/courses/${courseUuid}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ============================================================
|
|
286
|
+
// Chapters
|
|
287
|
+
// ============================================================
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Get chapter by ID
|
|
291
|
+
*/
|
|
292
|
+
async getChapter(chapterId: number): Promise<Chapter> {
|
|
293
|
+
return this.get<Chapter>(`/chapters/${chapterId}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* List chapters for a course
|
|
298
|
+
*/
|
|
299
|
+
async listChapters(courseId: number, page: number = 1, limit: number = 50): Promise<Chapter[]> {
|
|
300
|
+
return this.get<Chapter[]>(`/chapters/course/${courseId}/page/${page}/limit/${limit}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Create a new chapter
|
|
305
|
+
*/
|
|
306
|
+
async createChapter(chapter: ChapterCreate): Promise<Chapter> {
|
|
307
|
+
return this.post<Chapter>("/chapters/", chapter);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Update a chapter
|
|
312
|
+
*/
|
|
313
|
+
async updateChapter(chapterId: number, updates: ChapterUpdate): Promise<Chapter> {
|
|
314
|
+
return this.put<Chapter>(`/chapters/${chapterId}`, updates);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Delete a chapter
|
|
319
|
+
*/
|
|
320
|
+
async deleteChapter(chapterId: number): Promise<void> {
|
|
321
|
+
await this.delete(`/chapters/${chapterId}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ============================================================
|
|
325
|
+
// Activities
|
|
326
|
+
// ============================================================
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Get activity by UUID
|
|
330
|
+
*/
|
|
331
|
+
async getActivity(activityUuid: string): Promise<Activity> {
|
|
332
|
+
return this.get<Activity>(`/activities/${activityUuid}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Get activity by numeric ID
|
|
337
|
+
*/
|
|
338
|
+
async getActivityById(activityId: number): Promise<Activity> {
|
|
339
|
+
return this.get<Activity>(`/activities/id/${activityId}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* List activities for a chapter
|
|
344
|
+
*/
|
|
345
|
+
async listActivities(chapterId: number): Promise<Activity[]> {
|
|
346
|
+
return this.get<Activity[]>(`/activities/chapter/${chapterId}`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Create a new activity
|
|
351
|
+
*/
|
|
352
|
+
async createActivity(activity: ActivityCreate): Promise<Activity> {
|
|
353
|
+
return this.post<Activity>("/activities/", {
|
|
354
|
+
...activity,
|
|
355
|
+
activity_type: activity.activity_type ?? "TYPE_DYNAMIC",
|
|
356
|
+
activity_sub_type: activity.activity_sub_type ?? "SUBTYPE_DYNAMIC_PAGE",
|
|
357
|
+
content: activity.content ?? { type: "doc", content: [] },
|
|
358
|
+
published: activity.published ?? false,
|
|
359
|
+
details: activity.details ?? {},
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Update an activity
|
|
365
|
+
*/
|
|
366
|
+
async updateActivity(activityUuid: string, updates: ActivityUpdate): Promise<Activity> {
|
|
367
|
+
return this.put<Activity>(`/activities/${activityUuid}`, updates);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Delete an activity
|
|
372
|
+
*/
|
|
373
|
+
async deleteActivity(activityUuid: string): Promise<void> {
|
|
374
|
+
await this.delete(`/activities/${activityUuid}`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ============================================================
|
|
378
|
+
// Collections
|
|
379
|
+
// ============================================================
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* List collections for the configured organization
|
|
383
|
+
*/
|
|
384
|
+
async listCollections(page: number = 1, limit: number = 50): Promise<Collection[]> {
|
|
385
|
+
return this.get<Collection[]>(`/collections/org/${this.config.orgId}/page/${page}/limit/${limit}`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Get collection by UUID
|
|
390
|
+
*/
|
|
391
|
+
async getCollection(collectionUuid: string): Promise<Collection> {
|
|
392
|
+
return this.get<Collection>(`/collections/${collectionUuid}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Create a new collection
|
|
397
|
+
*/
|
|
398
|
+
async createCollection(collection: CollectionCreate): Promise<Collection> {
|
|
399
|
+
return this.post<Collection>("/collections/", {
|
|
400
|
+
...collection,
|
|
401
|
+
org_id: this.config.orgId,
|
|
402
|
+
public: collection.public ?? true,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Update a collection
|
|
408
|
+
*/
|
|
409
|
+
async updateCollection(collectionUuid: string, updates: Partial<CollectionCreate>): Promise<Collection> {
|
|
410
|
+
return this.put<Collection>(`/collections/${collectionUuid}`, updates);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Delete a collection
|
|
415
|
+
*/
|
|
416
|
+
async deleteCollection(collectionUuid: string): Promise<void> {
|
|
417
|
+
await this.delete(`/collections/${collectionUuid}`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ============================================================
|
|
421
|
+
// Progress / Trail
|
|
422
|
+
// ============================================================
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Get progress for a course
|
|
426
|
+
*/
|
|
427
|
+
async getCourseProgress(courseUuid: string): Promise<TrailProgress> {
|
|
428
|
+
return this.get<TrailProgress>(`/trail/course/${courseUuid}/completion`);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Get activity completion status
|
|
433
|
+
*/
|
|
434
|
+
async getActivityStatus(activityUuid: string): Promise<ActivityStatus> {
|
|
435
|
+
return this.get<ActivityStatus>(`/trail/activity/${activityUuid}/status`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Mark activity as complete
|
|
440
|
+
*/
|
|
441
|
+
async markActivityComplete(activityUuid: string): Promise<{ completed: boolean }> {
|
|
442
|
+
return this.post<{ completed: boolean }>(`/trail/activity/${activityUuid}/mark_complete`);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Mark activity as incomplete
|
|
447
|
+
*/
|
|
448
|
+
async markActivityIncomplete(activityUuid: string): Promise<{ completed: boolean }> {
|
|
449
|
+
return this.post<{ completed: boolean }>(`/trail/activity/${activityUuid}/mark_incomplete`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ============================================================
|
|
453
|
+
// Search
|
|
454
|
+
// ============================================================
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Search across courses, users, and collections
|
|
458
|
+
*/
|
|
459
|
+
async search(orgSlug: string, query: string): Promise<SearchResult> {
|
|
460
|
+
return this.get<SearchResult>(`/search/org_slug/${orgSlug}`, {
|
|
461
|
+
query: query,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ============================================================
|
|
466
|
+
// Health
|
|
467
|
+
// ============================================================
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Check API health
|
|
471
|
+
*/
|
|
472
|
+
async healthCheck(): Promise<Record<string, unknown>> {
|
|
473
|
+
return this.get<Record<string, unknown>>("/health");
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Export a factory function
|
|
478
|
+
export function createClient(config: LearnHouseConfig): LearnHouseClient {
|
|
479
|
+
return new LearnHouseClient(config);
|
|
480
|
+
}
|