@anantagnanam/lms 0.1.0 → 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 (40) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +23 -2
  3. package/astro/client.ts +73 -151
  4. package/astro/components/CourseOverview.astro +25 -3
  5. package/astro/components/LearnerDashboard.astro +6 -0
  6. package/astro/components/LearningShell.astro +10 -1
  7. package/astro/components/LessonAvailabilityNote.astro +26 -0
  8. package/astro/components/LessonContent.astro +53 -0
  9. package/astro/components/LessonPlayer.astro +40 -61
  10. package/astro/components/MediaPlayer.astro +55 -4
  11. package/astro/media-player.ts +99 -0
  12. package/astro/pages/author-preview.astro +71 -0
  13. package/astro/pages/course.astro +10 -0
  14. package/astro/pages/lesson.astro +3 -0
  15. package/astro/pages/library.astro +173 -0
  16. package/astro/pages/paths.astro +73 -0
  17. package/astro/quiz-drafts.ts +87 -0
  18. package/astro/quiz-player.ts +362 -0
  19. package/astro/styles.css +12 -0
  20. package/dist/admin.d.ts +3 -1
  21. package/dist/admin.js +585 -116
  22. package/dist/admin.js.map +1 -1
  23. package/dist/astro.js +3 -0
  24. package/dist/astro.js.map +1 -1
  25. package/dist/{constants-C0kT94tc.js → constants-Cnx7k82Z.js} +25 -3
  26. package/dist/constants-Cnx7k82Z.js.map +1 -0
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/runtime.d.ts +104 -12
  30. package/dist/runtime.js +896 -116
  31. package/dist/runtime.js.map +1 -1
  32. package/dist/schemas.d.ts +68 -1
  33. package/dist/schemas.js +34 -2
  34. package/dist/schemas.js.map +1 -1
  35. package/dist/server.d.ts +11 -1
  36. package/dist/server.js +13 -2
  37. package/dist/server.js.map +1 -1
  38. package/dist/types.d.ts +107 -4
  39. package/package.json +1 -1
  40. package/dist/constants-C0kT94tc.js.map +0 -1
package/dist/runtime.js CHANGED
@@ -1,6 +1,6 @@
1
- import { a as adminPages, i as PLUGIN_VERSION, n as MAX_DOCUMENT_BYTES, o as storage, r as PLUGIN_ID } from "./constants-C0kT94tc.js";
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
2
  import { t as normalizeLmsOptions } from "./config-BX3beTpM.js";
3
- import { assignmentSubmitSchema, catalogSchema, certificateActionSchema, certificateRevokeSchema, cohortSaveSchema, courseIdSchema, courseSaveSchema, discussionSchema, enrollmentAdminSchema, enrollmentRequestSchema, gradeSchema, idSchema, keySchema, lessonIdSchema, moderateSchema, noteSchema, pageSchema, quizSubmitSchema, reviewSchema, settingsSchema } from "./schemas.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
4
  import { n as invariant, t as LmsError } from "./errors-ChzJS9al.js";
5
5
  import { n as enrollmentCsvLines, t as enrollmentCsvHeader } from "./csv-DKq_4OWU.js";
6
6
  import { definePlugin, definePluginRoute, pluginResponse } from "emdash";
@@ -37,6 +37,212 @@ async function putOnce(store, id, value) {
37
37
  await store.compareAndSet(id, null, value);
38
38
  }
39
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
+
40
246
  //#endregion
41
247
  //#region src/curriculum.ts
42
248
  function lessons(course) {
@@ -103,6 +309,8 @@ function publicLesson(lesson) {
103
309
  const { quiz, contentSource: _source, ...safe } = lesson;
104
310
  return {
105
311
  ...safe,
312
+ transcript: lesson.transcript ?? "",
313
+ captions: lesson.captions ?? [],
106
314
  quiz: quiz ? {
107
315
  passingPercent: quiz.passingPercent,
108
316
  maxAttempts: quiz.maxAttempts,
@@ -116,7 +324,10 @@ function publicLesson(lesson) {
116
324
  }
117
325
  function publicAttempt(attempt) {
118
326
  const { quiz: _quiz, ...safe } = attempt;
119
- return safe;
327
+ return {
328
+ ...safe,
329
+ draftRevision: attempt.draftRevision ?? 0
330
+ };
120
331
  }
121
332
  function effectiveAccess(enrollment, now = /* @__PURE__ */ new Date()) {
122
333
  if (enrollment.status === "suspended" || enrollment.accessStatus === "suspended") return "suspended";
@@ -142,16 +353,65 @@ function publicEnrollment(enrollment, now = /* @__PURE__ */ new Date()) {
142
353
  }]))
143
354
  };
144
355
  }
145
- function progressSummary(course, enrollment) {
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()) {
146
366
  const required = enrollment?.completedAt ? enrollment.completionLessonIds : lessons(course).filter((lesson) => lesson.required).map((lesson) => lesson.id);
147
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;
148
372
  return {
149
373
  completed,
150
374
  total: required.length,
151
375
  percent: required.length ? Math.floor(completed / required.length * 100) : 0,
152
- nextLessonId: lessons(course).find((lesson) => !enrollment?.progress[lesson.id]?.completedAt)?.id ?? null
376
+ nextLessonId,
377
+ blockedLessonId: nextLessonId ? null : incomplete[0]?.id ?? null,
378
+ availability
153
379
  };
154
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
+ }
155
415
  function assertEnrollmentAccess(enrollment, now) {
156
416
  invariant(enrollment && (enrollment.status === "active" || enrollment.status === "completed"), "ENROLLMENT_REQUIRED", "An active enrollment is required for this lesson.", 403);
157
417
  invariant(enrollment.accessStatus === "granted", "ACCESS_RESTRICTED", "Your course access is not currently granted.", 403);
@@ -173,8 +433,7 @@ function assertLessonAccess(course, lessonId, enrollment, now, allowPreview = fa
173
433
  ].includes(lesson.kind)) return lesson;
