@anantagnanam/lms 0.0.0-stage → 0.2.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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -2
  3. package/astro/client.ts +355 -0
  4. package/astro/components/CertificateCard.astro +83 -0
  5. package/astro/components/CourseCard.astro +61 -0
  6. package/astro/components/CourseCatalog.astro +96 -0
  7. package/astro/components/CourseOverview.astro +308 -0
  8. package/astro/components/LearnerDashboard.astro +127 -0
  9. package/astro/components/LearningShell.astro +37 -0
  10. package/astro/components/LessonAvailabilityNote.astro +26 -0
  11. package/astro/components/LessonContent.astro +53 -0
  12. package/astro/components/LessonPlayer.astro +302 -0
  13. package/astro/components/MediaPlayer.astro +111 -0
  14. package/astro/components/index.ts +7 -0
  15. package/astro/layouts/AcademyLayout.astro +84 -0
  16. package/astro/media-player.ts +99 -0
  17. package/astro/pages/author-preview.astro +71 -0
  18. package/astro/pages/certificate.astro +57 -0
  19. package/astro/pages/cohort.astro +102 -0
  20. package/astro/pages/course.astro +90 -0
  21. package/astro/pages/dashboard.astro +34 -0
  22. package/astro/pages/index.astro +40 -0
  23. package/astro/pages/lesson.astro +103 -0
  24. package/astro/pages/library.astro +173 -0
  25. package/astro/pages/paths.astro +73 -0
  26. package/astro/pages/teach.astro +15 -0
  27. package/astro/quiz-drafts.ts +87 -0
  28. package/astro/quiz-player.ts +362 -0
  29. package/astro/styles.css +1300 -0
  30. package/dist/admin.d.ts +34 -0
  31. package/dist/admin.js +3231 -0
  32. package/dist/admin.js.map +1 -0
  33. package/dist/astro.d.ts +11 -0
  34. package/dist/astro.js +52 -0
  35. package/dist/astro.js.map +1 -0
  36. package/dist/commerce.d.ts +72 -0
  37. package/dist/commerce.js +0 -0
  38. package/dist/config-BX3beTpM.js +15 -0
  39. package/dist/config-BX3beTpM.js.map +1 -0
  40. package/dist/constants-Cnx7k82Z.js +161 -0
  41. package/dist/constants-Cnx7k82Z.js.map +1 -0
  42. package/dist/csv-DKq_4OWU.js +55 -0
  43. package/dist/csv-DKq_4OWU.js.map +1 -0
  44. package/dist/errors-ChzJS9al.js +16 -0
  45. package/dist/errors-ChzJS9al.js.map +1 -0
  46. package/dist/index.d.ts +13 -0
  47. package/dist/index.js +19 -0
  48. package/dist/index.js.map +1 -0
  49. package/dist/runtime.d.ts +773 -0
  50. package/dist/runtime.js +2840 -0
  51. package/dist/runtime.js.map +1 -0
  52. package/dist/schemas.d.ts +651 -0
  53. package/dist/schemas.js +347 -0
  54. package/dist/schemas.js.map +1 -0
  55. package/dist/server.d.ts +40 -0
  56. package/dist/server.js +65 -0
  57. package/dist/server.js.map +1 -0
  58. package/dist/types.d.ts +387 -0
  59. package/dist/types.js +0 -0
  60. package/package.json +83 -4
