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/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
+ }