174
434
  assertEnrollmentAccess(enrollment, now);
175
435
  invariant(course.status !== "draft", "NOT_FOUND", "Course not found.", 404);
176
- const module = course.modules.find((item) => item.lessons.some((entry) => entry.id === lessonId));
177
- const releaseAt = Math.max(module.unlockAt ? Date.parse(module.unlockAt) : 0, Date.parse(enrollment.approvedAt ?? enrollment.enrolledAt) + module.releaseDays * 864e5);
436
+ const releaseAt = moduleReleaseAt(course, lessonId, enrollment);
178
437
  invariant(now.getTime() >= releaseAt, "LESSON_LOCKED", "This module has not been released yet.", 403);
179
438
  if (course.sequential) {
180
439
  const all = lessons(course);
@@ -233,16 +492,18 @@ function parse(schema, input) {
233
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);
234
493
  return result.data;
235
494
  }
236
- function user(actor) {
495
+ function user$1(actor) {
237
496
  invariant(actor, "UNAUTHORIZED", "Sign in to continue.", 401);
238
497
  return actor;
239
498
  }
240
499
  var LmsService = class {
241
500
  stores;
501
+ editions;
242
502
  constructor(ctx, now = () => /* @__PURE__ */ new Date()) {
243
503
  this.ctx = ctx;
244
504
  this.now = now;
245
505
  this.stores = collections(ctx);
506
+ this.editions = new CourseEditions(this.stores, () => this.time(), (lesson) => this.resolveContent(lesson));
246
507
  }
247
508
  time() {
248
509
  return this.now().toISOString();
@@ -251,7 +512,7 @@ var LmsService = class {
251
512
  return settingsSchema.parse(await this.ctx.settings.get("configuration") ?? {});
252
513
  }
253
514
  async saveSettings(actor, input) {
254
- invariant(user(actor).role >= 50, "FORBIDDEN", "Only administrators can change LMS settings.", 403);
515
+ invariant(user$1(actor).role >= 50, "FORBIDDEN", "Only administrators can change LMS settings.", 403);
255
516
  const settings = parse(settingsSchema, input);
256
517
  invariant(!settings.emailNotifications || this.ctx.email, "EMAIL_UNAVAILABLE", "Configure an email transport in EmDash before enabling LMS emails.");
257
518
  await this.ctx.settings.set("configuration", settings);
@@ -295,51 +556,182 @@ var LmsService = class {
295
556
  async enrollmentView(enrollment) {
296
557
  return publicEnrollment(await this.accessEnrollment(enrollment), this.now());
297
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
+ }
298
568
  async lessonAccess(course, lessonId, enrollment) {
299
- return assertLessonAccess(course, lessonId, await this.accessEnrollment(enrollment), this.now());
569
+ return assertLessonAccess(await this.editions.learning(course, enrollment), lessonId, await this.accessEnrollment(enrollment), this.now());
300
570
  }
301
571
  instructor(course, actor) {
302
- const current = user(actor);
303
- invariant(current.role >= 50 || course.createdBy === current.id || course.instructorIds.includes(current.id), "FORBIDDEN", "You are not an instructor for this course.", 403);
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);
304
574
  return current;
305
575
  }
306
576
  async saveCourse(actor, input) {
307
- const currentActor = user(actor);
577
+ const currentActor = user$1(actor);
308
578
  const parsed = parse(courseSaveSchema, input);
309
579
  const id = parsed.id ?? crypto.randomUUID();
310
- const current = await this.stores.courses.getVersioned(id);
580
+ let current = await this.stores.courses.getVersioned(id);
311
581
  if (current) this.instructor(current.value, currentActor);
312
582
  else invariant(currentActor.role >= 20, "FORBIDDEN", "Course authoring requires a contributor account or higher.", 403);
313
583
  invariant((current?.revision ?? null) === parsed.expectedRevision, "CONFLICT", "The course changed since you opened it. Reload before saving.", 409);
314
- if (parsed.data.status === "published") invariant(currentActor.role >= 50 || currentActor.role >= 30, "FORBIDDEN", "Publishing requires an author account or higher.", 403);
315
- invariant(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);
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);
316
586
  invariant(!(await this.stores.courses.query({
317
587
  where: { slug: parsed.data.slug },
318
588
  limit: 1
319
589
  })).items.some((item) => item.id !== id), "SLUG_TAKEN", "A course already uses this URL.", 409);
320
590
  await this.assertPrerequisiteGraph(id, parsed.data.prerequisiteCourseIds);
321
- if (current && await this.stores.enrollments.count({ courseId: id })) {
322
- const nextIds = new Set(parsed.data.modules.flatMap((module) => module.lessons.map((lesson) => lesson.id)));
323
- invariant(lessons(current.value).every((lesson) => nextIds.has(lesson.id)), "CURRICULUM_IN_USE", "Enrolled courses must keep existing lesson IDs. Archive the course or create a new edition to remove lessons.", 409);
324
- }
325
- const course = {
591
+ if (current) current = await this.editions.ensure(id, current.revision);
592
+ let course = {
326
593
  ...parsed.data,
327
594
  id,
328
595
  searchTitle: parsed.data.title.toLocaleLowerCase("en"),
329
596
  createdBy: current?.value.createdBy ?? currentActor.id,
330
597
  createdAt: current?.value.createdAt ?? this.time(),
331
598
  updatedAt: this.time(),
332
- schemaVersion: 1
599
+ schemaVersion: 2
333
600
  };
334
- assertSize(course);
335
- const saved = await this.stores.courses.compareAndSet(id, current?.revision ?? null, course);
336
- invariant(saved.applied, "CONFLICT", "The course changed while saving. Reload before saving.", 409);
337
- await this.audit(currentActor, "course.save", id, { status: course.status });
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 });
338
660
  return {
339
- course,
661
+ course: await this.editions.draft(next),
340
662
  revision: saved.revision
341
663
  };
342
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
+ }
343
735
  async assertPrerequisiteGraph(courseId, prerequisites) {
344
736
  const visited = /* @__PURE__ */ new Set();
345
737
  const visit = async (id) => {
@@ -357,12 +749,12 @@ var LmsService = class {
357
749
  invariant(current, "NOT_FOUND", "Course not found.", 404);
358
750
  this.instructor(current.value, actor);
359
751
  return {
360
- course: current.value,
752
+ course: await this.editions.draft(current.value),
361
753
  revision: current.revision
362
754
  };
363
755
  }
364
756
  async adminCourses(actor, options = {}) {
365
- const current = user(actor);
757
+ const current = user$1(actor);
366
758
  const page = await queryPage(this.stores.courses, {
367
759
  where: options.status ? { status: options.status } : {},
368
760
  orderBy: { updatedAt: "desc" },
@@ -371,7 +763,7 @@ var LmsService = class {
371
763
  });
372
764
  return {
373
765
  ...page,
374
- items: page.items.filter((course) => current.role >= 50 || course.createdBy === current.id || course.instructorIds.includes(current.id))
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)))
375
767
  };
376
768
  }
377
769
  async catalog(input) {
@@ -449,6 +841,13 @@ var LmsService = class {
449
841
  portableText: []
450
842
  };
451
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
+ }
452
851
  async assertPrerequisites(course, actorId) {
453
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);
454
853
  }
@@ -458,6 +857,7 @@ var LmsService = class {
458
857
  return {
459
858
  id: enrollmentKey(course.id, actor.id),
460
859
  courseId: course.id,
860
+ courseEditionId: course.publishedEditionId ?? course.legacyEditionId,
461
861
  userId: actor.id,
462
862
  learnerName: actor.name || actor.email.split("@")[0] || "Learner",
463
863
  learnerEmail: actor.email,
@@ -501,10 +901,12 @@ var LmsService = class {
501
901
  };
502
902
  }
503
903
  async enroll(actor, courseId, options = {}) {
504
- const current = user(actor);
505
- const course = await this.course(courseId);
904
+ const current = user$1(actor);
905
+ let course = await this.course(courseId);
506
906
  invariant(course.status === "published", "NOT_FOUND", "Course not found.", 404);
507
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);
508
910
  const cohort = options.cohortId ? await this.stores.cohorts.get(options.cohortId) : null;
509
911
  if (options.cohortId) invariant(cohort && cohort.courseIds.includes(courseId), "NOT_FOUND", "This course is not part of that cohort.", 404);
510
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);
@@ -554,10 +956,11 @@ var LmsService = class {
554
956
  }
555
957
  async adminEnrollment(actor, input) {
556
958
  const parsed = parse(enrollmentAdminSchema, input);
557
- const course = await this.course(parsed.courseId);
959
+ let course = await this.course(parsed.courseId);
558
960
  const currentActor = this.instructor(course, actor);
559
961
  invariant(currentActor.role >= 50, "FORBIDDEN", "Only administrators can make enrollment decisions.", 403);
560
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;
561
964
  const target = await this.ctx.users.get(parsed.userId);
562
965
  invariant(target, "NOT_FOUND", "Learner account not found.", 404);
563
966
  invariant(!parsed.accessStartsAt || !parsed.accessExpiresAt || Date.parse(parsed.accessExpiresAt) > Date.parse(parsed.accessStartsAt), "INVALID_ACCESS_WINDOW", "Access expiry must follow its start.");
@@ -576,6 +979,7 @@ var LmsService = class {
576
979
  const enrollment = await change(this.stores.enrollments, enrollmentKey(course.id, target.id), async (stored) => {
577
980
  const next = structuredClone(stored ?? this.newEnrollment(course, target, parsed.status));
578
981
  const firstApproval = parsed.status === "active" && !next.approvedAt;
982
+ next.courseEditionId ??= course.legacyEditionId ?? course.publishedEditionId;
579
983
  next.status = parsed.status === "active" && next.completedAt ? "completed" : parsed.status;
580
984
  next.accessStatus = parsed.status === "active" ? "granted" : parsed.status === "suspended" ? "suspended" : "revoked";
581
985
  next.approvedBy = parsed.status === "active" ? currentActor.id : next.approvedBy;
@@ -604,7 +1008,7 @@ var LmsService = class {
604
1008
  }
605
1009
  invariant(!next.accessStartsAt || !next.accessExpiresAt || Date.parse(next.accessExpiresAt) > Date.parse(next.accessStartsAt), "INVALID_ACCESS_WINDOW", "Access expiry must follow its start.");
606
1010
  next.lastActivityAt = this.time();
607
- if (parsed.status === "active") this.finish(course, next, settings);
1011
+ if (parsed.status === "active") this.finish(await this.editions.learning(course, next), next, settings);
608
1012
  return {
609
1013
  value: next,
610
1014
  result: next
@@ -613,22 +1017,21 @@ var LmsService = class {
613
1017
  await this.audit(currentActor, "enrollment.update", enrollment.id, { status: enrollment.status });
614
1018
  await this.notify(enrollment, `enrollment-${enrollment.status}-${enrollment.decisionAt}`, "Enrollment updated", `${course.title}: your enrollment is ${enrollment.status}.`);
615
1019
  await this.projectCompletion(enrollment);
616
- return this.enrollmentView(enrollment);
1020
+ return staffEnrollment(await this.accessEnrollment(enrollment), this.now());
617
1021
  }
618
1022
  async learnerCourse(actor, courseId) {
619
- const current = user(actor);
1023
+ const current = user$1(actor);
620
1024
  const course = await this.course(courseId);
621
1025
  const enrollment = await this.stores.enrollments.get(enrollmentKey(courseId, current.id));
622
1026
  invariant(course.status === "published" || course.status === "archived" && enrollment, "NOT_FOUND", "Course not found.", 404);
623
1027
  if (enrollment?.completedAt) await this.projectCompletion(enrollment);
624
1028
  return {
625
- course: await this.detail(course),
626
- enrollment: enrollment ? await this.enrollmentView(enrollment) : null,
627
- progress: progressSummary(course, enrollment)
1029
+ course: await this.detail(await this.editions.learning(course, enrollment)),
1030
+ ...await this.learningState(course, enrollment)
628
1031
  };
629
1032
  }
630
1033
  async learnerCourseBySlug(actor, slug) {
631
- user(actor);
1034
+ user$1(actor);
632
1035
  const page = await this.stores.courses.query({
633
1036
  where: { slug },
634
1037
  limit: 1
@@ -637,7 +1040,7 @@ var LmsService = class {
637
1040
  return this.learnerCourse(actor, page.items[0].id);
638
1041
  }
639
1042
  async dashboard(actor, options = {}) {
640
- const current = user(actor);
1043
+ const current = user$1(actor);
641
1044
  const page = await queryPage(this.stores.enrollments, {
642
1045
  where: { userId: current.id },
643
1046
  orderBy: { enrolledAt: "desc" },
@@ -649,9 +1052,8 @@ var LmsService = class {
649
1052
  if (enrollment.completedAt) await this.projectCompletion(enrollment);
650
1053
  const course = courses.get(enrollment.courseId);
651
1054
  return course ? {
652
- course: courseCard(course),
653
- enrollment: await this.enrollmentView(enrollment),
654
- progress: progressSummary(course, enrollment)
1055
+ course: courseCard(await this.editions.learning(course, enrollment)),
1056
+ ...await this.learningState(course, enrollment)
655
1057
  } : null;
656
1058
  }))).filter((row) => row !== null);
657
1059
  return {
@@ -660,19 +1062,18 @@ var LmsService = class {
660
1062
  };
661
1063
  }
662
1064
  async learnerLesson(actor, courseId, lessonId) {
663
- const current = user(actor);
1065
+ const current = user$1(actor);
664
1066
  const course = await this.course(courseId);
665
1067
  const enrollment = await this.stores.enrollments.get(enrollmentKey(courseId, current.id));
666
1068
  const lesson = await this.lessonAccess(course, lessonId, enrollment);
667
1069
  return {
668
- course: courseDetail(course),
1070
+ course: courseDetail(await this.editions.learning(course, enrollment)),
669
1071
  lesson: publicLesson(await this.resolveContent(lesson)),
670
- enrollment: await this.enrollmentView(enrollment),
671
- progress: progressSummary(course, enrollment)
1072
+ ...await this.learningState(course, enrollment)
672
1073
  };
673
1074
  }
674
1075
  async startLesson(actor, courseId, lessonId) {
675
- const current = user(actor);
1076
+ const current = user$1(actor);
676
1077
  const course = await this.course(courseId);
677
1078
  const enrollment = await change(this.stores.enrollments, enrollmentKey(courseId, current.id), async (stored) => {
678
1079
  await this.lessonAccess(course, lessonId, stored);
@@ -689,7 +1090,7 @@ var LmsService = class {
689
1090
  return await this.enrollmentView(enrollment);
690
1091
  }
691
1092
  async completeLesson(actor, courseId, lessonId) {
692
- const current = user(actor);
1093
+ const current = user$1(actor);
693
1094
  const course = await this.course(courseId);
694
1095
  const settings = await this.settings();
695
1096
  const enrollment = await change(this.stores.enrollments, enrollmentKey(courseId, current.id), async (stored) => {
@@ -702,17 +1103,14 @@ var LmsService = class {
702
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);
703
1104
  progress.completedAt ??= this.time();
704
1105
  next.progress[lessonId] = progress;
705
- this.finish(course, next, settings);
1106
+ this.finish(await this.editions.learning(course, next), next, settings);
706
1107
  return {
707
1108
  value: next,
708
1109
  result: next
709
1110
  };
710
1111
  });
711
1112
  await this.projectCompletion(enrollment);
712
- return {
713
- enrollment: await this.enrollmentView(enrollment),
714
- progress: progressSummary(course, enrollment)
715
- };
1113
+ return { ...await this.learningState(course, enrollment) };
716
1114
  }
717
1115
  finish(course, enrollment, settings) {
718
1116
  enrollment.lastActivityAt = this.time();
@@ -743,7 +1141,7 @@ var LmsService = class {
743
1141
  if (enrollment.completedAt) await this.notify(enrollment, "completed", "Course complete", "You have completed your course. Your achievement is saved in My Learning.");
744
1142
  }
745
1143
  async startQuiz(actor, courseId, lessonId) {
746
- const current = user(actor);
1144
+ const current = user$1(actor);
747
1145
  const course = await this.course(courseId);
748
1146
  const result = await change(this.stores.enrollments, enrollmentKey(courseId, current.id), async (stored) => {
749
1147
  const lesson = await this.lessonAccess(course, lessonId, stored);
@@ -790,7 +1188,8 @@ var LmsService = class {
790
1188
  percent: null,
791
1189
  passed: false,
792
1190
  feedback: [],
793
- answers: {}
1191
+ answers: {},
1192
+ draftRevision: 0
794
1193
  };
795
1194
  progress.quizAttempts.push(attempt);
796
1195
  next.lastActivityAt = this.time();
@@ -806,7 +1205,7 @@ var LmsService = class {
806
1205
  return publicAttempt(result.attempt);
807
1206
  }
808
1207
  async submitQuiz(actor, input) {
809
- const current = user(actor);
1208
+ const current = user$1(actor);
810
1209
  const parsed = parse(quizSubmitSchema, input);
811
1210
  const course = await this.course(parsed.courseId);
812
1211
  const settings = await this.settings();
@@ -838,6 +1237,7 @@ var LmsService = class {
838
1237
  };
839
1238
  }
840
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);
841
1241
  const graded = gradeAnswers(attempt.quiz.questions, parsed.answers);
842
1242
  attempt.passed = graded.score * 100 >= graded.maxScore * attempt.quiz.passingPercent;
843
1243
  attempt.feedback = attempt.quiz.showFeedback === "after_submit" || attempt.quiz.showFeedback === "after_pass" && attempt.passed ? graded.feedback : [];
@@ -849,7 +1249,7 @@ var LmsService = class {
849
1249
  attempt.submittedAt = this.time();
850
1250
  attempt.quiz = null;
851
1251
  if (attempt.passed) progress.completedAt ??= this.time();
852
- this.finish(course, next, settings);
1252
+ this.finish(await this.editions.learning(course, next), next, settings);
853
1253
  return {
854
1254
  value: next,
855
1255
  result: {
@@ -863,31 +1263,40 @@ var LmsService = class {
863
1263
  await this.projectCompletion(result.enrollment);
864
1264
  return {
865
1265
  attempt: publicAttempt(result.attempt),
866
- enrollment: await this.enrollmentView(result.enrollment),
867
- progress: progressSummary(course, result.enrollment)
1266
+ ...await this.learningState(course, result.enrollment)
868
1267
  };
869
1268
  }
870
1269
  async saveQuizDraft(actor, input) {
871
- const current = user(actor);
872
- const parsed = parse(quizSubmitSchema, input);
1270
+ const current = user$1(actor);
1271
+ const parsed = parse(quizDraftSchema, input);
873
1272
  const course = await this.course(parsed.courseId);
874
- await change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
875
- await this.lessonAccess(course, parsed.lessonId, stored);
876
- const next = structuredClone(stored);
877
- const attempt = next.progress[parsed.lessonId]?.quizAttempts.find((item) => item.id === parsed.attemptId);
878
- invariant(attempt?.status === "in_progress" && attempt.quiz, "ATTEMPT_CLOSED", "This quiz attempt is closed.", 409);
879
- invariant(!attempt.expiresAt || this.now().getTime() < Date.parse(attempt.expiresAt), "QUIZ_EXPIRED", "This quiz attempt has expired.", 409);
880
- gradeAnswers(attempt.quiz.questions, parsed.answers);
881
- attempt.answers = parsed.answers;
882
- return {
883
- value: next,
884
- result: true
885
- };
886
- });
887
- return { saved: true };
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
+ };
888
1297
  }
889
1298
  async submitAssignment(actor, input) {
890
- const current = user(actor);
1299
+ const current = user$1(actor);
891
1300
  const parsed = parse(assignmentSubmitSchema, input);
892
1301
  const course = await this.course(parsed.courseId);
893
1302
  const enrollment = await change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
@@ -947,7 +1356,7 @@ var LmsService = class {
947
1356
  "suspended",
948
1357
  "revoked",
949
1358
  "pending"
950
- ].includes(access)) this.finish(course, next, settings);
1359
+ ].includes(access)) this.finish(await this.editions.learning(course, next), next, settings);
951
1360
  return {
952
1361
  value: next,
953
1362
  result: next
@@ -959,10 +1368,10 @@ var LmsService = class {
959
1368
  lessonId: parsed.lessonId,
960
1369
  points: parsed.points
961
1370
  });
962
- return await this.enrollmentView(enrollment);
1371
+ return staffEnrollment(await this.accessEnrollment(enrollment), this.now());
963
1372
  }
964
1373
  async saveNote(actor, input) {
965
- const current = user(actor);
1374
+ const current = user$1(actor);
966
1375
  const parsed = parse(noteSchema, input);
967
1376
  const course = await this.course(parsed.courseId);
968
1377
  return { note: (await change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
@@ -978,14 +1387,14 @@ var LmsService = class {
978
1387
  })).progress[parsed.lessonId].note };
979
1388
  }
980
1389
  async certificate(actor, courseId) {
981
- const current = user(actor);
1390
+ const current = user$1(actor);
982
1391
  const enrollment = await this.stores.enrollments.get(enrollmentKey(courseId, current.id));
983
1392
  invariant(enrollment?.certificate, "NOT_FOUND", "Certificate not found.", 404);
984
1393
  await this.projectCompletion(enrollment);
985
1394
  return enrollment.certificate;
986
1395
  }
987
1396
  async ownedCertificate(actor, id) {
988
- const current = user(actor);
1397
+ const current = user$1(actor);
989
1398
  const index = await this.stores.certificates.get(id);
990
1399
  invariant(index?.userId === current.id, "NOT_FOUND", "Certificate not found.", 404);
991
1400
  const enrollment = await this.stores.enrollments.get(index.enrollmentId);
@@ -1008,7 +1417,7 @@ var LmsService = class {
1008
1417
  };
1009
1418
  }
1010
1419
  async shareCertificate(actor, courseId, shared) {
1011
- const current = user(actor);
1420
+ const current = user$1(actor);
1012
1421
  return await change(this.stores.enrollments, enrollmentKey(courseId, current.id), async (stored) => {
1013
1422
  invariant(stored?.certificate, "NOT_FOUND", "Certificate not found.", 404);
1014
1423
  const next = structuredClone(stored);
@@ -1036,7 +1445,7 @@ var LmsService = class {
1036
1445
  return certificate;
1037
1446
  }
1038
1447
  async cancelEnrollment(actor, courseId) {
1039
- const current = user(actor);
1448
+ const current = user$1(actor);
1040
1449
  const enrollment = await change(this.stores.enrollments, enrollmentKey(courseId, current.id), async (stored) => {
1041
1450
  invariant(stored, "NOT_FOUND", "Enrollment not found.", 404);
1042
1451
  const next = {
@@ -1052,7 +1461,7 @@ var LmsService = class {
1052
1461
  return await this.enrollmentView(enrollment);
1053
1462
  }
1054
1463
  async discussions(actor, courseId, lessonId, cursor) {
1055
- const current = user(actor);
1464
+ const current = user$1(actor);
1056
1465
  const course = await this.course(courseId);
1057
1466
  const enrollment = await this.stores.enrollments.get(enrollmentKey(courseId, current.id));
1058
1467
  await this.lessonAccess(course, lessonId, enrollment);
@@ -1081,7 +1490,7 @@ var LmsService = class {
1081
1490
  };
1082
1491
  }
1083
1492
  async postDiscussion(actor, input) {
1084
- const current = user(actor);
1493
+ const current = user$1(actor);
1085
1494
  const parsed = parse(discussionSchema, input);
1086
1495
  const course = await this.course(parsed.courseId);
1087
1496
  const enrollment = await this.stores.enrollments.get(enrollmentKey(course.id, current.id));
@@ -1117,7 +1526,7 @@ var LmsService = class {
1117
1526
  });
1118
1527
  }
1119
1528
  async postReview(actor, input) {
1120
- const current = user(actor);
1529
+ const current = user$1(actor);
1121
1530
  const parsed = parse(reviewSchema, input);
1122
1531
  const course = await this.course(parsed.courseId);
1123
1532
  const enrollment = await this.accessEnrollment(await this.stores.enrollments.get(enrollmentKey(course.id, current.id)));
@@ -1159,7 +1568,7 @@ var LmsService = class {
1159
1568
  return next;
1160
1569
  }
1161
1570
  async communityQueue(actor, collection, options = {}) {
1162
- const current = user(actor);
1571
+ const current = user$1(actor);
1163
1572
  if (options.courseId) this.instructor(await this.course(options.courseId), current);
1164
1573
  else invariant(current.role >= 50, "FORBIDDEN", "Select a course you teach.", 403);
1165
1574
  const store = this.stores[collection];
@@ -1182,7 +1591,7 @@ var LmsService = class {
1182
1591
  };
1183
1592
  }
1184
1593
  async enrollments(actor, options = {}) {
1185
- const current = user(actor);
1594
+ const current = user$1(actor);
1186
1595
  if (options.courseId) this.instructor(await this.course(options.courseId), current);
1187
1596
  else invariant(current.role >= 50, "FORBIDDEN", "Select a course you teach.", 403);
1188
1597
  const page = await queryPage(this.stores.enrollments, {
@@ -1197,17 +1606,23 @@ var LmsService = class {
1197
1606
  const courses = await this.stores.courses.getMany(page.items.map((item) => item.courseId));
1198
1607
  return {
1199
1608
  ...page,
1200
- items: await Promise.all(page.items.map(async (enrollment) => ({
1201
- ...await this.enrollmentView(enrollment),
1202
- learnerEmail: enrollment.learnerEmail,
1203
- courseTitle: courses.get(enrollment.courseId)?.title ?? "Archived course",
1204
- lessonTitles: Object.fromEntries((courses.has(enrollment.courseId) ? lessons(courses.get(enrollment.courseId)) : []).map((lesson) => [lesson.id, lesson.title])),
1205
- progressSummary: courses.has(enrollment.courseId) ? progressSummary(courses.get(enrollment.courseId), enrollment) : null
1206
- })))
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
+ }))
1207
1622
  };
1208
1623
  }
1209
1624
  async report(actor, courseId) {
1210
- const current = user(actor);
1625
+ const current = user$1(actor);
1211
1626
  if (courseId) this.instructor(await this.course(courseId), current);
1212
1627
  else invariant(current.role >= 50, "FORBIDDEN", "Select a course you teach.", 403);
1213
1628
  const filter = courseId ? { courseId } : {};
@@ -1279,7 +1694,7 @@ var LmsService = class {
1279
1694
  return "" + lines.join("\r\n");
1280
1695
  }
1281
1696
  async cohortList(actor, cursor) {
1282
- invariant(user(actor).role >= 50, "FORBIDDEN", "Only administrators can manage cohorts.", 403);
1697
+ invariant(user$1(actor).role >= 50, "FORBIDDEN", "Only administrators can manage cohorts.", 403);
1283
1698
  const page = await this.stores.cohorts.query({
1284
1699
  orderBy: { createdAt: "desc" },
1285
1700
  limit: 24,
@@ -1307,7 +1722,7 @@ var LmsService = class {
1307
1722
  };
1308
1723
  }
1309
1724
  async requestCohort(actor, cohortId) {
1310
- const current = user(actor);
1725
+ const current = user$1(actor);
1311
1726
  const cohort = await this.stores.cohorts.get(cohortId);
1312
1727
  invariant(cohort, "NOT_FOUND", "Cohort not found.", 404);
1313
1728
  invariant(!cohort.endsAt || this.now().getTime() < Date.parse(cohort.endsAt), "COHORT_CLOSED", "This cohort has ended.", 403);
@@ -1337,7 +1752,7 @@ var LmsService = class {
1337
1752
  });
1338
1753
  }
1339
1754
  async cohortMemberships(actor, options = {}) {
1340
- invariant(user(actor).role >= 50, "FORBIDDEN", "Only administrators can review cohort membership.", 403);
1755
+ invariant(user$1(actor).role >= 50, "FORBIDDEN", "Only administrators can review cohort membership.", 403);
1341
1756
  return queryPage(this.stores.cohort_enrollments, {
1342
1757
  where: {
1343
1758
  ...options.cohortId ? { cohortId: options.cohortId } : {},
@@ -1349,7 +1764,7 @@ var LmsService = class {
1349
1764
  });
1350
1765
  }
1351
1766
  async decideCohort(actor, cohortId, userId, status, reason = "") {
1352
- const current = user(actor);
1767
+ const current = user$1(actor);
1353
1768
  invariant(current.role >= 50, "FORBIDDEN", "Only administrators can make cohort decisions.", 403);
1354
1769
  const cohort = await this.stores.cohorts.get(cohortId);
1355
1770
  invariant(cohort && this.ctx.users, "NOT_FOUND", "Cohort not found.", 404);
@@ -1421,7 +1836,7 @@ var LmsService = class {
1421
1836
  };
1422
1837
  }
1423
1838
  async saveCohort(actor, input) {
1424
- const current = user(actor);
1839
+ const current = user$1(actor);
1425
1840
  invariant(current.role >= 50, "FORBIDDEN", "Only administrators can manage cohorts.", 403);
1426
1841
  const parsed = parse(cohortSaveSchema, input);
1427
1842
  for (const id of parsed.data.courseIds) await this.course(id);
@@ -1445,7 +1860,7 @@ var LmsService = class {
1445
1860
  };
1446
1861
  }
1447
1862
  async applyCohort(actor, id, expectedRevision, offset) {
1448
- const current = user(actor);
1863
+ const current = user$1(actor);
1449
1864
  invariant(current.role >= 50, "FORBIDDEN", "Only administrators can enroll cohorts.", 403);
1450
1865
  const stored = await this.stores.cohorts.getVersioned(id);
1451
1866
  invariant(stored, "NOT_FOUND", "Cohort not found.", 404);
@@ -1476,6 +1891,7 @@ var LmsService = class {
1476
1891
  }
1477
1892
  async notify(enrollment, key, title, message) {
1478
1893
  const settings = await this.settings();
1894
+ const preferences = await this.stores.preferences.get(enrollment.userId);
1479
1895
  const id = `${enrollment.id}:${key}`;
1480
1896
  const notification = {
1481
1897
  id,
@@ -1486,14 +1902,14 @@ var LmsService = class {
1486
1902
  courseId: enrollment.courseId,
1487
1903
  read: false,
1488
1904
  createdAt: this.time(),
1489
- emailStatus: settings.emailNotifications ? "pending" : "disabled",
1905
+ emailStatus: settings.emailNotifications && preferences?.emailNotifications !== false ? "pending" : "disabled",
1490
1906
  emailAttempts: 0,
1491
1907
  claimedAt: null
1492
1908
  };
1493
1909
  await putOnce(this.stores.notifications, id, notification);
1494
1910
  }
1495
1911
  async notifications(actor, cursor) {
1496
- const current = user(actor);
1912
+ const current = user$1(actor);
1497
1913
  const page = await queryPage(this.stores.notifications, {
1498
1914
  where: { userId: current.id },
1499
1915
  orderBy: { createdAt: "desc" },
@@ -1506,7 +1922,7 @@ var LmsService = class {
1506
1922
  };
1507
1923
  }
1508
1924
  async readNotification(actor, id) {
1509
- const current = user(actor);
1925
+ const current = user$1(actor);
1510
1926
  await change(this.stores.notifications, id, async (stored) => {
1511
1927
  invariant(stored && stored.userId === current.id, "NOT_FOUND", "Notification not found.", 404);
1512
1928
  return {
@@ -1561,6 +1977,17 @@ var LmsService = class {
1561
1977
  delta: { emailAttempts: { inc: 1 } }
1562
1978
  })).applied) continue;
1563
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
+ }
1564
1991
  await this.ctx.email.send({
1565
1992
  to: item.data.email,
1566
1993
  subject: item.data.title,
@@ -1611,11 +2038,13 @@ var LmsService = class {
1611
2038
  }
1612
2039
  async exportCourse(actor, id) {
1613
2040
  const { course } = await this.editableCourse(actor, id);
1614
- const { id: _id, searchTitle: _search, createdBy: _creator, createdAt: _created, updatedAt: _updated, schemaVersion: _version, ...data } = course;
1615
2041
  return {
1616
2042
  format: "anantagnanam-lms-course",
1617
2043
  version: 1,
1618
- course: data
2044
+ course: courseDataSchema.parse({
2045
+ ...course,
2046
+ status: "draft"
2047
+ })
1619
2048
  };
1620
2049
  }
1621
2050
  async importCourse(actor, input) {
@@ -1642,7 +2071,7 @@ var LmsService = class {
1642
2071
  return { deleted: true };
1643
2072
  }
1644
2073
  async retryNotification(actor, id) {
1645
- invariant(user(actor).role >= 50, "FORBIDDEN", "Only administrators can retry email delivery.", 403);
2074
+ invariant(user$1(actor).role >= 50, "FORBIDDEN", "Only administrators can retry email delivery.", 403);
1646
2075
  invariant((await this.stores.notifications.updateIf(id, {
1647
2076
  where: {
1648
2077
  emailStatus: "failed",
@@ -1657,7 +2086,7 @@ var LmsService = class {
1657
2086
  return { queued: true };
1658
2087
  }
1659
2088
  async notificationQueue(actor, options = {}) {
1660
- invariant(user(actor).role >= 50, "FORBIDDEN", "Administrator access required.", 403);
2089
+ invariant(user$1(actor).role >= 50, "FORBIDDEN", "Administrator access required.", 403);
1661
2090
  return queryPage(this.stores.notifications, {
1662
2091
  where: options.status ? { emailStatus: options.status } : {},
1663
2092
  orderBy: { createdAt: "desc" },
@@ -1666,7 +2095,7 @@ var LmsService = class {
1666
2095
  });
1667
2096
  }
1668
2097
  async auditList(actor, options = {}) {
1669
- invariant(user(actor).role >= 50, "FORBIDDEN", "Administrator access required.", 403);
2098
+ invariant(user$1(actor).role >= 50, "FORBIDDEN", "Administrator access required.", 403);
1670
2099
  return queryPage(this.stores.audit, {
1671
2100
  orderBy: { createdAt: "desc" },
1672
2101
  limit: options.limit ?? 30,
@@ -1696,6 +2125,292 @@ var LmsService = class {
1696
2125
  }
1697
2126
  };
1698
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
+
1699
2414
  //#endregion
1700
2415
  //#region src/runtime.ts
1701
2416
  const listSchema = pageSchema.extend({
@@ -1719,6 +2434,10 @@ const communityListSchema = pageSchema.extend({
1719
2434
  });
1720
2435
  const slugSchema = z.object({ slug: z.string().min(1).max(100) });
1721
2436
  const keyInput = z.object({ id: keySchema });
2437
+ const courseRevisionInput = z.object({
2438
+ id: idSchema,
2439
+ expectedRevision: z.string().min(1).max(128)
2440
+ });
1722
2441
  function get(handler, publicRoute = false, permission = "content:read") {
1723
2442
  return definePluginRoute({
1724
2443
  methods: ["GET"],
@@ -1762,7 +2481,7 @@ function createPlugin(_options = {}) {
1762
2481
  },
1763
2482
  hooks: {
1764
2483
  "plugin:install": async (_event, ctx) => {
1765
- await ctx.kv.set("schemaVersion", 1);
2484
+ await ctx.kv.compareAndSet("schemaVersion", null, 1);
1766
2485
  },
1767
2486
  "plugin:activate": async (_event, ctx) => {
1768
2487
  await ctx.cron?.schedule("maintenance", { schedule: "*/15 * * * *" });
@@ -1773,12 +2492,39 @@ function createPlugin(_options = {}) {
1773
2492
  cron: async (event, ctx) => {
1774
2493
  if (event.name === "maintenance") {
1775
2494
  const service = new LmsService(ctx);
1776
- await service.reminders();
1777
- await service.deliverNotifications();
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
+ }
1778
2512
  }
1779
2513
  }
1780
2514
  },
1781
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"),
1782
2528
  academy: get(async (_ctx, service) => {
1783
2529
  const settings = await service.settings();
1784
2530
  return {
@@ -1854,6 +2600,34 @@ function createPlugin(_options = {}) {
1854
2600
  ]).optional() }), ctx.input))),
1855
2601
  "admin/course": get(async (ctx, service) => service.editableCourse(ctx.user, parse(z.object({ id: idSchema }), ctx.input).id)),
1856
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
+ }),
1857
2631
  "admin/course/delete": post(async (ctx, service) => {
1858
2632
  const input = parse(z.object({
1859
2633
  id: idSchema,
@@ -2035,10 +2809,16 @@ function createPlugin(_options = {}) {
2035
2809
  },
2036
2810
  save_course: {
2037
2811
  route: "admin/course/save",
2038
- description: "Create or update a course using its current storage revision. Can publish course content.",
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.",
2039
2813
  input: courseSaveSchema,
2040
2814
  destructive: true
2041
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
+ },
2042
2822
  enroll_learner: {
2043
2823
  route: "admin/enrollment/save",
2044
2824
  description: "Approve, reject, suspend, or manually enroll a learner. Requires an administrator account.",