@@ -0,0 +1,2840 @@
1
+ import { a as adminPages, i as PLUGIN_VERSION, n as MAX_DOCUMENT_BYTES, o as storage, r as PLUGIN_ID } from "./constants-Cnx7k82Z.js";
2
+ import { t as normalizeLmsOptions } from "./config-BX3beTpM.js";
3
+ import { assignmentSubmitSchema, catalogSchema, certificateActionSchema, certificateRevokeSchema, cohortSaveSchema, courseDataSchema, courseIdSchema, courseSaveSchema, discussionSchema, enrollmentAdminSchema, enrollmentRequestSchema, gradeSchema, idSchema, keySchema, lessonIdSchema, moderateSchema, noteSchema, pageSchema, pathSaveSchema, playbackSchema, preferencesSchema, quizDraftSchema, quizSubmitSchema, reviewSchema, settingsSchema } from "./schemas.js";
4
+ import { n as invariant, t as LmsError } from "./errors-ChzJS9al.js";
5
+ import { n as enrollmentCsvLines, t as enrollmentCsvHeader } from "./csv-DKq_4OWU.js";
6
+ import { definePlugin, definePluginRoute, pluginResponse } from "emdash";
7
+ import { z } from "zod";
8
+
9
+ //#region src/store.ts
10
+ function collections(ctx) {
11
+ return ctx.storage;
12
+ }
13
+ function enrollmentKey(courseId, userId) {
14
+ return `${courseId}:${userId}`;
15
+ }
16
+ function assertSize(data) {
17
+ invariant(new TextEncoder().encode(JSON.stringify(data)).byteLength <= MAX_DOCUMENT_BYTES, "DOCUMENT_TOO_LARGE", "This record exceeds the LMS storage limit. Split the course into smaller courses.", 413);
18
+ }
19
+ async function change(store, id, update) {
20
+ for (let attempt = 0; attempt < 8; attempt++) {
21
+ const current = await store.getVersioned(id);
22
+ const { value, result } = await update(current?.value ?? null);
23
+ assertSize(value);
24
+ if ((await store.compareAndSet(id, current?.revision ?? null, value)).applied) return result;
25
+ }
26
+ throw new LmsError("CONFLICT", "Another request changed this record. Please try again.", 409);
27
+ }
28
+ async function queryPage(store, options = {}) {
29
+ const page = await store.query(options);
30
+ return {
31
+ ...page,
32
+ items: page.items.map((item) => item.data)
33
+ };
34
+ }
35
+ async function putOnce(store, id, value) {
36
+ assertSize(value);
37
+ await store.compareAndSet(id, null, value);
38
+ }
39
+
40
+ //#endregion
41
+ //#region src/editions.ts
42
+ /** Child snapshots are immutable. Only the root CAS makes a revision visible. */
43
+ var CourseEditions = class {
44
+ constructor(stores, time, resolveContent) {
45
+ this.stores = stores;
46
+ this.time = time;
47
+ this.resolveContent = resolveContent;
48
+ }
49
+ async snapshot(courseId, data) {
50
+ const frozen = structuredClone(data);
51
+ for (const module of frozen.modules) for (let i = 0; i < module.lessons.length; i++) module.lessons[i] = {
52
+ ...await this.resolveContent(module.lessons[i]),
53
+ contentSource: null
54
+ };
55
+ const id = crypto.randomUUID();
56
+ await putOnce(this.stores.course_editions, id, {
57
+ id,
58
+ courseId,
59
+ data: frozen,
60
+ createdAt: this.time()
61
+ });
62
+ return id;
63
+ }
64
+ async revision(course, data, actorId, kind, extra = {}) {
65
+ const revision = {
66
+ id: crypto.randomUUID(),
67
+ courseId: course.id,
68
+ parentId: course.draftRevisionId ?? null,
69
+ data: courseDataSchema.parse({
70
+ ...data,
71
+ status: "draft"
72
+ }),
73
+ kind,
74
+ createdBy: actorId,
75
+ createdAt: this.time(),
76
+ ...extra
77
+ };
78
+ await putOnce(this.stores.course_revisions, revision.id, revision);
79
+ return revision;
80
+ }
81
+ async commit(current, course) {
82
+ assertSize(course);
83
+ const saved = await this.stores.courses.compareAndSet(course.id, current?.revision ?? null, course);
84
+ invariant(saved.applied, "CONFLICT", "The course changed. Reload before continuing.", 409);
85
+ return {
86
+ value: course,
87
+ revision: saved.revision
88
+ };
89
+ }
90
+ async ensure(id, expectedRevision) {
91
+ for (let attempt = 0; attempt < 8; attempt++) {
92
+ const current = await this.stores.courses.getVersioned(id);
93
+ invariant(current, "NOT_FOUND", "Course not found.", 404);
94
+ invariant(expectedRevision === void 0 || current.revision === expectedRevision, "CONFLICT", "The course changed. Reload before continuing.", 409);
95
+ if (current.value.schemaVersion === 2) return current;
96
+ const course = current.value;
97
+ invariant(course.schemaVersion === 1, "UNSUPPORTED_SCHEMA", "This course needs a different LMS version. No migration was applied.", 409);
98
+ const data = courseDataSchema.parse(course);
99
+ const editionId = await this.snapshot(id, data);
100
+ const revision = await this.revision(course, data, course.createdBy, "migration", { editionId });
101
+ const edition = await this.stores.course_editions.get(editionId);
102
+ const next = {
103
+ ...course,
104
+ ...edition.data,
105
+ schemaVersion: 2,
106
+ draftRevisionId: revision.id,
107
+ legacyEditionId: editionId,
108
+ ...course.status !== "draft" ? {
109
+ publishedEditionId: editionId,
110
+ publishedDraftRevisionId: revision.id
111
+ } : {}
112
+ };
113
+ try {
114
+ return await this.commit(current, next);
115
+ } catch (error) {
116
+ if (!(error instanceof LmsError) || error.code !== "CONFLICT" || expectedRevision !== void 0) throw error;
117
+ }
118
+ }
119
+ throw new LmsError("CONFLICT", "Course migration conflicted. Please retry.", 409);
120
+ }
121
+ async draft(course) {
122
+ if (!course.draftRevisionId) return course;
123
+ const revision = await this.stores.course_revisions.get(course.draftRevisionId);
124
+ invariant(revision?.courseId === course.id, "EDITION_UNAVAILABLE", "The saved course draft is unavailable.", 503);
125
+ return {
126
+ ...course,
127
+ ...revision.data,
128
+ status: course.status
129
+ };
130
+ }
131
+ async learning(course, enrollment) {
132
+ if (!enrollment) return course;
133
+ const id = enrollment.courseEditionId ?? course.legacyEditionId ?? course.publishedEditionId;
134
+ if (!id) return course;
135
+ const edition = await this.stores.course_editions.get(id);
136
+ invariant(edition?.courseId === course.id, "EDITION_UNAVAILABLE", "The enrolled course edition is unavailable.", 503);
137
+ return {
138
+ ...course,
139
+ ...edition.data,
140
+ status: course.status,
141
+ slug: course.slug,
142
+ instructorIds: course.instructorIds
143
+ };
144
+ }
145
+ async history(course, cursor) {
146
+ let id = course.draftRevisionId;
147
+ let found = !cursor;
148
+ const items = [];
149
+ for (let i = 0; id && i < 1e3; i++) {
150
+ const revision = await this.stores.course_revisions.get(id);
151
+ invariant(revision?.courseId === course.id, "EDITION_UNAVAILABLE", "Course history is unavailable.", 503);
152
+ if (id === cursor) found = true;
153
+ if (found) {
154
+ const { data, ...summary } = revision;
155
+ items.push({
156
+ ...summary,
157
+ title: data.title
158
+ });
159
+ }
160
+ id = revision.parentId ?? void 0;
161
+ if (items.length === 20) return {
162
+ items,
163
+ cursor: id,
164
+ hasMore: !!id
165
+ };
166
+ }
167
+ invariant(found && !id, "INVALID_CURSOR", "History cursor is invalid or exceeds the 1,000-revision browsing limit.", 400);
168
+ return {
169
+ items,
170
+ hasMore: false
171
+ };
172
+ }
173
+ async migrationStatus() {
174
+ return await this.stores.migrations.get("course-editions-v2") ?? {
175
+ phase: "courses",
176
+ coursesProcessed: 0,
177
+ enrollmentsProcessed: 0,
178
+ updatedAt: this.time(),
179
+ lastError: null
180
+ };
181
+ }
182
+ async migrateBatch(limit) {
183
+ const id = "course-editions-v2";
184
+ const current = await this.stores.migrations.getVersioned(id);
185
+ const state = current?.value ?? await this.migrationStatus();
186
+ if (state.phase === "complete") return state;
187
+ const next = {
188
+ ...state,
189
+ lastError: null,
190
+ updatedAt: this.time()
191
+ };
192
+ try {
193
+ if (state.phase === "courses") {
194
+ const page = await this.stores.courses.query({
195
+ orderBy: { createdAt: "asc" },
196
+ cursor: state.cursor,
197
+ limit
198
+ });
199
+ for (const item of page.items) await this.ensure(item.id);
200
+ next.coursesProcessed += page.items.length;
201
+ next.cursor = page.cursor;
202
+ if (!page.hasMore) {
203
+ next.phase = "enrollments";
204
+ next.cursor = void 0;
205
+ }
206
+ } else {
207
+ const page = await this.stores.enrollments.query({
208
+ orderBy: { enrolledAt: "asc" },
209
+ cursor: state.cursor,
210
+ limit
211
+ });
212
+ for (const item of page.items) {
213
+ if (item.data.courseEditionId) continue;
214
+ const course = (await this.ensure(item.data.courseId)).value;
215
+ await change(this.stores.enrollments, item.id, async (enrollment) => {
216
+ invariant(enrollment, "NOT_FOUND", "Enrollment disappeared during migration.", 409);
217
+ return {
218
+ value: {
219
+ ...enrollment,
220
+ courseEditionId: enrollment.courseEditionId ?? course.legacyEditionId ?? course.publishedEditionId
221
+ },
222
+ result: null
223
+ };
224
+ });
225
+ }
226
+ next.enrollmentsProcessed += page.items.length;
227
+ next.cursor = page.cursor;
228
+ if (!page.hasMore) {
229
+ next.phase = "complete";
230
+ next.cursor = void 0;
231
+ }
232
+ }
233
+ } catch (error) {
234
+ await this.stores.migrations.compareAndSet(id, current?.revision ?? null, {
235
+ ...state,
236
+ updatedAt: this.time(),
237
+ lastError: error instanceof LmsError ? error.message : "Migration failed. Retry the batch after checking storage availability."
238
+ });
239
+ throw error;
240
+ }
241
+ invariant((await this.stores.migrations.compareAndSet(id, current?.revision ?? null, next)).applied, "CONFLICT", "Another migration batch advanced the checkpoint. Refresh and continue.", 409);
242
+ return next;
243
+ }
244
+ };
245
+
246
+ //#endregion
247
+ //#region src/curriculum.ts
248
+ function lessons(course) {
249
+ return course.modules.flatMap((module) => module.lessons);
250
+ }
251
+ function emptyProgress() {
252
+ return {
253
+ startedAt: null,
254
+ completedAt: null,
255
+ note: "",
256
+ quizAttempts: [],
257
+ assignments: []
258
+ };
259
+ }
260
+ function courseCard(course) {
261
+ const all = lessons(course);
262
+ return {
263
+ id: course.id,
264
+ slug: course.slug,
265
+ title: course.title,
266
+ summary: course.summary,
267
+ category: course.category,
268
+ tags: course.tags,
269
+ level: course.level,
270
+ language: course.language,
271
+ featuredImage: course.featuredImage,
272
+ instructorName: course.instructorName,
273
+ enrollmentMode: course.enrollmentMode,
274
+ lessonCount: all.length,
275
+ durationMinutes: all.reduce((sum, lesson) => sum + lesson.durationMinutes, 0)
276
+ };
277
+ }
278
+ function courseDetail(course) {
279
+ return {
280
+ prerequisites: [],
281
+ ...courseCard(course),
282
+ description: course.description,
283
+ outcomes: course.outcomes,
284
+ requirements: course.requirements,
285
+ sequential: course.sequential,
286
+ prerequisiteCourseIds: course.prerequisiteCourseIds,
287
+ enrollmentClosesAt: course.enrollmentClosesAt,
288
+ accessDays: course.accessDays,
289
+ certificateEnabled: course.certificateEnabled,
290
+ modules: course.modules.map((module) => ({
291
+ id: module.id,
292
+ title: module.title,
293
+ description: module.description,
294
+ releaseDays: module.releaseDays,
295
+ unlockAt: module.unlockAt,
296
+ lessons: module.lessons.map((lesson) => ({
297
+ id: lesson.id,
298
+ title: lesson.title,
299
+ summary: lesson.summary,
300
+ kind: lesson.kind,
301
+ durationMinutes: lesson.durationMinutes,
302
+ preview: lesson.preview,
303
+ required: lesson.required
304
+ }))
305
+ }))
306
+ };
307
+ }
308
+ function publicLesson(lesson) {
309
+ const { quiz, contentSource: _source, ...safe } = lesson;
310
+ return {
311
+ ...safe,
312
+ transcript: lesson.transcript ?? "",
313
+ captions: lesson.captions ?? [],
314
+ quiz: quiz ? {
315
+ passingPercent: quiz.passingPercent,
316
+ maxAttempts: quiz.maxAttempts,
317
+ timeLimitMinutes: quiz.timeLimitMinutes,
318
+ randomizeQuestions: quiz.randomizeQuestions,
319
+ randomizeOptions: quiz.randomizeOptions,
320
+ showFeedback: quiz.showFeedback,
321
+ questionCount: quiz.questions.length
322
+ } : null
323
+ };
324
+ }
325
+ function publicAttempt(attempt) {
326
+ const { quiz: _quiz, ...safe } = attempt;
327
+ return {
328
+ ...safe,
329
+ draftRevision: attempt.draftRevision ?? 0
330
+ };
331
+ }
332
+ function effectiveAccess(enrollment, now = /* @__PURE__ */ new Date()) {
333
+ if (enrollment.status === "suspended" || enrollment.accessStatus === "suspended") return "suspended";
334
+ if (["rejected", "cancelled"].includes(enrollment.status) || enrollment.accessStatus === "revoked") return "revoked";
335
+ if (enrollment.status === "pending" || enrollment.accessStatus === "pending") return "pending";
336
+ if (enrollment.accessExpiresAt && now.getTime() >= Date.parse(enrollment.accessExpiresAt)) return "expired";
337
+ if (enrollment.accessStartsAt && now.getTime() < Date.parse(enrollment.accessStartsAt)) return "scheduled";
338
+ const grants = Object.values(enrollment.grants);
339
+ if (grants.some((grant) => grant.status === "granted" && (!grant.startsAt || now.getTime() >= Date.parse(grant.startsAt)) && (!grant.expiresAt || now.getTime() < Date.parse(grant.expiresAt)))) return "granted";
340
+ if (grants.some((grant) => grant.status === "granted" && grant.startsAt && now.getTime() < Date.parse(grant.startsAt) && (!grant.expiresAt || now.getTime() < Date.parse(grant.expiresAt)))) return "scheduled";
341
+ if (grants.some((grant) => grant.status === "suspended")) return "suspended";
342
+ if (grants.some((grant) => grant.status === "granted" && grant.expiresAt && now.getTime() >= Date.parse(grant.expiresAt))) return "expired";
343
+ return "revoked";
344
+ }
345
+ function publicEnrollment(enrollment, now = /* @__PURE__ */ new Date()) {
346
+ const { learnerEmail: _email, progress, ...safe } = enrollment;
347
+ return {
348
+ ...safe,
349
+ effectiveAccess: effectiveAccess(enrollment, now),
350
+ progress: Object.fromEntries(Object.entries(progress).map(([id, item]) => [id, {
351
+ ...item,
352
+ quizAttempts: item.quizAttempts.map(publicAttempt)
353
+ }]))
354
+ };
355
+ }
356
+ /** Staff need assessment and completion records, not a learner's private study notes. */
357
+ function staffEnrollment(enrollment, now = /* @__PURE__ */ new Date()) {
358
+ const view = publicEnrollment(enrollment, now);
359
+ for (const progress of Object.values(view.progress)) {
360
+ progress.note = "";
361
+ delete progress.playback;
362
+ }
363
+ return view;
364
+ }
365
+ function progressSummary(course, enrollment, now = /* @__PURE__ */ new Date()) {
366
+ const required = enrollment?.completedAt ? enrollment.completionLessonIds : lessons(course).filter((lesson) => lesson.required).map((lesson) => lesson.id);
367
+ const completed = required.filter((id) => enrollment?.progress[id]?.completedAt).length;
368
+ const all = lessons(course);
369
+ const availability = Object.fromEntries(all.map((lesson) => [lesson.id, lessonAvailability(course, lesson.id, enrollment, now)]));
370
+ const incomplete = all.filter((lesson) => !enrollment?.progress[lesson.id]?.completedAt);
371
+ const nextLessonId = incomplete.find((lesson) => availability[lesson.id].available)?.id ?? null;
372
+ return {
373
+ completed,
374
+ total: required.length,
375
+ percent: required.length ? Math.floor(completed / required.length * 100) : 0,
376
+ nextLessonId,
377
+ blockedLessonId: nextLessonId ? null : incomplete[0]?.id ?? null,
378
+ availability
379
+ };
380
+ }
381
+ /** Use the same access boundary for navigation and private lesson reads. Enrollment
382
+ * must already have its cohort grants resolved against authoritative membership. */
383
+ function lessonAvailability(course, lessonId, enrollment, now) {
384
+ try {
385
+ assertLessonAccess(course, lessonId, enrollment, now);
386
+ return {
387
+ available: true,
388
+ code: null,
389
+ reason: null,
390
+ releaseAt: null
391
+ };
392
+ } catch (error) {
393
+ if (!(error instanceof LmsError)) throw error;
394
+ let releaseAt = null;
395
+ if (enrollment && error.code === "LESSON_LOCKED") releaseAt = new Date(moduleReleaseAt(course, lessonId, enrollment)).toISOString();
396
+ else if (enrollment && effectiveAccess(enrollment, now) === "scheduled") {
397
+ const starts = Object.values(enrollment.grants).filter((grant) => grant.status === "granted" && (!grant.expiresAt || Date.parse(grant.expiresAt) > now.getTime())).flatMap((grant) => {
398
+ const date = Math.max(grant.startsAt ? Date.parse(grant.startsAt) : 0, enrollment.accessStartsAt ? Date.parse(enrollment.accessStartsAt) : 0);
399
+ return date > now.getTime() && (!grant.expiresAt || date < Date.parse(grant.expiresAt)) && (!enrollment.accessExpiresAt || date < Date.parse(enrollment.accessExpiresAt)) ? [date] : [];
400
+ });
401
+ if (starts.length) releaseAt = new Date(Math.min(...starts)).toISOString();
402
+ }
403
+ return {
404
+ available: false,
405
+ code: error.code,
406
+ reason: error.message,
407
+ releaseAt
408
+ };
409
+ }
410
+ }
411
+ function moduleReleaseAt(course, lessonId, enrollment) {
412
+ const module = course.modules.find((item) => item.lessons.some((entry) => entry.id === lessonId));
413
+ return Math.max(module.unlockAt ? Date.parse(module.unlockAt) : 0, Date.parse(enrollment.approvedAt ?? enrollment.enrolledAt) + module.releaseDays * 864e5);
414
+ }
415
+ function assertEnrollmentAccess(enrollment, now) {
416
+ invariant(enrollment && (enrollment.status === "active" || enrollment.status === "completed"), "ENROLLMENT_REQUIRED", "An active enrollment is required for this lesson.", 403);
417
+ invariant(enrollment.accessStatus === "granted", "ACCESS_RESTRICTED", "Your course access is not currently granted.", 403);
418
+ invariant(Object.values(enrollment.grants).some((grant) => grant.status === "granted" && (!grant.startsAt || now.getTime() >= Date.parse(grant.startsAt)) && (!grant.expiresAt || now.getTime() < Date.parse(grant.expiresAt))), "GRANT_REQUIRED", "Your course access grant has not started, expired, or been revoked.", 403);
419
+ invariant(!enrollment.accessStartsAt || now.getTime() >= Date.parse(enrollment.accessStartsAt), "COURSE_NOT_STARTED", "Your course access has not started yet.", 403);
420
+ invariant(!enrollment.accessExpiresAt || now.getTime() < Date.parse(enrollment.accessExpiresAt), "ACCESS_EXPIRED", "Your course access has expired.", 403);
421
+ }
422
+ function findLesson(course, lessonId) {
423
+ const lesson = lessons(course).find((item) => item.id === lessonId);
424
+ invariant(lesson, "NOT_FOUND", "Lesson not found.", 404);
425
+ return lesson;
426
+ }
427
+ function assertLessonAccess(course, lessonId, enrollment, now, allowPreview = false) {
428
+ const lesson = findLesson(course, lessonId);
429
+ if (allowPreview && lesson.preview && ![
430
+ "quiz",
431
+ "assignment",
432
+ "live"
433
+ ].includes(lesson.kind)) return lesson;
434
+ assertEnrollmentAccess(enrollment, now);
435
+ invariant(course.status !== "draft", "NOT_FOUND", "Course not found.", 404);
436
+ const releaseAt = moduleReleaseAt(course, lessonId, enrollment);
437
+ invariant(now.getTime() >= releaseAt, "LESSON_LOCKED", "This module has not been released yet.", 403);
438
+ if (course.sequential) {
439
+ const all = lessons(course);
440
+ const index = all.findIndex((item) => item.id === lessonId);
441
+ invariant(all.slice(0, index).filter((item) => item.required).every((item) => enrollment.progress[item.id]?.completedAt), "PREREQUISITE_REQUIRED", "Complete the earlier required lessons first.", 403);
442
+ }
443
+ return lesson;
444
+ }
445
+ function shuffle(items, random = () => {
446
+ const value = new Uint32Array(1);
447
+ crypto.getRandomValues(value);
448
+ return value[0] / 4294967296;
449
+ }) {
450
+ const output = [...items];
451
+ for (let index = output.length - 1; index > 0; index--) {
452
+ const pick = Math.floor(random() * (index + 1));
453
+ [output[index], output[pick]] = [output[pick], output[index]];
454
+ }
455
+ return output;
456
+ }
457
+ function normalized(value) {
458
+ return value.normalize("NFKC").trim().toLocaleLowerCase("en").replace(/\s+/g, " ");
459
+ }
460
+ function gradeAnswers(questions, answers) {
461
+ const ids = new Set(questions.map((question) => question.id));
462
+ invariant(Object.keys(answers).every((id) => ids.has(id)), "INVALID_ANSWERS", "Answers contain an unknown question.");
463
+ const feedback = questions.map((question) => {
464
+ const response = answers[question.id];
465
+ const selected = Array.isArray(response) ? response : response === void 0 ? [] : [response];
466
+ invariant(new Set(selected).size === selected.length, "INVALID_ANSWERS", "Duplicate answer choices are not accepted.");
467
+ if (question.type !== "multiple") invariant(selected.length <= 1, "INVALID_ANSWERS", "This question accepts one answer.");
468
+ if (question.type !== "short") invariant(selected.every((answer) => question.options.some((option) => option.id === answer)), "INVALID_ANSWERS", "An answer contains an invalid option.");
469
+ const correct = question.type === "short" ? selected.length === 1 && question.correctAnswers.some((answer) => normalized(answer) === normalized(selected[0])) : selected.length === question.correctAnswers.length && selected.every((answer) => question.correctAnswers.includes(answer));
470
+ return {
471
+ questionId: question.id,
472
+ correct,
473
+ points: correct ? question.points : 0,
474
+ maxPoints: question.points,
475
+ explanation: question.explanation
476
+ };
477
+ });
478
+ const score = feedback.reduce((sum, item) => sum + item.points, 0);
479
+ const maxScore = questions.reduce((sum, question) => sum + question.points, 0);
480
+ return {
481
+ score,
482
+ maxScore,
483
+ percent: Math.round(score / maxScore * 1e4) / 100,
484
+ feedback
485
+ };
486
+ }
487
+
488
+ //#endregion
489
+ //#region src/service.ts
490
+ function parse(schema, input) {
491
+ const result = schema.safeParse(input);
492
+ if (!result.success) throw new LmsError("VALIDATION_ERROR", result.error.issues.map((issue) => `${issue.path.join(".") || "Input"}: ${issue.message}`).slice(0, 5).join(" "), 400);
493
+ return result.data;
494
+ }
495
+ function user$1(actor) {
496
+ invariant(actor, "UNAUTHORIZED", "Sign in to continue.", 401);
497
+ return actor;
498
+ }
499
+ var LmsService = class {
500
+ stores;
501
+ editions;
502
+ constructor(ctx, now = () => /* @__PURE__ */ new Date()) {
503
+ this.ctx = ctx;
504
+ this.now = now;
505
+ this.stores = collections(ctx);
506
+ this.editions = new CourseEditions(this.stores, () => this.time(), (lesson) => this.resolveContent(lesson));
507
+ }
508
+ time() {
509
+ return this.now().toISOString();
510
+ }
511
+ async settings() {
512
+ return settingsSchema.parse(await this.ctx.settings.get("configuration") ?? {});
513
+ }
514
+ async saveSettings(actor, input) {
515
+ invariant(user$1(actor).role >= 50, "FORBIDDEN", "Only administrators can change LMS settings.", 403);
516
+ const settings = parse(settingsSchema, input);
517
+ invariant(!settings.emailNotifications || this.ctx.email, "EMAIL_UNAVAILABLE", "Configure an email transport in EmDash before enabling LMS emails.");
518
+ await this.ctx.settings.set("configuration", settings);
519
+ await this.audit(actor, "settings.save", "configuration");
520
+ return settings;
521
+ }
522
+ async audit(actor, action, resourceId, details = {}) {
523
+ const id = crypto.randomUUID();
524
+ await this.stores.audit.put(id, {
525
+ id,
526
+ actorId: actor.id,
527
+ action,
528
+ resourceId,
529
+ createdAt: this.time(),
530
+ details
531
+ });
532
+ }
533
+ async course(id) {
534
+ const course = await this.stores.courses.get(id);
535
+ invariant(course, "NOT_FOUND", "Course not found.", 404);
536
+ return course;
537
+ }
538
+ async accessEnrollment(enrollment) {
539
+ if (!enrollment) return null;
540
+ const grants = Object.values(enrollment.grants).filter((grant) => grant.source === "cohort");
541
+ if (!grants.length) return enrollment;
542
+ const [memberships, cohorts] = await Promise.all([this.stores.cohort_enrollments.getMany(grants.map((grant) => enrollmentKey(grant.referenceId ?? "", enrollment.userId))), this.stores.cohorts.getMany(grants.map((grant) => grant.referenceId ?? ""))]);
543
+ const resolved = structuredClone(enrollment);
544
+ for (const grant of grants) {
545
+ const membership = memberships.get(enrollmentKey(grant.referenceId ?? "", enrollment.userId));
546
+ const cohort = cohorts.get(grant.referenceId ?? "");
547
+ if (membership?.status !== "active" || !cohort?.courseIds.includes(enrollment.courseId)) resolved.grants[grant.id].status = membership?.status === "suspended" ? "suspended" : "revoked";
548
+ else {
549
+ resolved.grants[grant.id].status = "granted";
550
+ resolved.grants[grant.id].startsAt = cohort.startsAt;
551
+ resolved.grants[grant.id].expiresAt = cohort.endsAt;
552
+ }
553
+ }
554
+ return resolved;
555
+ }
556
+ async enrollmentView(enrollment) {
557
+ return publicEnrollment(await this.accessEnrollment(enrollment), this.now());
558
+ }
559
+ async learningState(course, enrollment) {
560
+ course = await this.editions.learning(course, enrollment);
561
+ const resolved = await this.accessEnrollment(enrollment);
562
+ const now = this.now();
563
+ return {
564
+ enrollment: resolved ? publicEnrollment(resolved, now) : null,
565
+ progress: progressSummary(course, resolved, now)
566
+ };
567
+ }
568
+ async lessonAccess(course, lessonId, enrollment) {
569
+ return assertLessonAccess(await this.editions.learning(course, enrollment), lessonId, await this.accessEnrollment(enrollment), this.now());
570
+ }
571
+ instructor(course, actor) {
572
+ const current = user$1(actor);
573
+ invariant(current.role >= 20 && (current.role >= 50 || course.createdBy === current.id || course.instructorIds.includes(current.id)), "FORBIDDEN", "You are not an instructor for this course.", 403);
574
+ return current;
575
+ }
576
+ async saveCourse(actor, input) {
577
+ const currentActor = user$1(actor);
578
+ const parsed = parse(courseSaveSchema, input);
579
+ const id = parsed.id ?? crypto.randomUUID();
580
+ let current = await this.stores.courses.getVersioned(id);
581
+ if (current) this.instructor(current.value, currentActor);
582
+ else invariant(currentActor.role >= 20, "FORBIDDEN", "Course authoring requires a contributor account or higher.", 403);
583
+ invariant((current?.revision ?? null) === parsed.expectedRevision, "CONFLICT", "The course changed since you opened it. Reload before saving.", 409);
584
+ if (!current && parsed.data.status === "published") invariant(currentActor.role >= 50 || currentActor.role >= 30, "FORBIDDEN", "Publishing requires an author account or higher.", 403);
585
+ invariant(current || parsed.data.status !== "published" || parsed.data.commerce.accessModel === "free", "PAID_COURSES_DISABLED", "Payment processing is not enabled in this release. Publish free courses only.", 409);
586
+ invariant(!(await this.stores.courses.query({
587
+ where: { slug: parsed.data.slug },
588
+ limit: 1
589
+ })).items.some((item) => item.id !== id), "SLUG_TAKEN", "A course already uses this URL.", 409);
590
+ await this.assertPrerequisiteGraph(id, parsed.data.prerequisiteCourseIds);
591
+ if (current) current = await this.editions.ensure(id, current.revision);
592
+ let course = {
593
+ ...parsed.data,
594
+ id,
595
+ searchTitle: parsed.data.title.toLocaleLowerCase("en"),
596
+ createdBy: current?.value.createdBy ?? currentActor.id,
597
+ createdAt: current?.value.createdAt ?? this.time(),
598
+ updatedAt: this.time(),
599
+ schemaVersion: 2
600
+ };
601
+ const draft = await this.editions.revision(current?.value ?? course, parsed.data, currentActor.id, "draft");
602
+ if (current) {
603
+ course = {
604
+ ...current.value,
605
+ ...current.value.publishedEditionId ? {} : parsed.data,
606
+ status: current.value.status,
607
+ draftRevisionId: draft.id,
608
+ updatedAt: this.time()
609
+ };
610
+ course.instructorIds = parsed.data.instructorIds;
611
+ } else {
612
+ course.draftRevisionId = draft.id;
613
+ if (course.status === "published") {
614
+ const editionId = await this.editions.snapshot(id, parsed.data);
615
+ const edition = await this.stores.course_editions.get(editionId);
616
+ course = {
617
+ ...course,
618
+ ...edition.data,
619
+ publishedEditionId: editionId,
620
+ publishedDraftRevisionId: draft.id
621
+ };
622
+ }
623
+ }
624
+ course.searchTitle = course.title.toLocaleLowerCase("en");
625
+ const saved = await this.editions.commit(current, course);
626
+ await this.audit(currentActor, "course.save", id, { draftRevisionId: draft.id });
627
+ return {
628
+ course: await this.editions.draft(course),
629
+ revision: saved.revision
630
+ };
631
+ }
632
+ async publishCourse(actor, id, expectedRevision) {
633
+ const owner = this.instructor(await this.course(id), actor);
634
+ invariant(owner.role >= 30, "FORBIDDEN", "Publishing requires an author account or higher.", 403);
635
+ const current = await this.editions.ensure(id, expectedRevision);
636
+ const data = parse(courseDataSchema, {
637
+ ...await this.editions.draft(current.value),
638
+ status: "published"
639
+ });
640
+ invariant(data.commerce.accessModel === "free", "PAID_COURSES_DISABLED", "Publish free courses only.", 409);
641
+ await this.assertPrerequisiteGraph(id, data.prerequisiteCourseIds);
642
+ invariant(!(await this.stores.courses.query({
643
+ where: { slug: data.slug },
644
+ limit: 1
645
+ })).items.some((row) => row.id !== id), "SLUG_TAKEN", "A course already uses this URL.", 409);
646
+ const editionId = await this.editions.snapshot(id, data);
647
+ const revision = await this.editions.revision(current.value, data, owner.id, "publish", { editionId });
648
+ const edition = await this.stores.course_editions.get(editionId);
649
+ const next = {
650
+ ...current.value,
651
+ ...edition.data,
652
+ searchTitle: data.title.toLocaleLowerCase("en"),
653
+ draftRevisionId: revision.id,
654
+ publishedDraftRevisionId: revision.id,
655
+ publishedEditionId: editionId,
656
+ updatedAt: this.time()
657
+ };
658
+ const saved = await this.editions.commit(current, next);
659
+ await this.audit(owner, "course.publish", id, { editionId });
660
+ return {
661
+ course: await this.editions.draft(next),
662
+ revision: saved.revision
663
+ };
664
+ }
665
+ async courseHistory(actor, id, cursor) {
666
+ const course = await this.course(id);
667
+ this.instructor(course, actor);
668
+ return this.editions.history(course, cursor);
669
+ }
670
+ async restoreCourse(actor, id, expectedRevision, revisionId) {
671
+ const owner = this.instructor(await this.course(id), actor);
672
+ const current = await this.editions.ensure(id, expectedRevision);
673
+ invariant((await this.editions.history(current.value, revisionId)).items[0]?.id === revisionId, "NOT_FOUND", "Saved revision not found.", 404);
674
+ const data = {
675
+ ...(await this.stores.course_revisions.get(revisionId)).data,
676
+ instructorIds: current.value.instructorIds
677
+ };
678
+ const revision = await this.editions.revision(current.value, data, owner.id, "restore", { restoredFrom: revisionId });
679
+ const next = {
680
+ ...current.value,
681
+ draftRevisionId: revision.id,
682
+ updatedAt: this.time()
683
+ };
684
+ const saved = await this.editions.commit(current, next);
685
+ await this.audit(owner, "course.restore", id, { revisionId });
686
+ return {
687
+ course: await this.editions.draft(next),
688
+ revision: saved.revision
689
+ };
690
+ }
691
+ async archiveCourse(actor, id, expectedRevision) {
692
+ const owner = this.instructor(await this.course(id), actor);
693
+ invariant(owner.role >= 30, "FORBIDDEN", "Archiving requires an author account or higher.", 403);
694
+ const legacy = await this.stores.courses.getVersioned(id);
695
+ if (legacy?.value.schemaVersion === 1) {
696
+ invariant(legacy.revision === expectedRevision, "CONFLICT", "The course changed. Reload before continuing.", 409);
697
+ const next = {
698
+ ...legacy.value,
699
+ status: "archived",
700
+ updatedAt: this.time()
701
+ };
702
+ const saved = await this.editions.commit(legacy, next);
703
+ await this.audit(owner, "course.archive", id);
704
+ return {
705
+ course: next,
706
+ revision: saved.revision
707
+ };
708
+ }
709
+ const current = await this.editions.ensure(id, expectedRevision);
710
+ const draft = await this.editions.draft(current.value);
711
+ const revision = await this.editions.revision(current.value, draft, owner.id, "archive");
712
+ const next = {
713
+ ...current.value,
714
+ status: "archived",
715
+ draftRevisionId: revision.id,
716
+ updatedAt: this.time()
717
+ };
718
+ const saved = await this.editions.commit(current, next);
719
+ await this.audit(owner, "course.archive", id);
720
+ return {
721
+ course: await this.editions.draft(next),
722
+ revision: saved.revision
723
+ };
724
+ }
725
+ async migrationStatus(actor) {
726
+ invariant(user$1(actor).role >= 50, "FORBIDDEN", "Only administrators can manage migrations.", 403);
727
+ return this.editions.migrationStatus();
728
+ }
729
+ async migrateBatch(actor, limit) {
730
+ await this.migrationStatus(actor);
731
+ const state = await this.editions.migrateBatch(limit);
732
+ if (state.phase === "complete") await this.ctx.kv.set("schemaVersion", 2);
733
+ return state;
734
+ }
735
+ async assertPrerequisiteGraph(courseId, prerequisites) {
736
+ const visited = /* @__PURE__ */ new Set();
737
+ const visit = async (id) => {
738
+ invariant(id !== courseId, "PREREQUISITE_CYCLE", "Course prerequisites cannot form a cycle.");
739
+ if (visited.has(id)) return;
740
+ invariant(visited.size < 100, "PREREQUISITE_GRAPH_TOO_LARGE", "The prerequisite chain is too long.");
741
+ visited.add(id);
742
+ const course = await this.course(id);
743
+ for (const next of course.prerequisiteCourseIds) await visit(next);
744
+ };
745
+ for (const id of prerequisites) await visit(id);
746
+ }
747
+ async editableCourse(actor, id) {
748
+ const current = await this.stores.courses.getVersioned(id);
749
+ invariant(current, "NOT_FOUND", "Course not found.", 404);
750
+ this.instructor(current.value, actor);
751
+ return {
752
+ course: await this.editions.draft(current.value),
753
+ revision: current.revision
754
+ };
755
+ }
756
+ async adminCourses(actor, options = {}) {
757
+ const current = user$1(actor);
758
+ const page = await queryPage(this.stores.courses, {
759
+ where: options.status ? { status: options.status } : {},
760
+ orderBy: { updatedAt: "desc" },
761
+ limit: options.limit ?? 24,
762
+ cursor: options.cursor
763
+ });
764
+ return {
765
+ ...page,
766
+ items: await Promise.all(page.items.filter((course) => current.role >= 20 && (current.role >= 50 || course.createdBy === current.id || course.instructorIds.includes(current.id))).map((course) => this.editions.draft(course)))
767
+ };
768
+ }
769
+ async catalog(input) {
770
+ const options = parse(catalogSchema, input);
771
+ const where = { status: "published" };
772
+ if (options.q.trim()) where.searchTitle = { startsWith: options.q.trim().toLocaleLowerCase("en") };
773
+ if (options.category) where.category = options.category;
774
+ if (options.language) where.language = options.language;
775
+ if (options.level && options.level !== "all") where.level = { in: [options.level, "all"] };
776
+ const page = await queryPage(this.stores.courses, {
777
+ where,
778
+ orderBy: { title: "asc" },
779
+ limit: options.limit,
780
+ cursor: options.cursor
781
+ });
782
+ return {
783
+ ...page,
784
+ items: page.items.map(courseCard)
785
+ };
786
+ }
787
+ async publicCourse(slug) {
788
+ const page = await this.stores.courses.query({
789
+ where: {
790
+ slug,
791
+ status: "published"
792
+ },
793
+ limit: 1
794
+ });
795
+ invariant(page.items[0], "NOT_FOUND", "Course not found.", 404);
796
+ return this.detail(page.items[0].data);
797
+ }
798
+ async detail(course) {
799
+ const prerequisites = await this.stores.courses.getMany(course.prerequisiteCourseIds);
800
+ return {
801
+ ...courseDetail(course),
802
+ prerequisites: course.prerequisiteCourseIds.map((id) => {
803
+ const value = prerequisites.get(id);
804
+ return {
805
+ id,
806
+ title: value?.title ?? "Prerequisite course",
807
+ slug: value?.status === "published" ? value.slug : null
808
+ };
809
+ })
810
+ };
811
+ }
812
+ async preview(courseId, lessonId) {
813
+ const course = await this.course(courseId);
814
+ invariant(course.status === "published", "NOT_FOUND", "Course not found.", 404);
815
+ const lesson = findLesson(course, lessonId);
816
+ invariant(lesson.preview && ![
817
+ "quiz",
818
+ "assignment",
819
+ "live"
820
+ ].includes(lesson.kind), "PREVIEW_UNAVAILABLE", "This lesson is available to enrolled learners.", 403);
821
+ return {
822
+ course: courseDetail(course),
823
+ lesson: publicLesson(await this.resolveContent(lesson))
824
+ };
825
+ }
826
+ async resolveContent(lesson) {
827
+ if (!lesson.contentSource) return lesson;
828
+ invariant(this.ctx.content, "CONTENT_UNAVAILABLE", "Content integration is unavailable.", 503);
829
+ const entry = await this.ctx.content.get(lesson.contentSource.collection, lesson.contentSource.id);
830
+ invariant(entry?.status === "published", "CONTENT_UNAVAILABLE", "The linked lesson content is not published.", 404);
831
+ const body = entry.data[lesson.contentSource.field];
832
+ invariant(Array.isArray(body) || typeof body === "string", "CONTENT_UNAVAILABLE", "The linked lesson field is unavailable.", 404);
833
+ if (Array.isArray(body)) return {
834
+ ...lesson,
835
+ body: "",
836
+ portableText: body
837
+ };
838
+ return {
839
+ ...lesson,
840
+ body,
841
+ portableText: []
842
+ };
843
+ }
844
+ async authorLessonPreview(actor, courseId, lessonId) {
845
+ const { course } = await this.editableCourse(actor, courseId);
846
+ return {
847
+ course: courseDetail(course),
848
+ lesson: publicLesson(await this.resolveContent(findLesson(course, lessonId)))
849
+ };
850
+ }
851
+ async assertPrerequisites(course, actorId) {
852
+ for (const id of course.prerequisiteCourseIds) invariant((await this.stores.enrollments.get(enrollmentKey(id, actorId)))?.completedAt, "COURSE_PREREQUISITE_REQUIRED", "Complete the prerequisite courses before enrolling.", 403);
853
+ }
854
+ newEnrollment(course, actor, status) {
855
+ const now = this.time();
856
+ const active = status === "active";
857
+ return {
858
+ id: enrollmentKey(course.id, actor.id),
859
+ courseId: course.id,
860
+ courseEditionId: course.publishedEditionId ?? course.legacyEditionId,
861
+ userId: actor.id,
862
+ learnerName: actor.name || actor.email.split("@")[0] || "Learner",
863
+ learnerEmail: actor.email,
864
+ status,
865
+ accessStatus: active ? "granted" : "pending",
866
+ accessSource: "free",
867
+ grants: { free: {
868
+ id: "free",
869
+ source: "free",
870
+ referenceId: null,
871
+ status: active ? "granted" : "pending",
872
+ startsAt: null,
873
+ expiresAt: active && course.accessDays ? new Date(this.now().getTime() + course.accessDays * 864e5).toISOString() : null,
874
+ grantedAt: active ? now : null,
875
+ grantedBy: null
876
+ } },
877
+ cohortIds: [],
878
+ acquisition: {
879
+ orderId: null,
880
+ offerId: course.commerce.offerId,
881
+ paymentStatus: "not_required",
882
+ amountMinor: null,
883
+ currency: null,
884
+ providerId: null,
885
+ providerPaymentId: null
886
+ },
887
+ approvedBy: null,
888
+ decisionAt: active ? now : null,
889
+ decisionReason: null,
890
+ enrolledAt: now,
891
+ approvedAt: active ? now : null,
892
+ completedAt: null,
893
+ accessStartsAt: null,
894
+ accessExpiresAt: null,
895
+ cohortId: null,
896
+ progress: {},
897
+ certificate: null,
898
+ completionLessonIds: [],
899
+ lastActivityAt: now,
900
+ schemaVersion: 1
901
+ };
902
+ }
903
+ async enroll(actor, courseId, options = {}) {
904
+ const current = user$1(actor);
905
+ let course = await this.course(courseId);
906
+ invariant(course.status === "published", "NOT_FOUND", "Course not found.", 404);
907
+ invariant(course.commerce.accessModel === "free", "PAID_COURSES_DISABLED", "Payment processing is not enabled for this course.", 403);
908
+ course = (await this.editions.ensure(courseId)).value;
909
+ invariant(course.status === "published", "NOT_FOUND", "Course not found.", 404);
910
+ const cohort = options.cohortId ? await this.stores.cohorts.get(options.cohortId) : null;
911
+ if (options.cohortId) invariant(cohort && cohort.courseIds.includes(courseId), "NOT_FOUND", "This course is not part of that cohort.", 404);
912
+ if (cohort) invariant((await this.stores.cohort_enrollments.get(enrollmentKey(cohort.id, current.id)))?.status === "active", "COHORT_APPROVAL_REQUIRED", "Request cohort membership and wait for administrator approval.", 403);
913
+ const id = enrollmentKey(courseId, current.id);
914
+ const existing = await this.stores.enrollments.get(id);
915
+ if (!cohort && existing && [
916
+ "active",
917
+ "completed",
918
+ "pending"
919
+ ].includes(existing.status)) return await this.enrollmentView(existing);
920
+ invariant(course.enrollmentMode !== "invite" || cohort, "INVITATION_REQUIRED", "An administrator must enroll you in this course.", 403);
921
+ invariant(!course.enrollmentClosesAt || this.now().getTime() < Date.parse(course.enrollmentClosesAt), "ENROLLMENT_CLOSED", "Enrollment for this course is closed.", 403);
922
+ await this.assertPrerequisites(course, current.id);
923
+ const enrollment = await change(this.stores.enrollments, id, async (stored) => {
924
+ invariant(!stored || ![
925
+ "suspended",
926
+ "cancelled",
927
+ "rejected"
928
+ ].includes(stored.status), "ENROLLMENT_RESTRICTED", "Contact an administrator to restore your enrollment.", 403);
929
+ const next = structuredClone(stored ?? this.newEnrollment(course, current, !cohort && course.enrollmentMode === "approval" ? "pending" : "active"));
930
+ if (cohort) {
931
+ next.cohortId = cohort.id;
932
+ next.cohortIds = [...new Set([...next.cohortIds, cohort.id])];
933
+ if (!stored || stored.accessStatus !== "granted") next.accessSource = "cohort";
934
+ if (!stored) delete next.grants.free;
935
+ next.status = next.completedAt ? "completed" : "active";
936
+ next.accessStatus = "granted";
937
+ next.approvedAt ??= this.time();
938
+ next.grants[`cohort:${cohort.id}`] = {
939
+ id: `cohort:${cohort.id}`,
940
+ source: "cohort",
941
+ referenceId: cohort.id,
942
+ status: "granted",
943
+ startsAt: cohort.startsAt,
944
+ expiresAt: cohort.endsAt,
945
+ grantedAt: this.time(),
946
+ grantedBy: null
947
+ };
948
+ }
949
+ return {
950
+ value: next,
951
+ result: next
952
+ };
953
+ });
954
+ await this.notify(enrollment, "enrolled", enrollment.status === "pending" ? "Enrollment requested" : "You are enrolled", `${course.title}: ${enrollment.status === "pending" ? "an administrator will review your request." : "your learning journey is ready."}`);
955
+ return await this.enrollmentView(enrollment);
956
+ }
957
+ async adminEnrollment(actor, input) {
958
+ const parsed = parse(enrollmentAdminSchema, input);
959
+ let course = await this.course(parsed.courseId);
960
+ const currentActor = this.instructor(course, actor);
961
+ invariant(currentActor.role >= 50, "FORBIDDEN", "Only administrators can make enrollment decisions.", 403);
962
+ invariant(this.ctx.users, "USERS_UNAVAILABLE", "The user directory is unavailable.", 503);
963
+ if (parsed.status === "active" && !await this.stores.enrollments.get(enrollmentKey(course.id, parsed.userId))) course = (await this.editions.ensure(parsed.courseId)).value;
964
+ const target = await this.ctx.users.get(parsed.userId);
965
+ invariant(target, "NOT_FOUND", "Learner account not found.", 404);
966
+ invariant(!parsed.accessStartsAt || !parsed.accessExpiresAt || Date.parse(parsed.accessExpiresAt) > Date.parse(parsed.accessStartsAt), "INVALID_ACCESS_WINDOW", "Access expiry must follow its start.");
967
+ if (parsed.cohortId) {
968
+ const cohort = await this.stores.cohorts.get(parsed.cohortId);
969
+ invariant(cohort && cohort.courseIds.includes(course.id), "INVALID_COHORT", "This course must belong to the selected cohort.");
970
+ }
971
+ if (parsed.status === "active") {
972
+ invariant(course.status !== "draft", "COURSE_NOT_PUBLISHED", "Publish the course before enrolling learners.", 409);
973
+ invariant(course.commerce.accessModel === "free", "PAID_COURSES_DISABLED", "Paid access is not enabled in this release.", 403);
974
+ await this.assertPrerequisites(course, target.id);
975
+ }
976
+ const cohort = parsed.cohortId ? await this.stores.cohorts.get(parsed.cohortId) : null;
977
+ if (cohort && parsed.status === "active") invariant((await this.stores.cohort_enrollments.get(enrollmentKey(cohort.id, target.id)))?.status === "active", "COHORT_APPROVAL_REQUIRED", "Approve cohort membership before granting cohort access.", 403);
978
+ const settings = await this.settings();
979
+ const enrollment = await change(this.stores.enrollments, enrollmentKey(course.id, target.id), async (stored) => {
980
+ const next = structuredClone(stored ?? this.newEnrollment(course, target, parsed.status));
981
+ const firstApproval = parsed.status === "active" && !next.approvedAt;
982
+ next.courseEditionId ??= course.legacyEditionId ?? course.publishedEditionId;
983
+ next.status = parsed.status === "active" && next.completedAt ? "completed" : parsed.status;
984
+ next.accessStatus = parsed.status === "active" ? "granted" : parsed.status === "suspended" ? "suspended" : "revoked";
985
+ next.approvedBy = parsed.status === "active" ? currentActor.id : next.approvedBy;
986
+ next.decisionAt = this.time();
987
+ next.decisionReason = parsed.reason || null;
988
+ next.accessSource = stored?.approvedAt ? stored.accessSource : cohort ? "cohort" : "manual";
989
+ if (firstApproval) next.approvedAt = this.time();
990
+ if (parsed.cohortId !== void 0) next.cohortId = parsed.cohortId;
991
+ if (!cohort && parsed.accessStartsAt !== void 0) next.accessStartsAt = parsed.accessStartsAt;
992
+ if (!cohort && parsed.accessExpiresAt !== void 0) next.accessExpiresAt = parsed.accessExpiresAt;
993
+ if (parsed.status === "active") {
994
+ if (!stored) delete next.grants.free;
995
+ const grantId = cohort ? `cohort:${cohort.id}` : "manual";
996
+ const previous = next.grants[grantId];
997
+ next.grants[grantId] = {
998
+ id: grantId,
999
+ source: cohort ? "cohort" : "manual",
1000
+ referenceId: cohort?.id ?? null,
1001
+ status: "granted",
1002
+ startsAt: cohort?.startsAt ?? null,
1003
+ expiresAt: cohort ? cohort.endsAt : parsed.accessExpiresAt !== void 0 ? parsed.accessExpiresAt : previous && (!previous.expiresAt || Date.parse(previous.expiresAt) > this.now().getTime()) ? previous.expiresAt : course.accessDays ? new Date(this.now().getTime() + course.accessDays * 864e5).toISOString() : null,
1004
+ grantedAt: previous?.grantedAt ?? this.time(),
1005
+ grantedBy: currentActor.id
1006
+ };
1007
+ if (cohort) next.cohortIds = [...new Set([...next.cohortIds, cohort.id])];
1008
+ }
1009
+ invariant(!next.accessStartsAt || !next.accessExpiresAt || Date.parse(next.accessExpiresAt) > Date.parse(next.accessStartsAt), "INVALID_ACCESS_WINDOW", "Access expiry must follow its start.");
1010
+ next.lastActivityAt = this.time();
1011
+ if (parsed.status === "active") this.finish(await this.editions.learning(course, next), next, settings);
1012
+ return {
1013
+ value: next,
1014
+ result: next
1015
+ };
1016
+ });
1017
+ await this.audit(currentActor, "enrollment.update", enrollment.id, { status: enrollment.status });
1018
+ await this.notify(enrollment, `enrollment-${enrollment.status}-${enrollment.decisionAt}`, "Enrollment updated", `${course.title}: your enrollment is ${enrollment.status}.`);
1019
+ await this.projectCompletion(enrollment);
1020
+ return staffEnrollment(await this.accessEnrollment(enrollment), this.now());
1021
+ }
1022
+ async learnerCourse(actor, courseId) {
1023
+ const current = user$1(actor);
1024
+ const course = await this.course(courseId);
1025
+ const enrollment = await this.stores.enrollments.get(enrollmentKey(courseId, current.id));
1026
+ invariant(course.status === "published" || course.status === "archived" && enrollment, "NOT_FOUND", "Course not found.", 404);
1027
+ if (enrollment?.completedAt) await this.projectCompletion(enrollment);
1028
+ return {
1029
+ course: await this.detail(await this.editions.learning(course, enrollment)),
1030
+ ...await this.learningState(course, enrollment)
1031
+ };
1032
+ }
1033
+ async learnerCourseBySlug(actor, slug) {
1034
+ user$1(actor);
1035
+ const page = await this.stores.courses.query({
1036
+ where: { slug },
1037
+ limit: 1
1038
+ });
1039
+ invariant(page.items[0], "NOT_FOUND", "Course not found.", 404);
1040
+ return this.learnerCourse(actor, page.items[0].id);
1041
+ }
1042
+ async dashboard(actor, options = {}) {
1043
+ const current = user$1(actor);
1044
+ const page = await queryPage(this.stores.enrollments, {
1045
+ where: { userId: current.id },
1046
+ orderBy: { enrolledAt: "desc" },
1047
+ limit: options.limit ?? 24,
1048
+ cursor: options.cursor
1049
+ });
1050
+ const courses = await this.stores.courses.getMany(page.items.map((entry) => entry.courseId));
1051
+ const items = (await Promise.all(page.items.map(async (enrollment) => {
1052
+ if (enrollment.completedAt) await this.projectCompletion(enrollment);
1053
+ const course = courses.get(enrollment.courseId);
1054
+ return course ? {
1055
+ course: courseCard(await this.editions.learning(course, enrollment)),
1056
+ ...await this.learningState(course, enrollment)
1057
+ } : null;
1058
+ }))).filter((row) => row !== null);
1059
+ return {
1060
+ ...page,
1061
+ items
1062
+ };
1063
+ }
1064
+ async learnerLesson(actor, courseId, lessonId) {
1065
+ const current = user$1(actor);
1066
+ const course = await this.course(courseId);
1067
+ const enrollment = await this.stores.enrollments.get(enrollmentKey(courseId, current.id));
1068
+ const lesson = await this.lessonAccess(course, lessonId, enrollment);
1069
+ return {
1070
+ course: courseDetail(await this.editions.learning(course, enrollment)),
1071
+ lesson: publicLesson(await this.resolveContent(lesson)),
1072
+ ...await this.learningState(course, enrollment)
1073
+ };
1074
+ }
1075
+ async startLesson(actor, courseId, lessonId) {
1076
+ const current = user$1(actor);
1077
+ const course = await this.course(courseId);
1078
+ const enrollment = await change(this.stores.enrollments, enrollmentKey(courseId, current.id), async (stored) => {
1079
+ await this.lessonAccess(course, lessonId, stored);
1080
+ const next = structuredClone(stored);
1081
+ const progress = next.progress[lessonId] ?? emptyProgress();
1082
+ progress.startedAt ??= this.time();
1083
+ next.progress[lessonId] = progress;
1084
+ next.lastActivityAt = this.time();
1085
+ return {
1086
+ value: next,
1087
+ result: next
1088
+ };
1089
+ });
1090
+ return await this.enrollmentView(enrollment);
1091
+ }
1092
+ async completeLesson(actor, courseId, lessonId) {
1093
+ const current = user$1(actor);
1094
+ const course = await this.course(courseId);
1095
+ const settings = await this.settings();
1096
+ const enrollment = await change(this.stores.enrollments, enrollmentKey(courseId, current.id), async (stored) => {
1097
+ const lesson = await this.lessonAccess(course, lessonId, stored);
1098
+ invariant(!["quiz", "assignment"].includes(lesson.kind), "ASSESSMENT_REQUIRED", "Pass the quiz or assignment to complete this lesson.", 403);
1099
+ const next = structuredClone(stored);
1100
+ const progress = next.progress[lessonId] ?? emptyProgress();
1101
+ invariant(progress.startedAt, "LESSON_NOT_STARTED", "Start the lesson before marking it complete.");
1102
+ if (lesson.kind === "live") invariant(lesson.liveStartsAt && this.now().getTime() >= Date.parse(lesson.liveStartsAt), "LIVE_NOT_STARTED", "The live session has not started yet.", 403);
1103
+ invariant(this.now().getTime() - Date.parse(progress.startedAt) >= lesson.minStudySeconds * 1e3, "STUDY_TIME_REQUIRED", "Spend the minimum study time on this lesson before completing it.", 403);
1104
+ progress.completedAt ??= this.time();
1105
+ next.progress[lessonId] = progress;
1106
+ this.finish(await this.editions.learning(course, next), next, settings);
1107
+ return {
1108
+ value: next,
1109
+ result: next
1110
+ };
1111
+ });
1112
+ await this.projectCompletion(enrollment);
1113
+ return { ...await this.learningState(course, enrollment) };
1114
+ }
1115
+ finish(course, enrollment, settings) {
1116
+ enrollment.lastActivityAt = this.time();
1117
+ const required = lessons(course).filter((lesson) => lesson.required);
1118
+ if (enrollment.completedAt || !required.length || !required.every((lesson) => enrollment.progress[lesson.id]?.completedAt)) return;
1119
+ enrollment.status = "completed";
1120
+ enrollment.completedAt = this.time();
1121
+ enrollment.completionLessonIds = required.map((lesson) => lesson.id);
1122
+ if (course.certificateEnabled) enrollment.certificate = {
1123
+ id: crypto.randomUUID(),
1124
+ courseId: course.id,
1125
+ courseTitle: course.title,
1126
+ userId: enrollment.userId,
1127
+ learnerName: enrollment.learnerName,
1128
+ issuerName: settings.issuerName,
1129
+ issuedAt: this.time(),
1130
+ shared: false,
1131
+ revokedAt: null,
1132
+ revocationReason: null
1133
+ };
1134
+ }
1135
+ async projectCompletion(enrollment) {
1136
+ if (enrollment.certificate) await putOnce(this.stores.certificates, enrollment.certificate.id, {
1137
+ enrollmentId: enrollment.id,
1138
+ courseId: enrollment.courseId,
1139
+ userId: enrollment.userId
1140
+ });
1141
+ if (enrollment.completedAt) await this.notify(enrollment, "completed", "Course complete", "You have completed your course. Your achievement is saved in My Learning.");
1142
+ }
1143
+ async startQuiz(actor, courseId, lessonId) {
1144
+ const current = user$1(actor);
1145
+ const course = await this.course(courseId);
1146
+ const result = await change(this.stores.enrollments, enrollmentKey(courseId, current.id), async (stored) => {
1147
+ const lesson = await this.lessonAccess(course, lessonId, stored);
1148
+ invariant(lesson.kind === "quiz" && lesson.quiz, "NOT_A_QUIZ", "This lesson has no quiz.");
1149
+ const next = structuredClone(stored);
1150
+ const progress = next.progress[lessonId] ?? emptyProgress();
1151
+ progress.startedAt ??= this.time();
1152
+ const existing = progress.quizAttempts.find((attempt) => attempt.status === "in_progress");
1153
+ if (existing && (!existing.expiresAt || this.now().getTime() < Date.parse(existing.expiresAt))) return {
1154
+ value: next,
1155
+ result: {
1156
+ attempt: existing,
1157
+ blocked: false
1158
+ }
1159
+ };
1160
+ if (existing) {
1161
+ existing.status = "expired";
1162
+ existing.quiz = null;
1163
+ existing.submittedAt = this.time();
1164
+ }
1165
+ next.progress[lessonId] = progress;
1166
+ if (progress.quizAttempts.length >= lesson.quiz.maxAttempts || progress.completedAt) return {
1167
+ value: next,
1168
+ result: {
1169
+ attempt: null,
1170
+ blocked: true
1171
+ }
1172
+ };
1173
+ const quiz = structuredClone(lesson.quiz);
1174
+ const questions = (quiz.randomizeQuestions ? shuffle(quiz.questions) : quiz.questions).map(({ correctAnswers: _correct, explanation: _explanation, ...question }) => ({
1175
+ ...question,
1176
+ options: quiz.randomizeOptions ? shuffle(question.options) : question.options
1177
+ }));
1178
+ const attempt = {
1179
+ id: crypto.randomUUID(),
1180
+ startedAt: this.time(),
1181
+ expiresAt: quiz.timeLimitMinutes ? new Date(this.now().getTime() + quiz.timeLimitMinutes * 6e4).toISOString() : null,
1182
+ status: "in_progress",
1183
+ quiz,
1184
+ questions,
1185
+ submittedAt: null,
1186
+ score: null,
1187
+ maxScore: null,
1188
+ percent: null,
1189
+ passed: false,
1190
+ feedback: [],
1191
+ answers: {},
1192
+ draftRevision: 0
1193
+ };
1194
+ progress.quizAttempts.push(attempt);
1195
+ next.lastActivityAt = this.time();
1196
+ return {
1197
+ value: next,
1198
+ result: {
1199
+ attempt,
1200
+ blocked: false
1201
+ }
1202
+ };
1203
+ });
1204
+ invariant(result.attempt && !result.blocked, "ATTEMPT_LIMIT", "No further quiz attempts are available. Contact an instructor.", 403);
1205
+ return publicAttempt(result.attempt);
1206
+ }
1207
+ async submitQuiz(actor, input) {
1208
+ const current = user$1(actor);
1209
+ const parsed = parse(quizSubmitSchema, input);
1210
+ const course = await this.course(parsed.courseId);
1211
+ const settings = await this.settings();
1212
+ const result = await change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
1213
+ await this.lessonAccess(course, parsed.lessonId, stored);
1214
+ const next = structuredClone(stored);
1215
+ const progress = next.progress[parsed.lessonId];
1216
+ const attempt = progress?.quizAttempts.find((item) => item.id === parsed.attemptId);
1217
+ invariant(attempt && progress, "NOT_FOUND", "Quiz attempt not found.", 404);
1218
+ if (attempt.status !== "in_progress") return {
1219
+ value: next,
1220
+ result: {
1221
+ enrollment: next,
1222
+ attempt,
1223
+ expired: attempt.status === "expired"
1224
+ }
1225
+ };
1226
+ if (attempt.expiresAt && this.now().getTime() >= Date.parse(attempt.expiresAt)) {
1227
+ attempt.status = "expired";
1228
+ attempt.quiz = null;
1229
+ attempt.submittedAt = this.time();
1230
+ return {
1231
+ value: next,
1232
+ result: {
1233
+ enrollment: next,
1234
+ attempt,
1235
+ expired: true
1236
+ }
1237
+ };
1238
+ }
1239
+ invariant(attempt.quiz, "INVALID_ATTEMPT", "This attempt cannot be graded.", 409);
1240
+ invariant(parsed.expectedDraftRevision === void 0 || parsed.expectedDraftRevision === (attempt.draftRevision ?? 0), "DRAFT_CONFLICT", "Saved answers changed in another request. Review them before submitting.", 409);
1241
+ const graded = gradeAnswers(attempt.quiz.questions, parsed.answers);
1242
+ attempt.passed = graded.score * 100 >= graded.maxScore * attempt.quiz.passingPercent;
1243
+ attempt.feedback = attempt.quiz.showFeedback === "after_submit" || attempt.quiz.showFeedback === "after_pass" && attempt.passed ? graded.feedback : [];
1244
+ attempt.score = graded.score;
1245
+ attempt.maxScore = graded.maxScore;
1246
+ attempt.percent = graded.percent;
1247
+ attempt.answers = parsed.answers;
1248
+ attempt.status = "submitted";
1249
+ attempt.submittedAt = this.time();
1250
+ attempt.quiz = null;
1251
+ if (attempt.passed) progress.completedAt ??= this.time();
1252
+ this.finish(await this.editions.learning(course, next), next, settings);
1253
+ return {
1254
+ value: next,
1255
+ result: {
1256
+ enrollment: next,
1257
+ attempt,
1258
+ expired: false
1259
+ }
1260
+ };
1261
+ });
1262
+ invariant(!result.expired, "QUIZ_EXPIRED", "The quiz time limit expired. Start a new attempt if one is available.", 409);
1263
+ await this.projectCompletion(result.enrollment);
1264
+ return {
1265
+ attempt: publicAttempt(result.attempt),
1266
+ ...await this.learningState(course, result.enrollment)
1267
+ };
1268
+ }
1269
+ async saveQuizDraft(actor, input) {
1270
+ const current = user$1(actor);
1271
+ const parsed = parse(quizDraftSchema, input);
1272
+ const course = await this.course(parsed.courseId);
1273
+ return {
1274
+ saved: true,
1275
+ draftRevision: await change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
1276
+ await this.lessonAccess(course, parsed.lessonId, stored);
1277
+ const next = structuredClone(stored);
1278
+ const attempt = next.progress[parsed.lessonId]?.quizAttempts.find((item) => item.id === parsed.attemptId);
1279
+ invariant(attempt?.status === "in_progress" && attempt.quiz, "ATTEMPT_CLOSED", "This quiz attempt is closed.", 409);
1280
+ invariant(!attempt.expiresAt || this.now().getTime() < Date.parse(attempt.expiresAt), "QUIZ_EXPIRED", "This quiz attempt has expired.", 409);
1281
+ gradeAnswers(attempt.quiz.questions, parsed.answers);
1282
+ const revision = attempt.draftRevision ?? 0;
1283
+ const canonical = (answers) => JSON.stringify(Object.entries(answers).sort(([a], [b]) => a.localeCompare(b)));
1284
+ if (revision === parsed.expectedDraftRevision + 1 && canonical(attempt.answers) === canonical(parsed.answers)) return {
1285
+ value: next,
1286
+ result: revision
1287
+ };
1288
+ invariant(revision === parsed.expectedDraftRevision, "DRAFT_CONFLICT", "Saved answers changed in another request. Review them before saving.", 409);
1289
+ attempt.answers = parsed.answers;
1290
+ attempt.draftRevision = revision + 1;
1291
+ return {
1292
+ value: next,
1293
+ result: attempt.draftRevision
1294
+ };
1295
+ })
1296
+ };
1297
+ }
1298
+ async submitAssignment(actor, input) {
1299
+ const current = user$1(actor);
1300
+ const parsed = parse(assignmentSubmitSchema, input);
1301
+ const course = await this.course(parsed.courseId);
1302
+ const enrollment = await change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
1303
+ const lesson = await this.lessonAccess(course, parsed.lessonId, stored);
1304
+ invariant(lesson.kind === "assignment" && lesson.assignment, "NOT_AN_ASSIGNMENT", "This lesson has no assignment.");
1305
+ invariant(!lesson.assignment.dueAt || this.now().getTime() <= Date.parse(lesson.assignment.dueAt), "ASSIGNMENT_CLOSED", "The assignment deadline has passed.", 403);
1306
+ const next = structuredClone(stored);
1307
+ const progress = next.progress[parsed.lessonId] ?? emptyProgress();
1308
+ const last = progress.assignments.at(-1);
1309
+ invariant(!progress.completedAt, "ASSIGNMENT_PASSED", "This assignment has already been passed.", 409);
1310
+ invariant(!last || last.status === "graded" && lesson.assignment.allowResubmission, "SUBMISSION_PENDING", "Wait for grading before submitting again.", 409);
1311
+ invariant(progress.assignments.length < 10, "SUBMISSION_LIMIT", "The assignment submission limit has been reached.", 403);
1312
+ progress.startedAt ??= this.time();
1313
+ progress.assignments.push({
1314
+ id: crypto.randomUUID(),
1315
+ body: parsed.body,
1316
+ attachmentUrl: parsed.attachmentUrl,
1317
+ submittedAt: this.time(),
1318
+ status: "submitted",
1319
+ maxPoints: lesson.assignment.maxPoints,
1320
+ passingPoints: lesson.assignment.passingPoints,
1321
+ points: null,
1322
+ feedback: "",
1323
+ gradedAt: null,
1324
+ gradedBy: null
1325
+ });
1326
+ next.progress[parsed.lessonId] = progress;
1327
+ next.lastActivityAt = this.time();
1328
+ return {
1329
+ value: next,
1330
+ result: next
1331
+ };
1332
+ });
1333
+ return await this.enrollmentView(enrollment);
1334
+ }
1335
+ async gradeAssignment(actor, input) {
1336
+ const parsed = parse(gradeSchema, input);
1337
+ const course = await this.course(parsed.courseId);
1338
+ const current = this.instructor(course, actor);
1339
+ const settings = await this.settings();
1340
+ const enrollment = await change(this.stores.enrollments, enrollmentKey(course.id, parsed.userId), async (stored) => {
1341
+ invariant(stored, "NOT_FOUND", "Enrollment not found.", 404);
1342
+ const next = structuredClone(stored);
1343
+ const progress = next.progress[parsed.lessonId];
1344
+ const submission = progress?.assignments.find((item) => item.id === parsed.submissionId);
1345
+ invariant(submission && progress, "NOT_FOUND", "Assignment submission not found.", 404);
1346
+ invariant(submission.status === "submitted", "ALREADY_GRADED", "This submission has already been graded.", 409);
1347
+ invariant(parsed.points <= submission.maxPoints, "INVALID_GRADE", "The grade cannot exceed the assignment's maximum points.");
1348
+ submission.points = parsed.points;
1349
+ submission.feedback = parsed.feedback;
1350
+ submission.status = "graded";
1351
+ submission.gradedAt = this.time();
1352
+ submission.gradedBy = current.id;
1353
+ if (parsed.points >= submission.passingPoints) progress.completedAt ??= this.time();
1354
+ const access = effectiveAccess(await this.accessEnrollment(next), this.now());
1355
+ if (["active", "completed"].includes(next.status) && ![
1356
+ "suspended",
1357
+ "revoked",
1358
+ "pending"
1359
+ ].includes(access)) this.finish(await this.editions.learning(course, next), next, settings);
1360
+ return {
1361
+ value: next,
1362
+ result: next
1363
+ };
1364
+ });
1365
+ await this.projectCompletion(enrollment);
1366
+ await this.notify(enrollment, `graded-${parsed.submissionId}`, "Assignment graded", "Your assignment feedback is ready in My Learning.");
1367
+ await this.audit(current, "assignment.grade", enrollment.id, {
1368
+ lessonId: parsed.lessonId,
1369
+ points: parsed.points
1370
+ });
1371
+ return staffEnrollment(await this.accessEnrollment(enrollment), this.now());
1372
+ }
1373
+ async saveNote(actor, input) {
1374
+ const current = user$1(actor);
1375
+ const parsed = parse(noteSchema, input);
1376
+ const course = await this.course(parsed.courseId);
1377
+ return { note: (await change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
1378
+ await this.lessonAccess(course, parsed.lessonId, stored);
1379
+ const next = structuredClone(stored);
1380
+ const progress = next.progress[parsed.lessonId] ?? emptyProgress();
1381
+ progress.note = parsed.note;
1382
+ next.progress[parsed.lessonId] = progress;
1383
+ return {
1384
+ value: next,
1385
+ result: next
1386
+ };
1387
+ })).progress[parsed.lessonId].note };
1388
+ }
1389
+ async certificate(actor, courseId) {
1390
+ const current = user$1(actor);
1391
+ const enrollment = await this.stores.enrollments.get(enrollmentKey(courseId, current.id));
1392
+ invariant(enrollment?.certificate, "NOT_FOUND", "Certificate not found.", 404);
1393
+ await this.projectCompletion(enrollment);
1394
+ return enrollment.certificate;
1395
+ }
1396
+ async ownedCertificate(actor, id) {
1397
+ const current = user$1(actor);
1398
+ const index = await this.stores.certificates.get(id);
1399
+ invariant(index?.userId === current.id, "NOT_FOUND", "Certificate not found.", 404);
1400
+ const enrollment = await this.stores.enrollments.get(index.enrollmentId);
1401
+ invariant(enrollment?.certificate?.id === id, "NOT_FOUND", "Certificate not found.", 404);
1402
+ return enrollment.certificate;
1403
+ }
1404
+ async verifyCertificate(id) {
1405
+ const index = await this.stores.certificates.get(id);
1406
+ invariant(index, "NOT_FOUND", "Certificate not found.", 404);
1407
+ const certificate = (await this.stores.enrollments.get(index.enrollmentId))?.certificate;
1408
+ invariant(certificate?.id === id, "NOT_FOUND", "Certificate not found.", 404);
1409
+ return {
1410
+ id,
1411
+ courseTitle: certificate.courseTitle,
1412
+ issuerName: certificate.issuerName,
1413
+ issuedAt: certificate.issuedAt,
1414
+ valid: !certificate.revokedAt,
1415
+ shared: certificate.shared,
1416
+ learnerName: certificate.shared ? certificate.learnerName : null
1417
+ };
1418
+ }
1419
+ async shareCertificate(actor, courseId, shared) {
1420
+ const current = user$1(actor);
1421
+ return await change(this.stores.enrollments, enrollmentKey(courseId, current.id), async (stored) => {
1422
+ invariant(stored?.certificate, "NOT_FOUND", "Certificate not found.", 404);
1423
+ const next = structuredClone(stored);
1424
+ next.certificate.shared = shared;
1425
+ return {
1426
+ value: next,
1427
+ result: next.certificate
1428
+ };
1429
+ });
1430
+ }
1431
+ async revokeCertificate(actor, courseId, userId, reason) {
1432
+ const course = await this.course(courseId);
1433
+ const current = this.instructor(course, actor);
1434
+ const certificate = await change(this.stores.enrollments, enrollmentKey(courseId, userId), async (stored) => {
1435
+ invariant(stored?.certificate, "NOT_FOUND", "Certificate not found.", 404);
1436
+ const next = structuredClone(stored);
1437
+ next.certificate.revokedAt = this.time();
1438
+ next.certificate.revocationReason = reason;
1439
+ return {
1440
+ value: next,
1441
+ result: next.certificate
1442
+ };
1443
+ });
1444
+ await this.audit(current, "certificate.revoke", certificate.id);
1445
+ return certificate;
1446
+ }
1447
+ async cancelEnrollment(actor, courseId) {
1448
+ const current = user$1(actor);
1449
+ const enrollment = await change(this.stores.enrollments, enrollmentKey(courseId, current.id), async (stored) => {
1450
+ invariant(stored, "NOT_FOUND", "Enrollment not found.", 404);
1451
+ const next = {
1452
+ ...stored,
1453
+ status: "cancelled",
1454
+ accessStatus: "revoked"
1455
+ };
1456
+ return {
1457
+ value: next,
1458
+ result: next
1459
+ };
1460
+ });
1461
+ return await this.enrollmentView(enrollment);
1462
+ }
1463
+ async discussions(actor, courseId, lessonId, cursor) {
1464
+ const current = user$1(actor);
1465
+ const course = await this.course(courseId);
1466
+ const enrollment = await this.stores.enrollments.get(enrollmentKey(courseId, current.id));
1467
+ await this.lessonAccess(course, lessonId, enrollment);
1468
+ const page = await queryPage(this.stores.discussions, {
1469
+ where: {
1470
+ courseId,
1471
+ lessonId,
1472
+ status: "published"
1473
+ },
1474
+ orderBy: { createdAt: "asc" },
1475
+ cursor,
1476
+ limit: 30
1477
+ });
1478
+ const own = await queryPage(this.stores.discussions, {
1479
+ where: {
1480
+ courseId,
1481
+ lessonId,
1482
+ authorId: current.id,
1483
+ status: "pending"
1484
+ },
1485
+ limit: 30
1486
+ });
1487
+ return {
1488
+ ...page,
1489
+ pending: own.items
1490
+ };
1491
+ }
1492
+ async postDiscussion(actor, input) {
1493
+ const current = user$1(actor);
1494
+ const parsed = parse(discussionSchema, input);
1495
+ const course = await this.course(parsed.courseId);
1496
+ const enrollment = await this.stores.enrollments.get(enrollmentKey(course.id, current.id));
1497
+ await this.lessonAccess(course, parsed.lessonId, enrollment);
1498
+ if (parsed.parentId) {
1499
+ const parent = await this.stores.discussions.get(parsed.parentId);
1500
+ invariant(parent && parent.courseId === course.id && parent.lessonId === parsed.lessonId && parent.status === "published" && !parent.parentId, "INVALID_PARENT", "Reply to a published discussion in this lesson.");
1501
+ }
1502
+ const settings = await this.settings();
1503
+ const id = crypto.randomUUID();
1504
+ const discussion = {
1505
+ ...parsed,
1506
+ id,
1507
+ authorId: current.id,
1508
+ authorName: current.name || "Learner",
1509
+ status: settings.moderateDiscussions ? "pending" : "published",
1510
+ createdAt: this.time(),
1511
+ updatedAt: this.time()
1512
+ };
1513
+ await this.stores.discussions.put(id, discussion);
1514
+ return discussion;
1515
+ }
1516
+ async publicReviews(courseId, cursor) {
1517
+ invariant((await this.course(courseId)).status === "published", "NOT_FOUND", "Course not found.", 404);
1518
+ return queryPage(this.stores.reviews, {
1519
+ where: {
1520
+ courseId,
1521
+ status: "published"
1522
+ },
1523
+ orderBy: { createdAt: "desc" },
1524
+ limit: 24,
1525
+ cursor
1526
+ });
1527
+ }
1528
+ async postReview(actor, input) {
1529
+ const current = user$1(actor);
1530
+ const parsed = parse(reviewSchema, input);
1531
+ const course = await this.course(parsed.courseId);
1532
+ const enrollment = await this.accessEnrollment(await this.stores.enrollments.get(enrollmentKey(course.id, current.id)));
1533
+ assertEnrollmentAccess(enrollment, this.now());
1534
+ invariant(Object.values(enrollment.progress).some((progress) => progress.completedAt), "REVIEW_TOO_EARLY", "Complete at least one lesson before reviewing this course.", 403);
1535
+ const settings = await this.settings();
1536
+ const id = enrollmentKey(course.id, current.id);
1537
+ return await change(this.stores.reviews, id, async (stored) => {
1538
+ const next = {
1539
+ ...parsed,
1540
+ id,
1541
+ userId: current.id,
1542
+ authorName: current.name || "Learner",
1543
+ status: settings.moderateReviews ? "pending" : "published",
1544
+ createdAt: stored?.createdAt ?? this.time(),
1545
+ updatedAt: this.time()
1546
+ };
1547
+ return {
1548
+ value: next,
1549
+ result: next
1550
+ };
1551
+ });
1552
+ }
1553
+ async moderate(actor, collection, input) {
1554
+ const parsed = parse(moderateSchema, input);
1555
+ const store = this.stores[collection];
1556
+ const stored = await store.getVersioned(parsed.id);
1557
+ invariant(stored, "NOT_FOUND", "Community item not found.", 404);
1558
+ const course = await this.course(stored.value.courseId);
1559
+ const current = this.instructor(course, actor);
1560
+ invariant(stored.revision === parsed.expectedRevision, "CONFLICT", "This item changed. Reload before moderating.", 409);
1561
+ const next = {
1562
+ ...stored.value,
1563
+ status: parsed.status,
1564
+ updatedAt: this.time()
1565
+ };
1566
+ invariant((await store.compareAndSet(parsed.id, stored.revision, next)).applied, "CONFLICT", "This item changed while moderating.", 409);
1567
+ await this.audit(current, `${collection}.moderate`, parsed.id, { status: parsed.status });
1568
+ return next;
1569
+ }
1570
+ async communityQueue(actor, collection, options = {}) {
1571
+ const current = user$1(actor);
1572
+ if (options.courseId) this.instructor(await this.course(options.courseId), current);
1573
+ else invariant(current.role >= 50, "FORBIDDEN", "Select a course you teach.", 403);
1574
+ const store = this.stores[collection];
1575
+ const page = await store.query({
1576
+ where: {
1577
+ ...options.courseId ? { courseId: options.courseId } : {},
1578
+ ...options.status ? { status: options.status } : {}
1579
+ },
1580
+ orderBy: { createdAt: "desc" },
1581
+ limit: 30,
1582
+ cursor: options.cursor
1583
+ });
1584
+ const items = (await Promise.all(page.items.map((item) => store.getVersioned(item.id)))).flatMap((item) => item ? [{
1585
+ ...item.value,
1586
+ revision: item.revision
1587
+ }] : []);
1588
+ return {
1589
+ ...page,
1590
+ items
1591
+ };
1592
+ }
1593
+ async enrollments(actor, options = {}) {
1594
+ const current = user$1(actor);
1595
+ if (options.courseId) this.instructor(await this.course(options.courseId), current);
1596
+ else invariant(current.role >= 50, "FORBIDDEN", "Select a course you teach.", 403);
1597
+ const page = await queryPage(this.stores.enrollments, {
1598
+ where: {
1599
+ ...options.courseId ? { courseId: options.courseId } : {},
1600
+ ...options.status ? { status: options.status } : {}
1601
+ },
1602
+ orderBy: { enrolledAt: "desc" },
1603
+ limit: options.limit ?? 30,
1604
+ cursor: options.cursor
1605
+ });
1606
+ const courses = await this.stores.courses.getMany(page.items.map((item) => item.courseId));
1607
+ return {
1608
+ ...page,
1609
+ items: await Promise.all(page.items.map(async (enrollment) => {
1610
+ const resolved = await this.accessEnrollment(enrollment);
1611
+ const root = courses.get(enrollment.courseId);
1612
+ const course = root ? await this.editions.learning(root, enrollment) : null;
1613
+ const now = this.now();
1614
+ return {
1615
+ ...staffEnrollment(resolved, now),
1616
+ learnerEmail: enrollment.learnerEmail,
1617
+ courseTitle: course?.title ?? "Archived course",
1618
+ lessonTitles: Object.fromEntries((course ? lessons(course) : []).map((lesson) => [lesson.id, lesson.title])),
1619
+ progressSummary: course ? progressSummary(course, resolved, now) : null
1620
+ };
1621
+ }))
1622
+ };
1623
+ }
1624
+ async report(actor, courseId) {
1625
+ const current = user$1(actor);
1626
+ if (courseId) this.instructor(await this.course(courseId), current);
1627
+ else invariant(current.role >= 50, "FORBIDDEN", "Select a course you teach.", 403);
1628
+ const filter = courseId ? { courseId } : {};
1629
+ const statuses = [
1630
+ "pending",
1631
+ "active",
1632
+ "completed",
1633
+ "rejected",
1634
+ "suspended",
1635
+ "cancelled"
1636
+ ];
1637
+ const counts = await Promise.all(statuses.map((status) => this.stores.enrollments.count({
1638
+ ...filter,
1639
+ status
1640
+ })));
1641
+ const statusCounts = Object.fromEntries(statuses.map((status, index) => [status, counts[index]]));
1642
+ const total = counts.reduce((sum, count) => sum + count, 0);
1643
+ const completedEver = await this.stores.enrollments.count({
1644
+ ...filter,
1645
+ completedAt: { gte: "0000-01-01T00:00:00.000Z" }
1646
+ });
1647
+ const sourceCounts = Object.fromEntries(await Promise.all([
1648
+ "free",
1649
+ "manual",
1650
+ "cohort",
1651
+ "purchase"
1652
+ ].map(async (accessSource) => [accessSource, await this.stores.enrollments.count({
1653
+ ...filter,
1654
+ accessSource
1655
+ })])));
1656
+ const accessDecisions = Object.fromEntries(await Promise.all([
1657
+ "pending",
1658
+ "granted",
1659
+ "suspended",
1660
+ "revoked"
1661
+ ].map(async (accessStatus) => [accessStatus, await this.stores.enrollments.count({
1662
+ ...filter,
1663
+ accessStatus
1664
+ })])));
1665
+ return {
1666
+ total,
1667
+ completedEver,
1668
+ ...statusCounts,
1669
+ accessSources: sourceCounts,
1670
+ accessDecisions,
1671
+ completionRate: total ? Math.round(completedEver / total * 100) : 0,
1672
+ courseCount: courseId ? 1 : await this.stores.courses.count(),
1673
+ generatedAt: this.time()
1674
+ };
1675
+ }
1676
+ async enrollmentCsv(actor, options = {}) {
1677
+ const lines = [enrollmentCsvHeader];
1678
+ let cursor;
1679
+ let rows = 0;
1680
+ let bytes = new TextEncoder().encode(enrollmentCsvHeader).byteLength + 3;
1681
+ do {
1682
+ const page = await this.enrollments(actor, {
1683
+ ...options,
1684
+ cursor,
1685
+ limit: 100
1686
+ });
1687
+ const next = enrollmentCsvLines(page.items);
1688
+ rows += next.length;
1689
+ bytes += new TextEncoder().encode(next.join("\r\n")).byteLength + next.length * 2;
1690
+ invariant(rows <= 1e4 && bytes <= 6e6, "EXPORT_TOO_LARGE", "Filter by course or enrollment status to export fewer records.", 413);
1691
+ lines.push(...next);
1692
+ cursor = page.hasMore ? page.cursor : void 0;
1693
+ } while (cursor);
1694
+ return "" + lines.join("\r\n");
1695
+ }
1696
+ async cohortList(actor, cursor) {
1697
+ invariant(user$1(actor).role >= 50, "FORBIDDEN", "Only administrators can manage cohorts.", 403);
1698
+ const page = await this.stores.cohorts.query({
1699
+ orderBy: { createdAt: "desc" },
1700
+ limit: 24,
1701
+ cursor
1702
+ });
1703
+ const items = (await Promise.all(page.items.map((item) => this.stores.cohorts.getVersioned(item.id)))).flatMap((item) => item ? [{
1704
+ ...item.value,
1705
+ revision: item.revision
1706
+ }] : []);
1707
+ return {
1708
+ ...page,
1709
+ items
1710
+ };
1711
+ }
1712
+ async publicCohort(id) {
1713
+ const cohort = await this.stores.cohorts.get(id);
1714
+ invariant(cohort, "NOT_FOUND", "Cohort not found.", 404);
1715
+ const courses = await this.stores.courses.getMany(cohort.courseIds);
1716
+ return {
1717
+ id: cohort.id,
1718
+ title: cohort.title,
1719
+ startsAt: cohort.startsAt,
1720
+ endsAt: cohort.endsAt,
1721
+ courses: [...courses.values()].filter((course) => course.status === "published" && course.commerce.accessModel === "free").map(courseCard)
1722
+ };
1723
+ }
1724
+ async requestCohort(actor, cohortId) {
1725
+ const current = user$1(actor);
1726
+ const cohort = await this.stores.cohorts.get(cohortId);
1727
+ invariant(cohort, "NOT_FOUND", "Cohort not found.", 404);
1728
+ invariant(!cohort.endsAt || this.now().getTime() < Date.parse(cohort.endsAt), "COHORT_CLOSED", "This cohort has ended.", 403);
1729
+ const id = enrollmentKey(cohortId, current.id);
1730
+ return change(this.stores.cohort_enrollments, id, async (stored) => {
1731
+ invariant(!stored || ![
1732
+ "rejected",
1733
+ "suspended",
1734
+ "cancelled"
1735
+ ].includes(stored.status), "COHORT_RESTRICTED", "Contact an administrator to restore your cohort membership.", 403);
1736
+ const next = stored ?? {
1737
+ id,
1738
+ cohortId,
1739
+ userId: current.id,
1740
+ learnerName: current.name || "Learner",
1741
+ learnerEmail: current.email,
1742
+ status: "pending",
1743
+ requestedAt: this.time(),
1744
+ decidedAt: null,
1745
+ decidedBy: null,
1746
+ reason: null
1747
+ };
1748
+ return {
1749
+ value: next,
1750
+ result: next
1751
+ };
1752
+ });
1753
+ }
1754
+ async cohortMemberships(actor, options = {}) {
1755
+ invariant(user$1(actor).role >= 50, "FORBIDDEN", "Only administrators can review cohort membership.", 403);
1756
+ return queryPage(this.stores.cohort_enrollments, {
1757
+ where: {
1758
+ ...options.cohortId ? { cohortId: options.cohortId } : {},
1759
+ ...options.status ? { status: options.status } : {}
1760
+ },
1761
+ orderBy: { requestedAt: "desc" },
1762
+ limit: 30,
1763
+ cursor: options.cursor
1764
+ });
1765
+ }
1766
+ async decideCohort(actor, cohortId, userId, status, reason = "") {
1767
+ const current = user$1(actor);
1768
+ invariant(current.role >= 50, "FORBIDDEN", "Only administrators can make cohort decisions.", 403);
1769
+ const cohort = await this.stores.cohorts.get(cohortId);
1770
+ invariant(cohort && this.ctx.users, "NOT_FOUND", "Cohort not found.", 404);
1771
+ const target = await this.ctx.users.get(userId);
1772
+ invariant(target, "NOT_FOUND", "Learner account not found.", 404);
1773
+ const membership = await change(this.stores.cohort_enrollments, enrollmentKey(cohortId, userId), async (stored) => {
1774
+ const next = {
1775
+ ...stored ?? {
1776
+ id: enrollmentKey(cohortId, userId),
1777
+ cohortId,
1778
+ userId,
1779
+ learnerName: target.name || "Learner",
1780
+ learnerEmail: target.email,
1781
+ requestedAt: this.time()
1782
+ },
1783
+ status,
1784
+ decidedAt: this.time(),
1785
+ decidedBy: current.id,
1786
+ reason: reason || null
1787
+ };
1788
+ return {
1789
+ value: next,
1790
+ result: next
1791
+ };
1792
+ });
1793
+ const results = [];
1794
+ for (const courseId of cohort.courseIds) try {
1795
+ if (status === "active") {
1796
+ const existing = await this.stores.enrollments.get(enrollmentKey(courseId, userId));
1797
+ invariant(!existing || ![
1798
+ "suspended",
1799
+ "cancelled",
1800
+ "rejected"
1801
+ ].includes(existing.status), "ENROLLMENT_RESTRICTED", "Restore this course enrollment separately before granting cohort access.", 403);
1802
+ await this.adminEnrollment(current, {
1803
+ courseId,
1804
+ userId,
1805
+ cohortId,
1806
+ status: "active"
1807
+ });
1808
+ } else {
1809
+ const id = enrollmentKey(courseId, userId);
1810
+ if (await this.stores.enrollments.exists(id)) await change(this.stores.enrollments, id, async (stored) => {
1811
+ invariant(stored, "NOT_FOUND", "Enrollment not found.", 404);
1812
+ const next = structuredClone(stored);
1813
+ const grant = next.grants[`cohort:${cohortId}`];
1814
+ if (grant) grant.status = status === "suspended" ? "suspended" : "revoked";
1815
+ return {
1816
+ value: next,
1817
+ result: true
1818
+ };
1819
+ });
1820
+ }
1821
+ results.push({
1822
+ courseId,
1823
+ success: true
1824
+ });
1825
+ } catch (error) {
1826
+ results.push({
1827
+ courseId,
1828
+ success: false,
1829
+ error: error instanceof LmsError ? error.message : "Access update failed."
1830
+ });
1831
+ }
1832
+ await this.audit(current, "cohort.decision", membership.id, { status });
1833
+ return {
1834
+ membership,
1835
+ results
1836
+ };
1837
+ }
1838
+ async saveCohort(actor, input) {
1839
+ const current = user$1(actor);
1840
+ invariant(current.role >= 50, "FORBIDDEN", "Only administrators can manage cohorts.", 403);
1841
+ const parsed = parse(cohortSaveSchema, input);
1842
+ for (const id of parsed.data.courseIds) await this.course(id);
1843
+ const id = parsed.id ?? crypto.randomUUID();
1844
+ const existing = await this.stores.cohorts.getVersioned(id);
1845
+ invariant((existing?.revision ?? null) === parsed.expectedRevision, "CONFLICT", "The cohort changed. Reload before saving.", 409);
1846
+ if (existing && await this.stores.cohort_enrollments.count({ cohortId: id })) invariant(existing.value.courseIds.every((courseId) => parsed.data.courseIds.includes(courseId)), "COHORT_IN_USE", "Cohorts with membership records must retain existing courses. Create a new cohort to remove courses.", 409);
1847
+ const cohort = {
1848
+ ...parsed.data,
1849
+ id,
1850
+ createdBy: existing?.value.createdBy ?? current.id,
1851
+ createdAt: existing?.value.createdAt ?? this.time(),
1852
+ updatedAt: this.time()
1853
+ };
1854
+ const saved = await this.stores.cohorts.compareAndSet(id, existing?.revision ?? null, cohort);
1855
+ invariant(saved.applied, "CONFLICT", "The cohort changed while saving.", 409);
1856
+ await this.audit(current, "cohort.save", id);
1857
+ return {
1858
+ cohort,
1859
+ revision: saved.revision
1860
+ };
1861
+ }
1862
+ async applyCohort(actor, id, expectedRevision, offset) {
1863
+ const current = user$1(actor);
1864
+ invariant(current.role >= 50, "FORBIDDEN", "Only administrators can enroll cohorts.", 403);
1865
+ const stored = await this.stores.cohorts.getVersioned(id);
1866
+ invariant(stored, "NOT_FOUND", "Cohort not found.", 404);
1867
+ invariant(stored.revision === expectedRevision, "CONFLICT", "The cohort changed. Reload before continuing.", 409);
1868
+ const cohort = stored.value;
1869
+ const members = cohort.memberIds.slice(offset, offset + Math.max(1, Math.floor(10 / cohort.courseIds.length)));
1870
+ const results = [];
1871
+ for (const userId of members) try {
1872
+ const decided = await this.decideCohort(current, id, userId, "active");
1873
+ results.push(...decided.results.map((result) => ({
1874
+ userId,
1875
+ ...result
1876
+ })));
1877
+ } catch (error) {
1878
+ results.push({
1879
+ userId,
1880
+ courseId: "",
1881
+ success: false,
1882
+ error: error instanceof LmsError ? error.message : "Enrollment failed."
1883
+ });
1884
+ }
1885
+ const nextOffset = offset + members.length;
1886
+ return {
1887
+ results,
1888
+ nextOffset,
1889
+ hasMore: nextOffset < cohort.memberIds.length
1890
+ };
1891
+ }
1892
+ async notify(enrollment, key, title, message) {
1893
+ const settings = await this.settings();
1894
+ const preferences = await this.stores.preferences.get(enrollment.userId);
1895
+ const id = `${enrollment.id}:${key}`;
1896
+ const notification = {
1897
+ id,
1898
+ userId: enrollment.userId,
1899
+ email: enrollment.learnerEmail,
1900
+ title,
1901
+ message,
1902
+ courseId: enrollment.courseId,
1903
+ read: false,
1904
+ createdAt: this.time(),
1905
+ emailStatus: settings.emailNotifications && preferences?.emailNotifications !== false ? "pending" : "disabled",
1906
+ emailAttempts: 0,
1907
+ claimedAt: null
1908
+ };
1909
+ await putOnce(this.stores.notifications, id, notification);
1910
+ }
1911
+ async notifications(actor, cursor) {
1912
+ const current = user$1(actor);
1913
+ const page = await queryPage(this.stores.notifications, {
1914
+ where: { userId: current.id },
1915
+ orderBy: { createdAt: "desc" },
1916
+ limit: 30,
1917
+ cursor
1918
+ });
1919
+ return {
1920
+ ...page,
1921
+ items: page.items.map(({ email: _email, emailStatus: _status, emailAttempts: _attempts, claimedAt: _claimed, ...item }) => item)
1922
+ };
1923
+ }
1924
+ async readNotification(actor, id) {
1925
+ const current = user$1(actor);
1926
+ await change(this.stores.notifications, id, async (stored) => {
1927
+ invariant(stored && stored.userId === current.id, "NOT_FOUND", "Notification not found.", 404);
1928
+ return {
1929
+ value: {
1930
+ ...stored,
1931
+ read: true
1932
+ },
1933
+ result: true
1934
+ };
1935
+ });
1936
+ return { read: true };
1937
+ }
1938
+ async deliverNotifications() {
1939
+ if (!(await this.settings()).emailNotifications || !this.ctx.email) return {
1940
+ sent: 0,
1941
+ failed: 0
1942
+ };
1943
+ const cutoff = (/* @__PURE__ */ new Date(this.now().getTime() - 15 * 6e4)).toISOString();
1944
+ const abandoned = await this.stores.notifications.query({
1945
+ where: {
1946
+ emailStatus: "sending",
1947
+ claimedAt: { lte: cutoff }
1948
+ },
1949
+ limit: 20
1950
+ });
1951
+ let failed = 0;
1952
+ for (const item of abandoned.items) if ((await this.stores.notifications.updateIf(item.id, {
1953
+ where: {
1954
+ emailStatus: "sending",
1955
+ claimedAt: { lte: cutoff }
1956
+ },
1957
+ set: { emailStatus: "failed" }
1958
+ })).applied) failed++;
1959
+ const page = await this.stores.notifications.query({
1960
+ where: { emailStatus: "pending" },
1961
+ limit: 20
1962
+ });
1963
+ let sent = 0;
1964
+ for (const item of page.items) {
1965
+ if (item.data.emailAttempts >= 3) continue;
1966
+ const claimedAt = this.time();
1967
+ const emailAttempts = item.data.emailAttempts + 1;
1968
+ if (!(await this.stores.notifications.updateIf(item.id, {
1969
+ where: {
1970
+ emailStatus: "pending",
1971
+ emailAttempts: item.data.emailAttempts
1972
+ },
1973
+ set: {
1974
+ emailStatus: "sending",
1975
+ claimedAt
1976
+ },
1977
+ delta: { emailAttempts: { inc: 1 } }
1978
+ })).applied) continue;
1979
+ try {
1980
+ if ((await this.stores.preferences.get(item.data.userId))?.emailNotifications === false) {
1981
+ await this.stores.notifications.updateIf(item.id, {
1982
+ where: {
1983
+ emailStatus: "sending",
1984
+ emailAttempts,
1985
+ claimedAt
1986
+ },
1987
+ set: { emailStatus: "disabled" }
1988
+ });
1989
+ continue;
1990
+ }
1991
+ await this.ctx.email.send({
1992
+ to: item.data.email,
1993
+ subject: item.data.title,
1994
+ text: item.data.message
1995
+ });
1996
+ await this.stores.notifications.updateIf(item.id, {
1997
+ where: {
1998
+ emailStatus: "sending",
1999
+ emailAttempts,
2000
+ claimedAt
2001
+ },
2002
+ set: { emailStatus: "sent" }
2003
+ });
2004
+ sent++;
2005
+ } catch {
2006
+ await this.stores.notifications.updateIf(item.id, {
2007
+ where: {
2008
+ emailStatus: "sending",
2009
+ emailAttempts,
2010
+ claimedAt
2011
+ },
2012
+ set: { emailStatus: "failed" }
2013
+ });
2014
+ failed++;
2015
+ }
2016
+ }
2017
+ return {
2018
+ sent,
2019
+ failed
2020
+ };
2021
+ }
2022
+ async assignments(actor, courseId, cursor) {
2023
+ const page = await this.enrollments(actor, {
2024
+ courseId,
2025
+ cursor,
2026
+ limit: 30
2027
+ });
2028
+ return {
2029
+ ...page,
2030
+ items: page.items.flatMap((enrollment) => Object.entries(enrollment.progress).flatMap(([lessonId, progress]) => progress.assignments.map((submission) => ({
2031
+ ...submission,
2032
+ userId: enrollment.userId,
2033
+ learnerName: enrollment.learnerName,
2034
+ courseId,
2035
+ lessonId
2036
+ }))))
2037
+ };
2038
+ }
2039
+ async exportCourse(actor, id) {
2040
+ const { course } = await this.editableCourse(actor, id);
2041
+ return {
2042
+ format: "anantagnanam-lms-course",
2043
+ version: 1,
2044
+ course: courseDataSchema.parse({
2045
+ ...course,
2046
+ status: "draft"
2047
+ })
2048
+ };
2049
+ }
2050
+ async importCourse(actor, input) {
2051
+ const parsed = parse(z.object({
2052
+ format: z.literal("anantagnanam-lms-course"),
2053
+ version: z.literal(1),
2054
+ course: courseSaveSchema.shape.data
2055
+ }), input);
2056
+ return this.saveCourse(actor, {
2057
+ data: {
2058
+ ...parsed.course,
2059
+ status: "draft"
2060
+ },
2061
+ expectedRevision: null
2062
+ });
2063
+ }
2064
+ async deleteCourse(actor, id, expectedRevision) {
2065
+ const stored = await this.stores.courses.getVersioned(id);
2066
+ invariant(stored, "NOT_FOUND", "Course not found.", 404);
2067
+ const current = this.instructor(stored.value, actor);
2068
+ invariant(stored.value.status === "draft" && await this.stores.enrollments.count({ courseId: id }) === 0, "COURSE_IN_USE", "Only drafts without enrollments can be deleted. Archive other courses.", 409);
2069
+ invariant((await this.stores.courses.compareAndDelete(id, expectedRevision)).applied, "CONFLICT", "The course changed. Reload before deleting.", 409);
2070
+ await this.audit(current, "course.delete", id);
2071
+ return { deleted: true };
2072
+ }
2073
+ async retryNotification(actor, id) {
2074
+ invariant(user$1(actor).role >= 50, "FORBIDDEN", "Only administrators can retry email delivery.", 403);
2075
+ invariant((await this.stores.notifications.updateIf(id, {
2076
+ where: {
2077
+ emailStatus: "failed",
2078
+ emailAttempts: { lt: 3 }
2079
+ },
2080
+ set: {
2081
+ emailStatus: "pending",
2082
+ claimedAt: null
2083
+ }
2084
+ })).applied, "RETRY_UNAVAILABLE", "This notification cannot be retried.", 409);
2085
+ await this.audit(actor, "notification.retry", id);
2086
+ return { queued: true };
2087
+ }
2088
+ async notificationQueue(actor, options = {}) {
2089
+ invariant(user$1(actor).role >= 50, "FORBIDDEN", "Administrator access required.", 403);
2090
+ return queryPage(this.stores.notifications, {
2091
+ where: options.status ? { emailStatus: options.status } : {},
2092
+ orderBy: { createdAt: "desc" },
2093
+ limit: options.limit ?? 30,
2094
+ cursor: options.cursor
2095
+ });
2096
+ }
2097
+ async auditList(actor, options = {}) {
2098
+ invariant(user$1(actor).role >= 50, "FORBIDDEN", "Administrator access required.", 403);
2099
+ return queryPage(this.stores.audit, {
2100
+ orderBy: { createdAt: "desc" },
2101
+ limit: options.limit ?? 30,
2102
+ cursor: options.cursor
2103
+ });
2104
+ }
2105
+ async reminders() {
2106
+ const settings = await this.settings();
2107
+ if (!settings.reminderDays) return 0;
2108
+ const cutoff = (/* @__PURE__ */ new Date(this.now().getTime() - settings.reminderDays * 864e5)).toISOString();
2109
+ const cursor = await this.ctx.kv.get("reminders:cursor");
2110
+ const page = await this.stores.enrollments.query({
2111
+ where: { status: "active" },
2112
+ limit: 30,
2113
+ cursor: cursor ?? void 0
2114
+ });
2115
+ const week = Math.floor(this.now().getTime() / (7 * 864e5));
2116
+ let queued = 0;
2117
+ for (const item of page.items) {
2118
+ if (item.data.lastActivityAt > cutoff || effectiveAccess(await this.accessEnrollment(item.data), this.now()) !== "granted") continue;
2119
+ await this.notify(item.data, `reminder-${week}`, "Continue your learning", "Your next lesson is waiting in My Learning.");
2120
+ queued++;
2121
+ }
2122
+ if (page.hasMore && page.cursor) await this.ctx.kv.set("reminders:cursor", page.cursor);
2123
+ else await this.ctx.kv.delete("reminders:cursor");
2124
+ return queued;
2125
+ }
2126
+ };
2127
+
2128
+ //#endregion
2129
+ //#region src/learning.ts
2130
+ const exportSchema = pageSchema.extend({ section: z.enum([
2131
+ "enrollments",
2132
+ "discussions",
2133
+ "reviews",
2134
+ "notifications",
2135
+ "saved_courses",
2136
+ "path_enrollments",
2137
+ "cohort_enrollments"
2138
+ ]) });
2139
+ function user(actor) {
2140
+ invariant(actor, "UNAUTHORIZED", "Sign in to continue.", 401);
2141
+ return actor;
2142
+ }
2143
+ function admin(actor) {
2144
+ const current = user(actor);
2145
+ invariant(current.role >= 50, "FORBIDDEN", "Administrator access required.", 403);
2146
+ return current;
2147
+ }
2148
+ function pathPage(input) {
2149
+ const options = parse(pageSchema, input);
2150
+ return {
2151
+ ...options,
2152
+ limit: Math.min(options.limit, 8)
2153
+ };
2154
+ }
2155
+ /** Optional learning features share the existing access and immutable-edition service. */
2156
+ var LearningFeatures = class {
2157
+ constructor(service) {
2158
+ this.service = service;
2159
+ }
2160
+ get stores() {
2161
+ return this.service.stores;
2162
+ }
2163
+ async preferences(actor) {
2164
+ const stored = await this.stores.preferences.getVersioned(user(actor).id);
2165
+ return {
2166
+ value: preferencesSchema.parse(stored?.value ?? {}),
2167
+ revision: stored?.revision ?? null
2168
+ };
2169
+ }
2170
+ async savePreferences(actor, input) {
2171
+ const current = user(actor);
2172
+ const parsed = parse(z.object({
2173
+ data: preferencesSchema,
2174
+ expectedRevision: z.string().max(128).nullable()
2175
+ }), input);
2176
+ const saved = await this.stores.preferences.compareAndSet(current.id, parsed.expectedRevision, parsed.data);
2177
+ invariant(saved.applied, "CONFLICT", "Preferences changed in another tab. Reload before saving.", 409);
2178
+ return {
2179
+ value: parsed.data,
2180
+ revision: saved.revision
2181
+ };
2182
+ }
2183
+ async saveCourse(actor, input) {
2184
+ const current = user(actor);
2185
+ const parsed = parse(courseIdSchema.extend({ saved: z.boolean() }), input);
2186
+ if (parsed.saved) invariant((await this.service.course(parsed.courseId)).status === "published", "NOT_FOUND", "Course not found.", 404);
2187
+ await change(this.stores.saved_courses, enrollmentKey(parsed.courseId, current.id), () => ({
2188
+ value: {
2189
+ userId: current.id,
2190
+ courseId: parsed.courseId,
2191
+ saved: parsed.saved,
2192
+ updatedAt: this.service.time()
2193
+ },
2194
+ result: true
2195
+ }));
2196
+ return { saved: parsed.saved };
2197
+ }
2198
+ async savedCourses(actor, input) {
2199
+ const current = user(actor);
2200
+ const options = parse(pageSchema, input);
2201
+ const page = await queryPage(this.stores.saved_courses, {
2202
+ ...options,
2203
+ where: {
2204
+ userId: current.id,
2205
+ saved: true
2206
+ },
2207
+ orderBy: { updatedAt: "desc" }
2208
+ });
2209
+ return {
2210
+ ...page,
2211
+ items: await Promise.all(page.items.map(async (item) => ({
2212
+ courseId: item.courseId,
2213
+ course: await this.card(item.courseId)
2214
+ })))
2215
+ };
2216
+ }
2217
+ async card(courseId) {
2218
+ const course = await this.stores.courses.get(courseId);
2219
+ return course?.status === "published" ? courseCard(course) : null;
2220
+ }
2221
+ async savePath(actor, input) {
2222
+ const current = admin(actor);
2223
+ const parsed = parse(pathSaveSchema, input);
2224
+ const id = parsed.id ?? crypto.randomUUID();
2225
+ const existing = await this.stores.learning_paths.getVersioned(id);
2226
+ invariant(!parsed.id || existing, "NOT_FOUND", "Learning path not found.", 404);
2227
+ invariant((existing?.revision ?? null) === parsed.expectedRevision, "CONFLICT", "This path changed. Reload before saving.", 409);
2228
+ for (const courseId of parsed.data.courseIds) {
2229
+ const course = await this.service.course(courseId);
2230
+ invariant(parsed.data.status !== "published" || course.status === "published", "VALIDATION_ERROR", "Publish all courses before publishing the path.");
2231
+ }
2232
+ const path = {
2233
+ ...parsed.data,
2234
+ id,
2235
+ createdAt: existing?.value.createdAt ?? this.service.time(),
2236
+ updatedAt: this.service.time()
2237
+ };
2238
+ assertSize(path);
2239
+ const saved = await this.stores.learning_paths.compareAndSet(id, parsed.expectedRevision, path);
2240
+ invariant(saved.applied, "CONFLICT", "This path changed. Reload before saving.", 409);
2241
+ await this.service.audit(current, "path.save", id);
2242
+ return {
2243
+ path,
2244
+ revision: saved.revision
2245
+ };
2246
+ }
2247
+ async adminPaths(actor, input) {
2248
+ admin(actor);
2249
+ const page = await this.stores.learning_paths.query({
2250
+ ...parse(pageSchema, input),
2251
+ orderBy: { updatedAt: "desc" }
2252
+ });
2253
+ const versions = await Promise.all(page.items.map((item) => this.stores.learning_paths.getVersioned(item.id)));
2254
+ return {
2255
+ ...page,
2256
+ items: versions.flatMap((item) => item ? [{
2257
+ ...item.value,
2258
+ revision: item.revision
2259
+ }] : [])
2260
+ };
2261
+ }
2262
+ async paths(input) {
2263
+ const page = await queryPage(this.stores.learning_paths, {
2264
+ ...pathPage(input),
2265
+ where: { status: "published" },
2266
+ orderBy: { updatedAt: "desc" }
2267
+ });
2268
+ const items = await Promise.all(page.items.map(async (path) => ({
2269
+ id: path.id,
2270
+ title: path.title,
2271
+ description: path.description,
2272
+ courses: await Promise.all(path.courseIds.map((id) => this.card(id)))
2273
+ })));
2274
+ return {
2275
+ ...page,
2276
+ items
2277
+ };
2278
+ }
2279
+ async joinPath(actor, input) {
2280
+ const current = user(actor);
2281
+ const { pathId } = parse(z.object({ pathId: idSchema }), input);
2282
+ const id = enrollmentKey(pathId, current.id);
2283
+ if (await this.stores.path_enrollments.get(id)) return { joined: true };
2284
+ const path = await this.stores.learning_paths.get(pathId);
2285
+ invariant(path?.status === "published", "NOT_FOUND", "Learning path not found.", 404);
2286
+ await putOnce(this.stores.path_enrollments, id, {
2287
+ pathId,
2288
+ userId: current.id,
2289
+ title: path.title,
2290
+ courseIds: path.courseIds,
2291
+ joinedAt: this.service.time()
2292
+ });
2293
+ return { joined: true };
2294
+ }
2295
+ async learnerPaths(actor, input) {
2296
+ const current = user(actor);
2297
+ const page = await queryPage(this.stores.path_enrollments, {
2298
+ ...pathPage(input),
2299
+ where: { userId: current.id },
2300
+ orderBy: { joinedAt: "desc" }
2301
+ });
2302
+ const items = await Promise.all(page.items.map(async (path) => {
2303
+ const courses = await Promise.all(path.courseIds.map(async (courseId) => {
2304
+ const enrollment = await this.stores.enrollments.get(enrollmentKey(courseId, current.id));
2305
+ const course = await this.stores.courses.get(courseId);
2306
+ return {
2307
+ course: course && enrollment ? courseCard(await this.service.editions.learning(course, enrollment)) : await this.card(courseId),
2308
+ completed: Boolean(enrollment?.completedAt)
2309
+ };
2310
+ }));
2311
+ return {
2312
+ pathId: path.pathId,
2313
+ title: path.title,
2314
+ joinedAt: path.joinedAt,
2315
+ total: courses.length,
2316
+ completed: courses.filter((c) => c.completed).length,
2317
+ courses
2318
+ };
2319
+ }));
2320
+ return {
2321
+ ...page,
2322
+ items
2323
+ };
2324
+ }
2325
+ async playback(actor, input) {
2326
+ const current = user(actor);
2327
+ const parsed = parse(playbackSchema, input);
2328
+ const course = await this.service.course(parsed.courseId);
2329
+ return change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
2330
+ const lesson = await this.service.lessonAccess(course, parsed.lessonId, stored);
2331
+ invariant(["audio", "video"].includes(lesson.kind), "VALIDATION_ERROR", "Only audio and video lessons store playback positions.");
2332
+ const next = structuredClone(stored);
2333
+ const progress = next.progress[lesson.id] ?? emptyProgress();
2334
+ invariant((progress.playback?.revision ?? 0) === parsed.expectedRevision, "CONFLICT", "Playback changed in another tab. Reload to resume saving.", 409);
2335
+ const playback = {
2336
+ positionSeconds: parsed.positionSeconds,
2337
+ playbackRate: parsed.playbackRate,
2338
+ revision: parsed.expectedRevision + 1,
2339
+ updatedAt: this.service.time()
2340
+ };
2341
+ progress.playback = playback;
2342
+ next.progress[lesson.id] = progress;
2343
+ next.lastActivityAt = this.service.time();
2344
+ return {
2345
+ value: next,
2346
+ result: playback
2347
+ };
2348
+ });
2349
+ }
2350
+ async exportData(actor, input) {
2351
+ const current = user(actor);
2352
+ const parsed = parse(exportSchema, input);
2353
+ const { section, cursor } = parsed;
2354
+ const options = {
2355
+ where: section === "discussions" ? { authorId: current.id } : { userId: current.id },
2356
+ cursor,
2357
+ limit: Math.min(parsed.limit, 5)
2358
+ };
2359
+ let page;
2360
+ if (section === "enrollments") {
2361
+ const raw = await queryPage(this.stores.enrollments, options);
2362
+ page = {
2363
+ ...raw,
2364
+ items: await Promise.all(raw.items.map((enrollment) => this.service.enrollmentView(enrollment)))
2365
+ };
2366
+ } else {
2367
+ const raw = await this.stores[section].query(options);
2368
+ page = {
2369
+ ...raw,
2370
+ items: raw.items.map((item) => item.data)
2371
+ };
2372
+ }
2373
+ return {
2374
+ format: "anantagnanam-lms-learner",
2375
+ version: 1,
2376
+ exportedAt: this.service.time(),
2377
+ section,
2378
+ profile: {
2379
+ id: current.id,
2380
+ name: current.name,
2381
+ email: current.email
2382
+ },
2383
+ preferences: (await this.preferences(current)).value,
2384
+ ...page
2385
+ };
2386
+ }
2387
+ async health(actor) {
2388
+ admin(actor);
2389
+ const [maintenance, pendingEmails, failedEmails, pending, settings, migration] = await Promise.all([
2390
+ this.service.ctx.kv.get("maintenanceHealth"),
2391
+ this.stores.notifications.count({ emailStatus: "pending" }),
2392
+ this.stores.notifications.count({ emailStatus: "failed" }),
2393
+ this.stores.notifications.query({
2394
+ where: { emailStatus: "pending" },
2395
+ orderBy: { createdAt: "asc" },
2396
+ limit: 1
2397
+ }),
2398
+ this.service.settings(),
2399
+ this.service.migrationStatus(actor)
2400
+ ]);
2401
+ return {
2402
+ version: PLUGIN_VERSION,
2403
+ maintenance,
2404
+ maintenanceStale: !maintenance?.completedAt || this.service.now().getTime() - Date.parse(maintenance.completedAt) > 45 * 6e4,
2405
+ pendingEmails,
2406
+ failedEmails,
2407
+ oldestPendingAt: pending.items[0]?.data.createdAt ?? null,
2408
+ emailEnabled: settings.emailNotifications,
2409
+ migration
2410
+ };
2411
+ }
2412
+ };
2413
+
2414
+ //#endregion
2415
+ //#region src/runtime.ts
2416
+ const listSchema = pageSchema.extend({
2417
+ courseId: idSchema.optional(),
2418
+ status: z.enum([
2419
+ "pending",
2420
+ "active",
2421
+ "completed",
2422
+ "rejected",
2423
+ "suspended",
2424
+ "cancelled"
2425
+ ]).optional()
2426
+ });
2427
+ const communityListSchema = pageSchema.extend({
2428
+ courseId: idSchema.optional(),
2429
+ status: z.enum([
2430
+ "pending",
2431
+ "published",
2432
+ "hidden"
2433
+ ]).optional()
2434
+ });
2435
+ const slugSchema = z.object({ slug: z.string().min(1).max(100) });
2436
+ const keyInput = z.object({ id: keySchema });
2437
+ const courseRevisionInput = z.object({
2438
+ id: idSchema,
2439
+ expectedRevision: z.string().min(1).max(128)
2440
+ });
2441
+ function get(handler, publicRoute = false, permission = "content:read") {
2442
+ return definePluginRoute({
2443
+ methods: ["GET"],
2444
+ public: publicRoute,
2445
+ permission,
2446
+ request: { body: "none" },
2447
+ handler: async (ctx) => handler(ctx, new LmsService(ctx))
2448
+ });
2449
+ }
2450
+ function post(handler, permission = "content:read") {
2451
+ return definePluginRoute({
2452
+ methods: ["POST"],
2453
+ permission,
2454
+ request: {
2455
+ body: "json",
2456
+ maxBytes: 1e6
2457
+ },
2458
+ handler: async (ctx) => handler(ctx, new LmsService(ctx))
2459
+ });
2460
+ }
2461
+ function createPlugin(_options = {}) {
2462
+ const options = normalizeLmsOptions(_options);
2463
+ return definePlugin({
2464
+ id: PLUGIN_ID,
2465
+ version: PLUGIN_VERSION,
2466
+ capabilities: [
2467
+ "schema:read",
2468
+ "content:read",
2469
+ "users:read",
2470
+ "email:send"
2471
+ ],
2472
+ storage,
2473
+ admin: {
2474
+ entry: "@anantagnanam/lms/admin",
2475
+ pages: adminPages,
2476
+ widgets: [{
2477
+ id: "learning",
2478
+ title: "Learning",
2479
+ size: "half"
2480
+ }]
2481
+ },
2482
+ hooks: {
2483
+ "plugin:install": async (_event, ctx) => {
2484
+ await ctx.kv.compareAndSet("schemaVersion", null, 1);
2485
+ },
2486
+ "plugin:activate": async (_event, ctx) => {
2487
+ await ctx.cron?.schedule("maintenance", { schedule: "*/15 * * * *" });
2488
+ },
2489
+ "plugin:deactivate": async (_event, ctx) => {
2490
+ await ctx.cron?.cancel("maintenance");
2491
+ },
2492
+ cron: async (event, ctx) => {
2493
+ if (event.name === "maintenance") {
2494
+ const service = new LmsService(ctx);
2495
+ const startedAt = service.time();
2496
+ try {
2497
+ await service.reminders();
2498
+ await service.deliverNotifications();
2499
+ await ctx.kv.set("maintenanceHealth", {
2500
+ startedAt,
2501
+ completedAt: service.time(),
2502
+ failed: false
2503
+ });
2504
+ } catch (error) {
2505
+ await ctx.kv.set("maintenanceHealth", {
2506
+ startedAt,
2507
+ completedAt: null,
2508
+ failed: true
2509
+ });
2510
+ throw error;
2511
+ }
2512
+ }
2513
+ }
2514
+ },
2515
+ routes: {
2516
+ paths: get(async (ctx, service) => new LearningFeatures(service).paths(ctx.input), true),
2517
+ "learner/paths": get(async (ctx, service) => new LearningFeatures(service).learnerPaths(ctx.user, ctx.input)),
2518
+ "learner/path/join": post(async (ctx, service) => new LearningFeatures(service).joinPath(ctx.user, ctx.input)),
2519
+ "learner/saved": get(async (ctx, service) => new LearningFeatures(service).savedCourses(ctx.user, ctx.input)),
2520
+ "learner/saved/save": post(async (ctx, service) => new LearningFeatures(service).saveCourse(ctx.user, ctx.input)),
2521
+ "learner/preferences": get(async (ctx, service) => new LearningFeatures(service).preferences(ctx.user)),
2522
+ "learner/preferences/save": post(async (ctx, service) => new LearningFeatures(service).savePreferences(ctx.user, ctx.input)),
2523
+ "learner/playback": post(async (ctx, service) => new LearningFeatures(service).playback(ctx.user, ctx.input)),
2524
+ "learner/export": get(async (ctx, service) => new LearningFeatures(service).exportData(ctx.user, ctx.input)),
2525
+ "admin/paths": get(async (ctx, service) => new LearningFeatures(service).adminPaths(ctx.user, ctx.input), false, "plugins:manage"),
2526
+ "admin/path/save": post(async (ctx, service) => new LearningFeatures(service).savePath(ctx.user, ctx.input), "plugins:manage"),
2527
+ "admin/health": get(async (ctx, service) => new LearningFeatures(service).health(ctx.user), false, "plugins:manage"),
2528
+ academy: get(async (_ctx, service) => {
2529
+ const settings = await service.settings();
2530
+ return {
2531
+ title: settings.academyTitle,
2532
+ description: settings.academyDescription,
2533
+ basePath: options.basePath
2534
+ };
2535
+ }, true),
2536
+ catalog: get(async (ctx, service) => service.catalog(ctx.input), true),
2537
+ course: get(async (ctx, service) => service.publicCourse(parse(slugSchema, ctx.input).slug), true),
2538
+ cohort: get(async (ctx, service) => service.publicCohort(parse(z.object({ id: idSchema }), ctx.input).id), true),
2539
+ preview: get(async (ctx, service) => {
2540
+ const input = parse(lessonIdSchema, ctx.input);
2541
+ return service.preview(input.courseId, input.lessonId);
2542
+ }, true),
2543
+ reviews: get(async (ctx, service) => {
2544
+ const input = parse(courseIdSchema.extend({ cursor: z.string().optional() }), ctx.input);
2545
+ return service.publicReviews(input.courseId, input.cursor);
2546
+ }, true),
2547
+ "certificates/verify": get(async (ctx, service) => service.verifyCertificate(parse(z.object({ id: idSchema }), ctx.input).id), true),
2548
+ "learner/dashboard": get(async (ctx, service) => service.dashboard(ctx.user, parse(pageSchema, ctx.input))),
2549
+ "learner/course": get(async (ctx, service) => service.learnerCourse(ctx.user, parse(courseIdSchema, ctx.input).courseId)),
2550
+ "learner/course-by-slug": get(async (ctx, service) => service.learnerCourseBySlug(ctx.user, parse(slugSchema, ctx.input).slug)),
2551
+ "learner/lesson": get(async (ctx, service) => {
2552
+ const input = parse(lessonIdSchema, ctx.input);
2553
+ return service.learnerLesson(ctx.user, input.courseId, input.lessonId);
2554
+ }),
2555
+ "learner/enroll": post(async (ctx, service) => {
2556
+ const input = parse(enrollmentRequestSchema, ctx.input);
2557
+ return service.enroll(ctx.user, input.courseId, { cohortId: input.cohortId });
2558
+ }),
2559
+ "learner/cohort/request": post(async (ctx, service) => service.requestCohort(ctx.user, parse(z.object({ cohortId: idSchema }), ctx.input).cohortId)),
2560
+ "learner/cancel": post(async (ctx, service) => service.cancelEnrollment(ctx.user, parse(courseIdSchema, ctx.input).courseId)),
2561
+ "learner/start": post(async (ctx, service) => {
2562
+ const input = parse(lessonIdSchema, ctx.input);
2563
+ return service.startLesson(ctx.user, input.courseId, input.lessonId);
2564
+ }),
2565
+ "learner/complete": post(async (ctx, service) => {
2566
+ const input = parse(lessonIdSchema, ctx.input);
2567
+ return service.completeLesson(ctx.user, input.courseId, input.lessonId);
2568
+ }),
2569
+ "learner/quiz/start": post(async (ctx, service) => {
2570
+ const input = parse(lessonIdSchema, ctx.input);
2571
+ return service.startQuiz(ctx.user, input.courseId, input.lessonId);
2572
+ }),
2573
+ "learner/quiz/submit": post(async (ctx, service) => service.submitQuiz(ctx.user, ctx.input)),
2574
+ "learner/quiz/draft": post(async (ctx, service) => service.saveQuizDraft(ctx.user, ctx.input)),
2575
+ "learner/assignment": post(async (ctx, service) => service.submitAssignment(ctx.user, ctx.input)),
2576
+ "learner/note": post(async (ctx, service) => service.saveNote(ctx.user, ctx.input)),
2577
+ "learner/discussions": get(async (ctx, service) => {
2578
+ const input = parse(lessonIdSchema.extend({ cursor: z.string().optional() }), ctx.input);
2579
+ return service.discussions(ctx.user, input.courseId, input.lessonId, input.cursor);
2580
+ }),
2581
+ "learner/discussion": post(async (ctx, service) => service.postDiscussion(ctx.user, ctx.input)),
2582
+ "learner/review": post(async (ctx, service) => service.postReview(ctx.user, ctx.input)),
2583
+ "learner/certificate": get(async (ctx, service) => service.certificate(ctx.user, parse(courseIdSchema, ctx.input).courseId)),
2584
+ "learner/certificate-by-id": get(async (ctx, service) => service.ownedCertificate(ctx.user, parse(z.object({ id: idSchema }), ctx.input).id)),
2585
+ "learner/cohort": get(async (ctx, service) => {
2586
+ const input = parse(z.object({ cohortId: idSchema }), ctx.input);
2587
+ invariant(ctx.user, "UNAUTHORIZED", "Sign in to continue.", 401);
2588
+ return service.stores.cohort_enrollments.get(`${input.cohortId}:${ctx.user.id}`);
2589
+ }),
2590
+ "learner/certificate/share": post(async (ctx, service) => {
2591
+ const input = parse(certificateActionSchema, ctx.input);
2592
+ return service.shareCertificate(ctx.user, input.courseId, input.shared);
2593
+ }),
2594
+ "learner/notifications": get(async (ctx, service) => service.notifications(ctx.user, parse(pageSchema, ctx.input).cursor)),
2595
+ "learner/notification/read": post(async (ctx, service) => service.readNotification(ctx.user, parse(keyInput, ctx.input).id)),
2596
+ "admin/courses": get(async (ctx, service) => service.adminCourses(ctx.user, parse(pageSchema.extend({ status: z.enum([
2597
+ "draft",
2598
+ "published",
2599
+ "archived"
2600
+ ]).optional() }), ctx.input))),
2601
+ "admin/course": get(async (ctx, service) => service.editableCourse(ctx.user, parse(z.object({ id: idSchema }), ctx.input).id)),
2602
+ "admin/course/save": post(async (ctx, service) => service.saveCourse(ctx.user, ctx.input)),
2603
+ "admin/course/publish": post(async (ctx, service) => {
2604
+ const input = parse(courseRevisionInput, ctx.input);
2605
+ return service.publishCourse(ctx.user, input.id, input.expectedRevision);
2606
+ }),
2607
+ "admin/course/archive": post(async (ctx, service) => {
2608
+ const input = parse(courseRevisionInput, ctx.input);
2609
+ return service.archiveCourse(ctx.user, input.id, input.expectedRevision);
2610
+ }),
2611
+ "admin/course/history": get(async (ctx, service) => {
2612
+ const input = parse(z.object({
2613
+ id: idSchema,
2614
+ cursor: idSchema.optional()
2615
+ }), ctx.input);
2616
+ return service.courseHistory(ctx.user, input.id, input.cursor);
2617
+ }),
2618
+ "admin/course/restore": post(async (ctx, service) => {
2619
+ const input = parse(courseRevisionInput.extend({ revisionId: idSchema }), ctx.input);
2620
+ return service.restoreCourse(ctx.user, input.id, input.expectedRevision, input.revisionId);
2621
+ }),
2622
+ "admin/migrations": get(async (ctx, service) => service.migrationStatus(ctx.user), false, "plugins:manage"),
2623
+ "admin/migrations/run": post(async (ctx, service) => {
2624
+ const input = parse(z.object({ limit: z.number().int().min(1).max(20).default(20) }), ctx.input);
2625
+ return service.migrateBatch(ctx.user, input.limit);
2626
+ }, "plugins:manage"),
2627
+ "admin/lesson/preview": get(async (ctx, service) => {
2628
+ const input = parse(lessonIdSchema, ctx.input);
2629
+ return service.authorLessonPreview(ctx.user, input.courseId, input.lessonId);
2630
+ }),
2631
+ "admin/course/delete": post(async (ctx, service) => {
2632
+ const input = parse(z.object({
2633
+ id: idSchema,
2634
+ expectedRevision: z.string().min(1).max(128)
2635
+ }), ctx.input);
2636
+ return service.deleteCourse(ctx.user, input.id, input.expectedRevision);
2637
+ }),
2638
+ "admin/course/export": get(async (ctx, service) => service.exportCourse(ctx.user, parse(z.object({ id: idSchema }), ctx.input).id)),
2639
+ "admin/course/import": post(async (ctx, service) => service.importCourse(ctx.user, ctx.input)),
2640
+ "admin/enrollments": get(async (ctx, service) => service.enrollments(ctx.user, parse(listSchema, ctx.input))),
2641
+ "admin/enrollment/save": post(async (ctx, service) => service.adminEnrollment(ctx.user, ctx.input), "plugins:manage"),
2642
+ "admin/assignments": get(async (ctx, service) => {
2643
+ const input = parse(courseIdSchema.extend({ cursor: z.string().optional() }), ctx.input);
2644
+ return service.assignments(ctx.user, input.courseId, input.cursor);
2645
+ }),
2646
+ "admin/assignment/grade": post(async (ctx, service) => service.gradeAssignment(ctx.user, ctx.input)),
2647
+ "admin/discussions": get(async (ctx, service) => service.communityQueue(ctx.user, "discussions", parse(communityListSchema, ctx.input))),
2648
+ "admin/reviews": get(async (ctx, service) => service.communityQueue(ctx.user, "reviews", parse(communityListSchema, ctx.input))),
2649
+ "admin/discussion/moderate": post(async (ctx, service) => service.moderate(ctx.user, "discussions", ctx.input)),
2650
+ "admin/review/moderate": post(async (ctx, service) => service.moderate(ctx.user, "reviews", ctx.input)),
2651
+ "admin/certificate/revoke": post(async (ctx, service) => {
2652
+ const input = parse(certificateRevokeSchema, ctx.input);
2653
+ return service.revokeCertificate(ctx.user, input.courseId, input.userId, input.reason);
2654
+ }),
2655
+ "admin/report": get(async (ctx, service) => service.report(ctx.user, parse(z.object({ courseId: idSchema.optional() }), ctx.input).courseId)),
2656
+ "admin/cohorts": get(async (ctx, service) => service.cohortList(ctx.user, parse(pageSchema, ctx.input).cursor), false, "plugins:manage"),
2657
+ "admin/cohort/save": post(async (ctx, service) => service.saveCohort(ctx.user, ctx.input), "plugins:manage"),
2658
+ "admin/cohort/enroll": post(async (ctx, service) => {
2659
+ const input = parse(z.object({
2660
+ id: idSchema,
2661
+ expectedRevision: z.string().min(1).max(128),
2662
+ offset: z.number().int().min(0).max(1e3).default(0)
2663
+ }), ctx.input);
2664
+ return service.applyCohort(ctx.user, input.id, input.expectedRevision, input.offset);
2665
+ }, "plugins:manage"),
2666
+ "admin/cohort/memberships": get(async (ctx, service) => service.cohortMemberships(ctx.user, parse(pageSchema.extend({
2667
+ cohortId: idSchema.optional(),
2668
+ status: z.enum([
2669
+ "pending",
2670
+ "active",
2671
+ "rejected",
2672
+ "suspended",
2673
+ "cancelled"
2674
+ ]).optional()
2675
+ }), ctx.input)), false, "plugins:manage"),
2676
+ "admin/cohort/decision": post(async (ctx, service) => {
2677
+ const input = parse(z.object({
2678
+ cohortId: idSchema,
2679
+ userId: idSchema,
2680
+ status: z.enum([
2681
+ "active",
2682
+ "rejected",
2683
+ "suspended",
2684
+ "cancelled"
2685
+ ]),
2686
+ reason: z.string().max(1e3).default("")
2687
+ }), ctx.input);
2688
+ return service.decideCohort(ctx.user, input.cohortId, input.userId, input.status, input.reason);
2689
+ }, "plugins:manage"),
2690
+ "admin/settings": get(async (_ctx, service) => service.settings(), false, "plugins:manage"),
2691
+ "admin/settings/save": post(async (ctx, service) => service.saveSettings(ctx.user, ctx.input), "plugins:manage"),
2692
+ "admin/notification/retry": post(async (ctx, service) => service.retryNotification(ctx.user, parse(keyInput, ctx.input).id), "plugins:manage"),
2693
+ "admin/notifications": get(async (ctx, service) => service.notificationQueue(ctx.user, parse(pageSchema.extend({ status: z.enum([
2694
+ "pending",
2695
+ "sending",
2696
+ "sent",
2697
+ "disabled",
2698
+ "failed"
2699
+ ]).optional() }), ctx.input)), false, "plugins:manage"),
2700
+ "admin/audit": get(async (ctx, service) => service.auditList(ctx.user, parse(pageSchema, ctx.input)), false, "plugins:manage"),
2701
+ "admin/users": get(async (ctx) => {
2702
+ invariant(ctx.users, "USERS_UNAVAILABLE", "The user directory is unavailable.", 503);
2703
+ const input = parse(pageSchema, ctx.input);
2704
+ const page = await ctx.users.list({
2705
+ limit: input.limit,
2706
+ cursor: input.cursor
2707
+ });
2708
+ return {
2709
+ ...page,
2710
+ items: page.items.map((entry) => ({
2711
+ id: entry.id,
2712
+ name: entry.name,
2713
+ email: entry.email,
2714
+ role: entry.role
2715
+ }))
2716
+ };
2717
+ }, false, "users:read"),
2718
+ "admin/instructors": get(async (ctx) => {
2719
+ invariant(ctx.user && ctx.user.role >= 20, "FORBIDDEN", "Course authoring access required.", 403);
2720
+ invariant(ctx.users, "USERS_UNAVAILABLE", "The user directory is unavailable.", 503);
2721
+ const input = parse(pageSchema, ctx.input);
2722
+ const page = await ctx.users.list({
2723
+ limit: input.limit,
2724
+ cursor: input.cursor
2725
+ });
2726
+ return {
2727
+ ...page,
2728
+ items: page.items.filter((entry) => entry.role >= 20).map((entry) => ({
2729
+ id: entry.id,
2730
+ name: entry.name,
2731
+ email: entry.email
2732
+ }))
2733
+ };
2734
+ }),
2735
+ "admin/content/collections": get(async (ctx) => {
2736
+ invariant(ctx.user && ctx.user.role >= 20, "FORBIDDEN", "Course authoring access required.", 403);
2737
+ invariant(ctx.schema, "SCHEMA_UNAVAILABLE", "The content schema is unavailable.", 503);
2738
+ return (await ctx.schema.listCollections()).map((collection) => ({
2739
+ slug: collection.slug,
2740
+ label: collection.label,
2741
+ fields: collection.fields.filter((field) => [
2742
+ "portableText",
2743
+ "text",
2744
+ "string"
2745
+ ].includes(field.type)).map((field) => ({
2746
+ slug: field.slug,
2747
+ label: field.label,
2748
+ type: field.type
2749
+ }))
2750
+ })).filter((collection) => collection.fields.length);
2751
+ }),
2752
+ "admin/content/entries": get(async (ctx) => {
2753
+ invariant(ctx.user && ctx.user.role >= 20, "FORBIDDEN", "Course authoring access required.", 403);
2754
+ invariant(ctx.schema && ctx.content, "CONTENT_UNAVAILABLE", "Content integration is unavailable.", 503);
2755
+ const input = parse(pageSchema.extend({ collection: idSchema }), ctx.input);
2756
+ const collection = await ctx.schema.getCollection(input.collection);
2757
+ invariant(collection, "NOT_FOUND", "Content collection not found.", 404);
2758
+ const page = await ctx.content.list(input.collection, {
2759
+ limit: input.limit,
2760
+ cursor: input.cursor,
2761
+ where: { status: "published" }
2762
+ });
2763
+ return {
2764
+ ...page,
2765
+ items: page.items.map((entry) => ({
2766
+ id: entry.id,
2767
+ title: String(entry.data[collection.titleField ?? "title"] ?? entry.slug ?? "Published entry")
2768
+ }))
2769
+ };
2770
+ }),
2771
+ "admin/learner/find": post(async (ctx, service) => {
2772
+ const input = parse(courseIdSchema.extend({ email: z.email().max(254) }), ctx.input);
2773
+ service.instructor(await service.course(input.courseId), ctx.user);
2774
+ invariant(ctx.users, "USERS_UNAVAILABLE", "The user directory is unavailable.", 503);
2775
+ const target = await ctx.users.getByEmail(input.email);
2776
+ invariant(target, "NOT_FOUND", "No account uses this email address. Invite the learner through EmDash first.", 404);
2777
+ return {
2778
+ id: target.id,
2779
+ name: target.name,
2780
+ email: target.email
2781
+ };
2782
+ }, "plugins:manage"),
2783
+ "admin/report/csv": definePluginRoute({
2784
+ methods: ["GET"],
2785
+ permission: "content:read",
2786
+ request: { body: "none" },
2787
+ response: "raw",
2788
+ handler: async (ctx) => {
2789
+ const input = parse(listSchema, ctx.input);
2790
+ return pluginResponse({
2791
+ body: {
2792
+ kind: "text",
2793
+ value: await new LmsService(ctx).enrollmentCsv(ctx.user, input)
2794
+ },
2795
+ headers: {
2796
+ "Content-Type": "text/csv; charset=utf-8",
2797
+ "Content-Disposition": "attachment; filename=\"lms-enrollments.csv\""
2798
+ }
2799
+ });
2800
+ }
2801
+ }),
2802
+ "mcp/courses": post(async (ctx, service) => service.adminCourses(ctx.user, parse(pageSchema, ctx.input)))
2803
+ },
2804
+ mcp: { tools: {
2805
+ list_courses: {
2806
+ route: "mcp/courses",
2807
+ description: "List courses available to this instructor or administrator.",
2808
+ input: pageSchema
2809
+ },
2810
+ save_course: {
2811
+ route: "admin/course/save",
2812
+ description: "Create a course or save a working draft using its current storage revision. Existing courses require publish_course to publish edits; initial creation with published status publishes immediately.",
2813
+ input: courseSaveSchema,
2814
+ destructive: true
2815
+ },
2816
+ publish_course: {
2817
+ route: "admin/course/publish",
2818
+ description: "Publish the saved draft as a new course edition using its current storage revision. Existing learners retain their enrolled edition.",
2819
+ input: courseRevisionInput,
2820
+ destructive: true
2821
+ },
2822
+ enroll_learner: {
2823
+ route: "admin/enrollment/save",
2824
+ description: "Approve, reject, suspend, or manually enroll a learner. Requires an administrator account.",
2825
+ input: enrollmentAdminSchema,
2826
+ destructive: true
2827
+ },
2828
+ grade_assignment: {
2829
+ route: "admin/assignment/grade",
2830
+ description: "Grade a pending assignment submission in a course you teach.",
2831
+ input: gradeSchema,
2832
+ destructive: true
2833
+ }
2834
+ } }
2835
+ });
2836
+ }
2837
+
2838
+ //#endregion
2839
+ export { LmsService, createPlugin };
2840
+ //# sourceMappingURL=runtime.js.map