@anantagnanam/lms 0.1.0 → 0.3.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 (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +40 -2
  3. package/astro/assignment-player.ts +231 -0
  4. package/astro/client.ts +97 -152
  5. package/astro/components/AssignmentFeedback.astro +32 -0
  6. package/astro/components/AssignmentRubric.astro +27 -0
  7. package/astro/components/CourseOverview.astro +25 -3
  8. package/astro/components/LearnerDashboard.astro +9 -0
  9. package/astro/components/LearningShell.astro +16 -1
  10. package/astro/components/LessonAvailabilityNote.astro +26 -0
  11. package/astro/components/LessonContent.astro +67 -0
  12. package/astro/components/LessonPlayer.astro +166 -99
  13. package/astro/components/MediaPlayer.astro +55 -4
  14. package/astro/draft-queue.ts +95 -0
  15. package/astro/media-player.ts +124 -0
  16. package/astro/pages/author-preview.astro +73 -0
  17. package/astro/pages/course.astro +10 -0
  18. package/astro/pages/lesson.astro +3 -0
  19. package/astro/pages/library.astro +173 -0
  20. package/astro/pages/paths.astro +73 -0
  21. package/astro/pages/study.astro +180 -0
  22. package/astro/quiz-drafts.ts +23 -0
  23. package/astro/quiz-player.ts +362 -0
  24. package/astro/study-tools.ts +386 -0
  25. package/astro/styles.css +30 -0
  26. package/astro/transcript-search.ts +56 -0
  27. package/dist/admin.d.ts +3 -1
  28. package/dist/admin.js +756 -127
  29. package/dist/admin.js.map +1 -1
  30. package/dist/assignments-CsuAjxGO.js +75 -0
  31. package/dist/assignments-CsuAjxGO.js.map +1 -0
  32. package/dist/astro.js +4 -0
  33. package/dist/astro.js.map +1 -1
  34. package/dist/{constants-C0kT94tc.js → constants-DyQbwNbP.js} +25 -3
  35. package/dist/constants-DyQbwNbP.js.map +1 -0
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/runtime.d.ts +129 -16
  39. package/dist/runtime.js +1201 -130
  40. package/dist/runtime.js.map +1 -1
  41. package/dist/schemas.d.ts +136 -3
  42. package/dist/schemas.js +80 -7
  43. package/dist/schemas.js.map +1 -1
  44. package/dist/server.d.ts +18 -1
  45. package/dist/server.js +17 -3
  46. package/dist/server.js.map +1 -1
  47. package/dist/types.d.ts +172 -4
  48. package/package.json +1 -1
  49. package/dist/constants-C0kT94tc.js.map +0 -1
  50. package/dist/errors-ChzJS9al.js +0 -16
  51. package/dist/errors-ChzJS9al.js.map +0 -1
package/dist/runtime.js CHANGED
@@ -1,7 +1,7 @@
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-DyQbwNbP.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";
4
- import { n as invariant, t as LmsError } from "./errors-ChzJS9al.js";
3
+ import { assignmentDraftSchema, 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, studyNoteSchema } from "./schemas.js";
4
+ import { a as invariant, i as LmsError, n as assignmentGrade, r as emptyAssignmentDraft, t as assignmentBlock } from "./assignments-CsuAjxGO.js";
5
5
  import { n as enrollmentCsvLines, t as enrollmentCsvHeader } from "./csv-DKq_4OWU.js";
6
6
  import { definePlugin, definePluginRoute, pluginResponse } from "emdash";
7
7
  import { z } from "zod";
@@ -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,68 @@ 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
+ delete view.studyNotes;
360
+ for (const progress of Object.values(view.progress)) {
361
+ progress.note = "";
362
+ delete progress.noteRevision;
363
+ delete progress.playback;
364
+ delete progress.assignmentDraft;
365
+ }
366
+ return view;
367
+ }
368
+ function progressSummary(course, enrollment, now = /* @__PURE__ */ new Date()) {
146
369
  const required = enrollment?.completedAt ? enrollment.completionLessonIds : lessons(course).filter((lesson) => lesson.required).map((lesson) => lesson.id);
147
370
  const completed = required.filter((id) => enrollment?.progress[id]?.completedAt).length;
371
+ const all = lessons(course);
372
+ const availability = Object.fromEntries(all.map((lesson) => [lesson.id, lessonAvailability(course, lesson.id, enrollment, now)]));
373
+ const incomplete = all.filter((lesson) => !enrollment?.progress[lesson.id]?.completedAt);
374
+ const nextLessonId = incomplete.find((lesson) => availability[lesson.id].available)?.id ?? null;
148
375
  return {
149
376
  completed,
150
377
  total: required.length,
151
378
  percent: required.length ? Math.floor(completed / required.length * 100) : 0,
152
- nextLessonId: lessons(course).find((lesson) => !enrollment?.progress[lesson.id]?.completedAt)?.id ?? null
379
+ nextLessonId,
380
+ blockedLessonId: nextLessonId ? null : incomplete[0]?.id ?? null,
381
+ availability
153
382
  };
154
383
  }
384
+ /** Use the same access boundary for navigation and private lesson reads. Enrollment
385
+ * must already have its cohort grants resolved against authoritative membership. */
386
+ function lessonAvailability(course, lessonId, enrollment, now) {
387
+ try {
388
+ assertLessonAccess(course, lessonId, enrollment, now);
389
+ return {
390
+ available: true,
391
+ code: null,
392
+ reason: null,
393
+ releaseAt: null
394
+ };
395
+ } catch (error) {
396
+ if (!(error instanceof LmsError)) throw error;
397
+ let releaseAt = null;
398
+ if (enrollment && error.code === "LESSON_LOCKED") releaseAt = new Date(moduleReleaseAt(course, lessonId, enrollment)).toISOString();
399
+ else if (enrollment && effectiveAccess(enrollment, now) === "scheduled") {
400
+ const starts = Object.values(enrollment.grants).filter((grant) => grant.status === "granted" && (!grant.expiresAt || Date.parse(grant.expiresAt) > now.getTime())).flatMap((grant) => {
401
+ const date = Math.max(grant.startsAt ? Date.parse(grant.startsAt) : 0, enrollment.accessStartsAt ? Date.parse(enrollment.accessStartsAt) : 0);
402
+ return date > now.getTime() && (!grant.expiresAt || date < Date.parse(grant.expiresAt)) && (!enrollment.accessExpiresAt || date < Date.parse(enrollment.accessExpiresAt)) ? [date] : [];
403
+ });
404
+ if (starts.length) releaseAt = new Date(Math.min(...starts)).toISOString();
405
+ }
406
+ return {
407
+ available: false,
408
+ code: error.code,
409
+ reason: error.message,
410
+ releaseAt
411
+ };
412
+ }
413
+ }
414
+ function moduleReleaseAt(course, lessonId, enrollment) {
415
+ const module = course.modules.find((item) => item.lessons.some((entry) => entry.id === lessonId));
416
+ return Math.max(module.unlockAt ? Date.parse(module.unlockAt) : 0, Date.parse(enrollment.approvedAt ?? enrollment.enrolledAt) + module.releaseDays * 864e5);
417
+ }
155
418
  function assertEnrollmentAccess(enrollment, now) {
156
419
  invariant(enrollment && (enrollment.status === "active" || enrollment.status === "completed"), "ENROLLMENT_REQUIRED", "An active enrollment is required for this lesson.", 403);
157
420
  invariant(enrollment.accessStatus === "granted", "ACCESS_RESTRICTED", "Your course access is not currently granted.", 403);
@@ -173,8 +436,7 @@ function assertLessonAccess(course, lessonId, enrollment, now, allowPreview = fa
173
436
  ].includes(lesson.kind)) return lesson;
174
437
  assertEnrollmentAccess(enrollment, now);
175
438
  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);
439
+ const releaseAt = moduleReleaseAt(course, lessonId, enrollment);
178
440
  invariant(now.getTime() >= releaseAt, "LESSON_LOCKED", "This module has not been released yet.", 403);
179
441
  if (course.sequential) {
180
442
  const all = lessons(course);
@@ -233,16 +495,18 @@ function parse(schema, input) {
233
495
  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
496
  return result.data;
235
497
  }
236
- function user(actor) {
498
+ function user$1(actor) {
237
499
  invariant(actor, "UNAUTHORIZED", "Sign in to continue.", 401);
238
500
  return actor;
239
501
  }
240
502
  var LmsService = class {
241
503
  stores;
504
+ editions;
242
505
  constructor(ctx, now = () => /* @__PURE__ */ new Date()) {
243
506
  this.ctx = ctx;
244
507
  this.now = now;
245
508
  this.stores = collections(ctx);
509
+ this.editions = new CourseEditions(this.stores, () => this.time(), (lesson) => this.resolveContent(lesson));
246
510
  }
247
511
  time() {
248
512
  return this.now().toISOString();
@@ -251,7 +515,7 @@ var LmsService = class {
251
515
  return settingsSchema.parse(await this.ctx.settings.get("configuration") ?? {});
252
516
  }
253
517
  async saveSettings(actor, input) {
254
- invariant(user(actor).role >= 50, "FORBIDDEN", "Only administrators can change LMS settings.", 403);
518
+ invariant(user$1(actor).role >= 50, "FORBIDDEN", "Only administrators can change LMS settings.", 403);
255
519
  const settings = parse(settingsSchema, input);
256
520
  invariant(!settings.emailNotifications || this.ctx.email, "EMAIL_UNAVAILABLE", "Configure an email transport in EmDash before enabling LMS emails.");
257
521
  await this.ctx.settings.set("configuration", settings);
@@ -295,51 +559,182 @@ var LmsService = class {
295
559
  async enrollmentView(enrollment) {
296
560
  return publicEnrollment(await this.accessEnrollment(enrollment), this.now());
297
561
  }
562
+ async learningState(course, enrollment) {
563
+ course = await this.editions.learning(course, enrollment);
564
+ const resolved = await this.accessEnrollment(enrollment);
565
+ const now = this.now();
566
+ return {
567
+ enrollment: resolved ? publicEnrollment(resolved, now) : null,
568
+ progress: progressSummary(course, resolved, now)
569
+ };
570
+ }
298
571
  async lessonAccess(course, lessonId, enrollment) {
299
- return assertLessonAccess(course, lessonId, await this.accessEnrollment(enrollment), this.now());
572
+ return assertLessonAccess(await this.editions.learning(course, enrollment), lessonId, await this.accessEnrollment(enrollment), this.now());
300
573
  }
301
574
  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);
575
+ const current = user$1(actor);
576
+ 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
577
  return current;
305
578
  }
306
579
  async saveCourse(actor, input) {
307
- const currentActor = user(actor);
580
+ const currentActor = user$1(actor);
308
581
  const parsed = parse(courseSaveSchema, input);
309
582
  const id = parsed.id ?? crypto.randomUUID();
310
- const current = await this.stores.courses.getVersioned(id);
583
+ let current = await this.stores.courses.getVersioned(id);
311
584
  if (current) this.instructor(current.value, currentActor);
312
585
  else invariant(currentActor.role >= 20, "FORBIDDEN", "Course authoring requires a contributor account or higher.", 403);
313
586
  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);
587
+ if (!current && parsed.data.status === "published") invariant(currentActor.role >= 50 || currentActor.role >= 30, "FORBIDDEN", "Publishing requires an author account or higher.", 403);
588
+ 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
589
  invariant(!(await this.stores.courses.query({
317
590
  where: { slug: parsed.data.slug },
318
591
  limit: 1
319
592
  })).items.some((item) => item.id !== id), "SLUG_TAKEN", "A course already uses this URL.", 409);
320
593
  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 = {
594
+ if (current) current = await this.editions.ensure(id, current.revision);
595
+ let course = {
326
596
  ...parsed.data,
327
597
  id,
328
598
  searchTitle: parsed.data.title.toLocaleLowerCase("en"),
329
599
  createdBy: current?.value.createdBy ?? currentActor.id,
330
600
  createdAt: current?.value.createdAt ?? this.time(),
331
601
  updatedAt: this.time(),
332
- schemaVersion: 1
602
+ schemaVersion: 2
333
603
  };
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 });
604
+ const draft = await this.editions.revision(current?.value ?? course, parsed.data, currentActor.id, "draft");
605
+ if (current) {
606
+ course = {
607
+ ...current.value,
608
+ ...current.value.publishedEditionId ? {} : parsed.data,
609
+ status: current.value.status,
610
+ draftRevisionId: draft.id,
611
+ updatedAt: this.time()
612
+ };
613
+ course.instructorIds = parsed.data.instructorIds;
614
+ } else {
615
+ course.draftRevisionId = draft.id;
616
+ if (course.status === "published") {
617
+ const editionId = await this.editions.snapshot(id, parsed.data);
618
+ const edition = await this.stores.course_editions.get(editionId);
619
+ course = {
620
+ ...course,
621
+ ...edition.data,
622
+ publishedEditionId: editionId,
623
+ publishedDraftRevisionId: draft.id
624
+ };
625
+ }
626
+ }
627
+ course.searchTitle = course.title.toLocaleLowerCase("en");
628
+ const saved = await this.editions.commit(current, course);
629
+ await this.audit(currentActor, "course.save", id, { draftRevisionId: draft.id });
630
+ return {
631
+ course: await this.editions.draft(course),
632
+ revision: saved.revision
633
+ };
634
+ }
635
+ async publishCourse(actor, id, expectedRevision) {
636
+ const owner = this.instructor(await this.course(id), actor);
637
+ invariant(owner.role >= 30, "FORBIDDEN", "Publishing requires an author account or higher.", 403);
638
+ const current = await this.editions.ensure(id, expectedRevision);
639
+ const data = parse(courseDataSchema, {
640
+ ...await this.editions.draft(current.value),
641
+ status: "published"
642
+ });
643
+ invariant(data.commerce.accessModel === "free", "PAID_COURSES_DISABLED", "Publish free courses only.", 409);
644
+ await this.assertPrerequisiteGraph(id, data.prerequisiteCourseIds);
645
+ invariant(!(await this.stores.courses.query({
646
+ where: { slug: data.slug },
647
+ limit: 1
648
+ })).items.some((row) => row.id !== id), "SLUG_TAKEN", "A course already uses this URL.", 409);
649
+ const editionId = await this.editions.snapshot(id, data);
650
+ const revision = await this.editions.revision(current.value, data, owner.id, "publish", { editionId });
651
+ const edition = await this.stores.course_editions.get(editionId);
652
+ const next = {
653
+ ...current.value,
654
+ ...edition.data,
655
+ searchTitle: data.title.toLocaleLowerCase("en"),
656
+ draftRevisionId: revision.id,
657
+ publishedDraftRevisionId: revision.id,
658
+ publishedEditionId: editionId,
659
+ updatedAt: this.time()
660
+ };
661
+ const saved = await this.editions.commit(current, next);
662
+ await this.audit(owner, "course.publish", id, { editionId });
338
663
  return {
339
- course,
664
+ course: await this.editions.draft(next),
340
665
  revision: saved.revision
341
666
  };
342
667
  }
668
+ async courseHistory(actor, id, cursor) {
669
+ const course = await this.course(id);
670
+ this.instructor(course, actor);
671
+ return this.editions.history(course, cursor);
672
+ }
673
+ async restoreCourse(actor, id, expectedRevision, revisionId) {
674
+ const owner = this.instructor(await this.course(id), actor);
675
+ const current = await this.editions.ensure(id, expectedRevision);
676
+ invariant((await this.editions.history(current.value, revisionId)).items[0]?.id === revisionId, "NOT_FOUND", "Saved revision not found.", 404);
677
+ const data = {
678
+ ...(await this.stores.course_revisions.get(revisionId)).data,
679
+ instructorIds: current.value.instructorIds
680
+ };
681
+ const revision = await this.editions.revision(current.value, data, owner.id, "restore", { restoredFrom: revisionId });
682
+ const next = {
683
+ ...current.value,
684
+ draftRevisionId: revision.id,
685
+ updatedAt: this.time()
686
+ };
687
+ const saved = await this.editions.commit(current, next);
688
+ await this.audit(owner, "course.restore", id, { revisionId });
689
+ return {
690
+ course: await this.editions.draft(next),
691
+ revision: saved.revision
692
+ };
693
+ }
694
+ async archiveCourse(actor, id, expectedRevision) {
695
+ const owner = this.instructor(await this.course(id), actor);
696
+ invariant(owner.role >= 30, "FORBIDDEN", "Archiving requires an author account or higher.", 403);
697
+ const legacy = await this.stores.courses.getVersioned(id);
698
+ if (legacy?.value.schemaVersion === 1) {
699
+ invariant(legacy.revision === expectedRevision, "CONFLICT", "The course changed. Reload before continuing.", 409);
700
+ const next = {
701
+ ...legacy.value,
702
+ status: "archived",
703
+ updatedAt: this.time()
704
+ };
705
+ const saved = await this.editions.commit(legacy, next);
706
+ await this.audit(owner, "course.archive", id);
707
+ return {
708
+ course: next,
709
+ revision: saved.revision
710
+ };
711
+ }
712
+ const current = await this.editions.ensure(id, expectedRevision);
713
+ const draft = await this.editions.draft(current.value);
714
+ const revision = await this.editions.revision(current.value, draft, owner.id, "archive");
715
+ const next = {
716
+ ...current.value,
717
+ status: "archived",
718
+ draftRevisionId: revision.id,
719
+ updatedAt: this.time()
720
+ };
721
+ const saved = await this.editions.commit(current, next);
722
+ await this.audit(owner, "course.archive", id);
723
+ return {
724
+ course: await this.editions.draft(next),
725
+ revision: saved.revision
726
+ };
727
+ }
728
+ async migrationStatus(actor) {
729
+ invariant(user$1(actor).role >= 50, "FORBIDDEN", "Only administrators can manage migrations.", 403);
730
+ return this.editions.migrationStatus();
731
+ }
732
+ async migrateBatch(actor, limit) {
733
+ await this.migrationStatus(actor);
734
+ const state = await this.editions.migrateBatch(limit);
735
+ if (state.phase === "complete") await this.ctx.kv.set("schemaVersion", 2);
736
+ return state;
737
+ }
343
738
  async assertPrerequisiteGraph(courseId, prerequisites) {
344
739
  const visited = /* @__PURE__ */ new Set();
345
740
  const visit = async (id) => {
@@ -357,12 +752,12 @@ var LmsService = class {
357
752
  invariant(current, "NOT_FOUND", "Course not found.", 404);
358
753
  this.instructor(current.value, actor);
359
754
  return {
360
- course: current.value,
755
+ course: await this.editions.draft(current.value),
361
756
  revision: current.revision
362
757
  };
363
758
  }
364
759
  async adminCourses(actor, options = {}) {
365
- const current = user(actor);
760
+ const current = user$1(actor);
366
761
  const page = await queryPage(this.stores.courses, {
367
762
  where: options.status ? { status: options.status } : {},
368
763
  orderBy: { updatedAt: "desc" },
@@ -371,7 +766,7 @@ var LmsService = class {
371
766
  });
372
767
  return {
373
768
  ...page,
374
- items: page.items.filter((course) => current.role >= 50 || course.createdBy === current.id || course.instructorIds.includes(current.id))
769
+ 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
770
  };
376
771
  }
377
772
  async catalog(input) {
@@ -449,6 +844,13 @@ var LmsService = class {
449
844
  portableText: []
450
845
  };
451
846
  }
847
+ async authorLessonPreview(actor, courseId, lessonId) {
848
+ const { course } = await this.editableCourse(actor, courseId);
849
+ return {
850
+ course: courseDetail(course),
851
+ lesson: publicLesson(await this.resolveContent(findLesson(course, lessonId)))
852
+ };
853
+ }
452
854
  async assertPrerequisites(course, actorId) {
453
855
  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
856
  }
@@ -458,6 +860,7 @@ var LmsService = class {
458
860
  return {
459
861
  id: enrollmentKey(course.id, actor.id),
460
862
  courseId: course.id,
863
+ courseEditionId: course.publishedEditionId ?? course.legacyEditionId,
461
864
  userId: actor.id,
462
865
  learnerName: actor.name || actor.email.split("@")[0] || "Learner",
463
866
  learnerEmail: actor.email,
@@ -501,10 +904,12 @@ var LmsService = class {
501
904
  };
502
905
  }
503
906
  async enroll(actor, courseId, options = {}) {
504
- const current = user(actor);
505
- const course = await this.course(courseId);
907
+ const current = user$1(actor);
908
+ let course = await this.course(courseId);
506
909
  invariant(course.status === "published", "NOT_FOUND", "Course not found.", 404);
507
910
  invariant(course.commerce.accessModel === "free", "PAID_COURSES_DISABLED", "Payment processing is not enabled for this course.", 403);
911
+ course = (await this.editions.ensure(courseId)).value;
912
+ invariant(course.status === "published", "NOT_FOUND", "Course not found.", 404);
508
913
  const cohort = options.cohortId ? await this.stores.cohorts.get(options.cohortId) : null;
509
914
  if (options.cohortId) invariant(cohort && cohort.courseIds.includes(courseId), "NOT_FOUND", "This course is not part of that cohort.", 404);
510
915
  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 +959,11 @@ var LmsService = class {
554
959
  }
555
960
  async adminEnrollment(actor, input) {
556
961
  const parsed = parse(enrollmentAdminSchema, input);
557
- const course = await this.course(parsed.courseId);
962
+ let course = await this.course(parsed.courseId);
558
963
  const currentActor = this.instructor(course, actor);
559
964
  invariant(currentActor.role >= 50, "FORBIDDEN", "Only administrators can make enrollment decisions.", 403);
560
965
  invariant(this.ctx.users, "USERS_UNAVAILABLE", "The user directory is unavailable.", 503);
966
+ if (parsed.status === "active" && !await this.stores.enrollments.get(enrollmentKey(course.id, parsed.userId))) course = (await this.editions.ensure(parsed.courseId)).value;
561
967
  const target = await this.ctx.users.get(parsed.userId);
562
968
  invariant(target, "NOT_FOUND", "Learner account not found.", 404);
563
969
  invariant(!parsed.accessStartsAt || !parsed.accessExpiresAt || Date.parse(parsed.accessExpiresAt) > Date.parse(parsed.accessStartsAt), "INVALID_ACCESS_WINDOW", "Access expiry must follow its start.");
@@ -576,6 +982,7 @@ var LmsService = class {
576
982
  const enrollment = await change(this.stores.enrollments, enrollmentKey(course.id, target.id), async (stored) => {
577
983
  const next = structuredClone(stored ?? this.newEnrollment(course, target, parsed.status));
578
984
  const firstApproval = parsed.status === "active" && !next.approvedAt;
985
+ next.courseEditionId ??= course.legacyEditionId ?? course.publishedEditionId;
579
986
  next.status = parsed.status === "active" && next.completedAt ? "completed" : parsed.status;
580
987
  next.accessStatus = parsed.status === "active" ? "granted" : parsed.status === "suspended" ? "suspended" : "revoked";
581
988
  next.approvedBy = parsed.status === "active" ? currentActor.id : next.approvedBy;
@@ -604,7 +1011,7 @@ var LmsService = class {
604
1011
  }
605
1012
  invariant(!next.accessStartsAt || !next.accessExpiresAt || Date.parse(next.accessExpiresAt) > Date.parse(next.accessStartsAt), "INVALID_ACCESS_WINDOW", "Access expiry must follow its start.");
606
1013
  next.lastActivityAt = this.time();
607
- if (parsed.status === "active") this.finish(course, next, settings);
1014
+ if (parsed.status === "active") this.finish(await this.editions.learning(course, next), next, settings);
608
1015
  return {
609
1016
  value: next,
610
1017
  result: next
@@ -613,22 +1020,21 @@ var LmsService = class {
613
1020
  await this.audit(currentActor, "enrollment.update", enrollment.id, { status: enrollment.status });
614
1021
  await this.notify(enrollment, `enrollment-${enrollment.status}-${enrollment.decisionAt}`, "Enrollment updated", `${course.title}: your enrollment is ${enrollment.status}.`);
615
1022
  await this.projectCompletion(enrollment);
616
- return this.enrollmentView(enrollment);
1023
+ return staffEnrollment(await this.accessEnrollment(enrollment), this.now());
617
1024
  }
618
1025
  async learnerCourse(actor, courseId) {
619
- const current = user(actor);
1026
+ const current = user$1(actor);
620
1027
  const course = await this.course(courseId);
621
1028
  const enrollment = await this.stores.enrollments.get(enrollmentKey(courseId, current.id));
622
1029
  invariant(course.status === "published" || course.status === "archived" && enrollment, "NOT_FOUND", "Course not found.", 404);
623
1030
  if (enrollment?.completedAt) await this.projectCompletion(enrollment);
624
1031
  return {
625
- course: await this.detail(course),
626
- enrollment: enrollment ? await this.enrollmentView(enrollment) : null,
627
- progress: progressSummary(course, enrollment)
1032
+ course: await this.detail(await this.editions.learning(course, enrollment)),
1033
+ ...await this.learningState(course, enrollment)
628
1034
  };
629
1035
  }
630
1036
  async learnerCourseBySlug(actor, slug) {
631
- user(actor);
1037
+ user$1(actor);
632
1038
  const page = await this.stores.courses.query({
633
1039
  where: { slug },
634
1040
  limit: 1
@@ -637,7 +1043,7 @@ var LmsService = class {
637
1043
  return this.learnerCourse(actor, page.items[0].id);
638
1044
  }
639
1045
  async dashboard(actor, options = {}) {
640
- const current = user(actor);
1046
+ const current = user$1(actor);
641
1047
  const page = await queryPage(this.stores.enrollments, {
642
1048
  where: { userId: current.id },
643
1049
  orderBy: { enrolledAt: "desc" },
@@ -649,9 +1055,8 @@ var LmsService = class {
649
1055
  if (enrollment.completedAt) await this.projectCompletion(enrollment);
650
1056
  const course = courses.get(enrollment.courseId);
651
1057
  return course ? {
652
- course: courseCard(course),
653
- enrollment: await this.enrollmentView(enrollment),
654
- progress: progressSummary(course, enrollment)
1058
+ course: courseCard(await this.editions.learning(course, enrollment)),
1059
+ ...await this.learningState(course, enrollment)
655
1060
  } : null;
656
1061
  }))).filter((row) => row !== null);
657
1062
  return {
@@ -660,19 +1065,18 @@ var LmsService = class {
660
1065
  };
661
1066
  }
662
1067
  async learnerLesson(actor, courseId, lessonId) {
663
- const current = user(actor);
1068
+ const current = user$1(actor);
664
1069
  const course = await this.course(courseId);
665
1070
  const enrollment = await this.stores.enrollments.get(enrollmentKey(courseId, current.id));
666
1071
  const lesson = await this.lessonAccess(course, lessonId, enrollment);
667
1072
  return {
668
- course: courseDetail(course),
1073
+ course: courseDetail(await this.editions.learning(course, enrollment)),
669
1074
  lesson: publicLesson(await this.resolveContent(lesson)),
670
- enrollment: await this.enrollmentView(enrollment),
671
- progress: progressSummary(course, enrollment)
1075
+ ...await this.learningState(course, enrollment)
672
1076
  };
673
1077
  }
674
1078
  async startLesson(actor, courseId, lessonId) {
675
- const current = user(actor);
1079
+ const current = user$1(actor);
676
1080
  const course = await this.course(courseId);
677
1081
  const enrollment = await change(this.stores.enrollments, enrollmentKey(courseId, current.id), async (stored) => {
678
1082
  await this.lessonAccess(course, lessonId, stored);
@@ -689,7 +1093,7 @@ var LmsService = class {
689
1093
  return await this.enrollmentView(enrollment);
690
1094
  }
691
1095
  async completeLesson(actor, courseId, lessonId) {
692
- const current = user(actor);
1096
+ const current = user$1(actor);
693
1097
  const course = await this.course(courseId);
694
1098
  const settings = await this.settings();
695
1099
  const enrollment = await change(this.stores.enrollments, enrollmentKey(courseId, current.id), async (stored) => {
@@ -702,17 +1106,14 @@ var LmsService = class {
702
1106
  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
1107
  progress.completedAt ??= this.time();
704
1108
  next.progress[lessonId] = progress;
705
- this.finish(course, next, settings);
1109
+ this.finish(await this.editions.learning(course, next), next, settings);
706
1110
  return {
707
1111
  value: next,
708
1112
  result: next
709
1113
  };
710
1114
  });
711
1115
  await this.projectCompletion(enrollment);
712
- return {
713
- enrollment: await this.enrollmentView(enrollment),
714
- progress: progressSummary(course, enrollment)
715
- };
1116
+ return { ...await this.learningState(course, enrollment) };
716
1117
  }
717
1118
  finish(course, enrollment, settings) {
718
1119
  enrollment.lastActivityAt = this.time();
@@ -743,7 +1144,7 @@ var LmsService = class {
743
1144
  if (enrollment.completedAt) await this.notify(enrollment, "completed", "Course complete", "You have completed your course. Your achievement is saved in My Learning.");
744
1145
  }
745
1146
  async startQuiz(actor, courseId, lessonId) {
746
- const current = user(actor);
1147
+ const current = user$1(actor);
747
1148
  const course = await this.course(courseId);
748
1149
  const result = await change(this.stores.enrollments, enrollmentKey(courseId, current.id), async (stored) => {
749
1150
  const lesson = await this.lessonAccess(course, lessonId, stored);
@@ -790,7 +1191,8 @@ var LmsService = class {
790
1191
  percent: null,
791
1192
  passed: false,
792
1193
  feedback: [],
793
- answers: {}
1194
+ answers: {},
1195
+ draftRevision: 0
794
1196
  };
795
1197
  progress.quizAttempts.push(attempt);
796
1198
  next.lastActivityAt = this.time();
@@ -806,7 +1208,7 @@ var LmsService = class {
806
1208
  return publicAttempt(result.attempt);
807
1209
  }
808
1210
  async submitQuiz(actor, input) {
809
- const current = user(actor);
1211
+ const current = user$1(actor);
810
1212
  const parsed = parse(quizSubmitSchema, input);
811
1213
  const course = await this.course(parsed.courseId);
812
1214
  const settings = await this.settings();
@@ -838,6 +1240,7 @@ var LmsService = class {
838
1240
  };
839
1241
  }
840
1242
  invariant(attempt.quiz, "INVALID_ATTEMPT", "This attempt cannot be graded.", 409);
1243
+ invariant(parsed.expectedDraftRevision === void 0 || parsed.expectedDraftRevision === (attempt.draftRevision ?? 0), "DRAFT_CONFLICT", "Saved answers changed in another request. Review them before submitting.", 409);
841
1244
  const graded = gradeAnswers(attempt.quiz.questions, parsed.answers);
842
1245
  attempt.passed = graded.score * 100 >= graded.maxScore * attempt.quiz.passingPercent;
843
1246
  attempt.feedback = attempt.quiz.showFeedback === "after_submit" || attempt.quiz.showFeedback === "after_pass" && attempt.passed ? graded.feedback : [];
@@ -849,7 +1252,7 @@ var LmsService = class {
849
1252
  attempt.submittedAt = this.time();
850
1253
  attempt.quiz = null;
851
1254
  if (attempt.passed) progress.completedAt ??= this.time();
852
- this.finish(course, next, settings);
1255
+ this.finish(await this.editions.learning(course, next), next, settings);
853
1256
  return {
854
1257
  value: next,
855
1258
  result: {
@@ -863,46 +1266,110 @@ var LmsService = class {
863
1266
  await this.projectCompletion(result.enrollment);
864
1267
  return {
865
1268
  attempt: publicAttempt(result.attempt),
866
- enrollment: await this.enrollmentView(result.enrollment),
867
- progress: progressSummary(course, result.enrollment)
1269
+ ...await this.learningState(course, result.enrollment)
868
1270
  };
869
1271
  }
870
1272
  async saveQuizDraft(actor, input) {
871
- const current = user(actor);
872
- const parsed = parse(quizSubmitSchema, input);
1273
+ const current = user$1(actor);
1274
+ const parsed = parse(quizDraftSchema, input);
873
1275
  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 };
1276
+ return {
1277
+ saved: true,
1278
+ draftRevision: await change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
1279
+ await this.lessonAccess(course, parsed.lessonId, stored);
1280
+ const next = structuredClone(stored);
1281
+ const attempt = next.progress[parsed.lessonId]?.quizAttempts.find((item) => item.id === parsed.attemptId);
1282
+ invariant(attempt?.status === "in_progress" && attempt.quiz, "ATTEMPT_CLOSED", "This quiz attempt is closed.", 409);
1283
+ invariant(!attempt.expiresAt || this.now().getTime() < Date.parse(attempt.expiresAt), "QUIZ_EXPIRED", "This quiz attempt has expired.", 409);
1284
+ gradeAnswers(attempt.quiz.questions, parsed.answers);
1285
+ const revision = attempt.draftRevision ?? 0;
1286
+ const canonical = (answers) => JSON.stringify(Object.entries(answers).sort(([a], [b]) => a.localeCompare(b)));
1287
+ if (revision === parsed.expectedDraftRevision + 1 && canonical(attempt.answers) === canonical(parsed.answers)) return {
1288
+ value: next,
1289
+ result: revision
1290
+ };
1291
+ invariant(revision === parsed.expectedDraftRevision, "DRAFT_CONFLICT", "Saved answers changed in another request. Review them before saving.", 409);
1292
+ attempt.answers = parsed.answers;
1293
+ attempt.draftRevision = revision + 1;
1294
+ return {
1295
+ value: next,
1296
+ result: attempt.draftRevision
1297
+ };
1298
+ })
1299
+ };
1300
+ }
1301
+ async assignmentState(actor, courseId, lessonId) {
1302
+ const current = user$1(actor);
1303
+ const course = await this.course(courseId);
1304
+ const enrollment = await this.stores.enrollments.get(enrollmentKey(courseId, current.id));
1305
+ const lesson = await this.lessonAccess(course, lessonId, enrollment);
1306
+ invariant(lesson.kind === "assignment" && lesson.assignment, "NOT_AN_ASSIGNMENT", "This lesson has no assignment.");
1307
+ const progress = enrollment.progress[lessonId] ?? emptyProgress();
1308
+ const blocked = assignmentBlock(lesson.assignment, progress, this.now());
1309
+ return {
1310
+ draft: progress.assignmentDraft ?? emptyAssignmentDraft(),
1311
+ submission: progress.assignments.at(-1) ?? null,
1312
+ canSubmit: !blocked,
1313
+ closedReason: blocked?.message ?? null
1314
+ };
1315
+ }
1316
+ async saveAssignmentDraft(actor, input) {
1317
+ const current = user$1(actor);
1318
+ const parsed = parse(assignmentDraftSchema, input);
1319
+ const course = await this.course(parsed.courseId);
1320
+ return {
1321
+ saved: true,
1322
+ draftRevision: await change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
1323
+ const lesson = await this.lessonAccess(course, parsed.lessonId, stored);
1324
+ invariant(lesson.kind === "assignment" && lesson.assignment, "NOT_AN_ASSIGNMENT", "This lesson has no assignment.");
1325
+ const next = structuredClone(stored);
1326
+ const progress = next.progress[parsed.lessonId] ?? emptyProgress();
1327
+ const draft = progress.assignmentDraft ?? emptyAssignmentDraft();
1328
+ const blocked = assignmentBlock(lesson.assignment, progress, this.now());
1329
+ if (blocked) throw new LmsError(blocked.code, blocked.message, blocked.status);
1330
+ if (draft.revision === parsed.expectedDraftRevision + 1 && draft.body === parsed.body && draft.attachmentUrl === parsed.attachmentUrl) return {
1331
+ value: next,
1332
+ result: draft.revision
1333
+ };
1334
+ invariant(draft.revision === parsed.expectedDraftRevision, "DRAFT_CONFLICT", "Your assignment draft changed in another request. Review it before saving.", 409);
1335
+ progress.assignmentDraft = {
1336
+ body: parsed.body,
1337
+ attachmentUrl: parsed.attachmentUrl,
1338
+ revision: draft.revision + 1,
1339
+ updatedAt: this.time()
1340
+ };
1341
+ next.progress[parsed.lessonId] = progress;
1342
+ return {
1343
+ value: next,
1344
+ result: progress.assignmentDraft.revision
1345
+ };
1346
+ })
1347
+ };
888
1348
  }
889
1349
  async submitAssignment(actor, input) {
890
- const current = user(actor);
1350
+ const current = user$1(actor);
891
1351
  const parsed = parse(assignmentSubmitSchema, input);
892
1352
  const course = await this.course(parsed.courseId);
893
1353
  const enrollment = await change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
894
1354
  const lesson = await this.lessonAccess(course, parsed.lessonId, stored);
895
1355
  invariant(lesson.kind === "assignment" && lesson.assignment, "NOT_AN_ASSIGNMENT", "This lesson has no assignment.");
896
- invariant(!lesson.assignment.dueAt || this.now().getTime() <= Date.parse(lesson.assignment.dueAt), "ASSIGNMENT_CLOSED", "The assignment deadline has passed.", 403);
897
1356
  const next = structuredClone(stored);
898
1357
  const progress = next.progress[parsed.lessonId] ?? emptyProgress();
899
- const last = progress.assignments.at(-1);
900
- invariant(!progress.completedAt, "ASSIGNMENT_PASSED", "This assignment has already been passed.", 409);
901
- invariant(!last || last.status === "graded" && lesson.assignment.allowResubmission, "SUBMISSION_PENDING", "Wait for grading before submitting again.", 409);
902
- invariant(progress.assignments.length < 10, "SUBMISSION_LIMIT", "The assignment submission limit has been reached.", 403);
1358
+ const previous = parsed.submissionId && progress.assignments.find((item) => item.id === parsed.submissionId);
1359
+ if (previous) {
1360
+ invariant(previous.body === parsed.body && previous.attachmentUrl === parsed.attachmentUrl, "SUBMISSION_CONFLICT", "This submission ID already belongs to different work.", 409);
1361
+ return {
1362
+ value: next,
1363
+ result: next
1364
+ };
1365
+ }
1366
+ const blocked = assignmentBlock(lesson.assignment, progress, this.now());
1367
+ if (blocked) throw new LmsError(blocked.code, blocked.message, blocked.status);
1368
+ const draft = progress.assignmentDraft ?? emptyAssignmentDraft();
1369
+ invariant(parsed.expectedDraftRevision === void 0 ? !draft.body && !draft.attachmentUrl : draft.revision === parsed.expectedDraftRevision, "DRAFT_CONFLICT", "Your assignment draft changed. Review it before submitting.", 409);
903
1370
  progress.startedAt ??= this.time();
904
1371
  progress.assignments.push({
905
- id: crypto.randomUUID(),
1372
+ id: parsed.submissionId ?? crypto.randomUUID(),
906
1373
  body: parsed.body,
907
1374
  attachmentUrl: parsed.attachmentUrl,
908
1375
  submittedAt: this.time(),
@@ -912,8 +1379,16 @@ var LmsService = class {
912
1379
  points: null,
913
1380
  feedback: "",
914
1381
  gradedAt: null,
915
- gradedBy: null
1382
+ gradedBy: null,
1383
+ rubric: structuredClone(lesson.assignment.rubric ?? []),
1384
+ rubricScores: []
916
1385
  });
1386
+ progress.assignmentDraft = {
1387
+ body: "",
1388
+ attachmentUrl: null,
1389
+ revision: draft.revision + 1,
1390
+ updatedAt: this.time()
1391
+ };
917
1392
  next.progress[parsed.lessonId] = progress;
918
1393
  next.lastActivityAt = this.time();
919
1394
  return {
@@ -935,19 +1410,20 @@ var LmsService = class {
935
1410
  const submission = progress?.assignments.find((item) => item.id === parsed.submissionId);
936
1411
  invariant(submission && progress, "NOT_FOUND", "Assignment submission not found.", 404);
937
1412
  invariant(submission.status === "submitted", "ALREADY_GRADED", "This submission has already been graded.", 409);
938
- invariant(parsed.points <= submission.maxPoints, "INVALID_GRADE", "The grade cannot exceed the assignment's maximum points.");
939
- submission.points = parsed.points;
1413
+ const grade = assignmentGrade(submission, parsed.points, parsed.rubricScores);
1414
+ submission.points = grade.points;
1415
+ submission.rubricScores = grade.rubricScores;
940
1416
  submission.feedback = parsed.feedback;
941
1417
  submission.status = "graded";
942
1418
  submission.gradedAt = this.time();
943
1419
  submission.gradedBy = current.id;
944
- if (parsed.points >= submission.passingPoints) progress.completedAt ??= this.time();
1420
+ if (grade.points >= submission.passingPoints) progress.completedAt ??= this.time();
945
1421
  const access = effectiveAccess(await this.accessEnrollment(next), this.now());
946
1422
  if (["active", "completed"].includes(next.status) && ![
947
1423
  "suspended",
948
1424
  "revoked",
949
1425
  "pending"
950
- ].includes(access)) this.finish(course, next, settings);
1426
+ ].includes(access)) this.finish(await this.editions.learning(course, next), next, settings);
951
1427
  return {
952
1428
  value: next,
953
1429
  result: next
@@ -957,35 +1433,47 @@ var LmsService = class {
957
1433
  await this.notify(enrollment, `graded-${parsed.submissionId}`, "Assignment graded", "Your assignment feedback is ready in My Learning.");
958
1434
  await this.audit(current, "assignment.grade", enrollment.id, {
959
1435
  lessonId: parsed.lessonId,
960
- points: parsed.points
1436
+ submissionId: parsed.submissionId,
1437
+ points: enrollment.progress[parsed.lessonId].assignments.find((item) => item.id === parsed.submissionId).points
961
1438
  });
962
- return await this.enrollmentView(enrollment);
1439
+ return staffEnrollment(await this.accessEnrollment(enrollment), this.now());
963
1440
  }
964
1441
  async saveNote(actor, input) {
965
- const current = user(actor);
1442
+ const current = user$1(actor);
966
1443
  const parsed = parse(noteSchema, input);
967
1444
  const course = await this.course(parsed.courseId);
968
- return { note: (await change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
1445
+ const enrollment = await change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
969
1446
  await this.lessonAccess(course, parsed.lessonId, stored);
970
1447
  const next = structuredClone(stored);
971
1448
  const progress = next.progress[parsed.lessonId] ?? emptyProgress();
1449
+ const revision = progress.noteRevision ?? 0;
1450
+ if (revision === parsed.expectedRevision + 1 && progress.note === parsed.note) return {
1451
+ value: next,
1452
+ result: next
1453
+ };
1454
+ invariant(revision === parsed.expectedRevision, "NOTE_CONFLICT", "Your lesson notes changed in another tab. Copy your work before reloading to compare it with the saved notes.", 409);
972
1455
  progress.note = parsed.note;
1456
+ progress.noteRevision = revision + 1;
973
1457
  next.progress[parsed.lessonId] = progress;
974
1458
  return {
975
1459
  value: next,
976
1460
  result: next
977
1461
  };
978
- })).progress[parsed.lessonId].note };
1462
+ });
1463
+ return {
1464
+ note: enrollment.progress[parsed.lessonId].note,
1465
+ revision: enrollment.progress[parsed.lessonId].noteRevision ?? 0
1466
+ };
979
1467
  }
980
1468
  async certificate(actor, courseId) {
981
- const current = user(actor);
1469
+ const current = user$1(actor);
982
1470
  const enrollment = await this.stores.enrollments.get(enrollmentKey(courseId, current.id));
983
1471
  invariant(enrollment?.certificate, "NOT_FOUND", "Certificate not found.", 404);
984
1472
  await this.projectCompletion(enrollment);
985
1473
  return enrollment.certificate;
986
1474
  }
987
1475
  async ownedCertificate(actor, id) {
988
- const current = user(actor);
1476
+ const current = user$1(actor);
989
1477
  const index = await this.stores.certificates.get(id);
990
1478
  invariant(index?.userId === current.id, "NOT_FOUND", "Certificate not found.", 404);
991
1479
  const enrollment = await this.stores.enrollments.get(index.enrollmentId);
@@ -1008,7 +1496,7 @@ var LmsService = class {
1008
1496
  };
1009
1497
  }
1010
1498
  async shareCertificate(actor, courseId, shared) {
1011
- const current = user(actor);
1499
+ const current = user$1(actor);
1012
1500
  return await change(this.stores.enrollments, enrollmentKey(courseId, current.id), async (stored) => {
1013
1501
  invariant(stored?.certificate, "NOT_FOUND", "Certificate not found.", 404);
1014
1502
  const next = structuredClone(stored);
@@ -1036,7 +1524,7 @@ var LmsService = class {
1036
1524
  return certificate;
1037
1525
  }
1038
1526
  async cancelEnrollment(actor, courseId) {
1039
- const current = user(actor);
1527
+ const current = user$1(actor);
1040
1528
  const enrollment = await change(this.stores.enrollments, enrollmentKey(courseId, current.id), async (stored) => {
1041
1529
  invariant(stored, "NOT_FOUND", "Enrollment not found.", 404);
1042
1530
  const next = {
@@ -1052,7 +1540,7 @@ var LmsService = class {
1052
1540
  return await this.enrollmentView(enrollment);
1053
1541
  }
1054
1542
  async discussions(actor, courseId, lessonId, cursor) {
1055
- const current = user(actor);
1543
+ const current = user$1(actor);
1056
1544
  const course = await this.course(courseId);
1057
1545
  const enrollment = await this.stores.enrollments.get(enrollmentKey(courseId, current.id));
1058
1546
  await this.lessonAccess(course, lessonId, enrollment);
@@ -1081,7 +1569,7 @@ var LmsService = class {
1081
1569
  };
1082
1570
  }
1083
1571
  async postDiscussion(actor, input) {
1084
- const current = user(actor);
1572
+ const current = user$1(actor);
1085
1573
  const parsed = parse(discussionSchema, input);
1086
1574
  const course = await this.course(parsed.courseId);
1087
1575
  const enrollment = await this.stores.enrollments.get(enrollmentKey(course.id, current.id));
@@ -1117,7 +1605,7 @@ var LmsService = class {
1117
1605
  });
1118
1606
  }
1119
1607
  async postReview(actor, input) {
1120
- const current = user(actor);
1608
+ const current = user$1(actor);
1121
1609
  const parsed = parse(reviewSchema, input);
1122
1610
  const course = await this.course(parsed.courseId);
1123
1611
  const enrollment = await this.accessEnrollment(await this.stores.enrollments.get(enrollmentKey(course.id, current.id)));
@@ -1159,7 +1647,7 @@ var LmsService = class {
1159
1647
  return next;
1160
1648
  }
1161
1649
  async communityQueue(actor, collection, options = {}) {
1162
- const current = user(actor);
1650
+ const current = user$1(actor);
1163
1651
  if (options.courseId) this.instructor(await this.course(options.courseId), current);
1164
1652
  else invariant(current.role >= 50, "FORBIDDEN", "Select a course you teach.", 403);
1165
1653
  const store = this.stores[collection];
@@ -1182,7 +1670,7 @@ var LmsService = class {
1182
1670
  };
1183
1671
  }
1184
1672
  async enrollments(actor, options = {}) {
1185
- const current = user(actor);
1673
+ const current = user$1(actor);
1186
1674
  if (options.courseId) this.instructor(await this.course(options.courseId), current);
1187
1675
  else invariant(current.role >= 50, "FORBIDDEN", "Select a course you teach.", 403);
1188
1676
  const page = await queryPage(this.stores.enrollments, {
@@ -1197,17 +1685,23 @@ var LmsService = class {
1197
1685
  const courses = await this.stores.courses.getMany(page.items.map((item) => item.courseId));
1198
1686
  return {
1199
1687
  ...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
- })))
1688
+ items: await Promise.all(page.items.map(async (enrollment) => {
1689
+ const resolved = await this.accessEnrollment(enrollment);
1690
+ const root = courses.get(enrollment.courseId);
1691
+ const course = root ? await this.editions.learning(root, enrollment) : null;
1692
+ const now = this.now();
1693
+ return {
1694
+ ...staffEnrollment(resolved, now),
1695
+ learnerEmail: enrollment.learnerEmail,
1696
+ courseTitle: course?.title ?? "Archived course",
1697
+ lessonTitles: Object.fromEntries((course ? lessons(course) : []).map((lesson) => [lesson.id, lesson.title])),
1698
+ progressSummary: course ? progressSummary(course, resolved, now) : null
1699
+ };
1700
+ }))
1207
1701
  };
1208
1702
  }
1209
1703
  async report(actor, courseId) {
1210
- const current = user(actor);
1704
+ const current = user$1(actor);
1211
1705
  if (courseId) this.instructor(await this.course(courseId), current);
1212
1706
  else invariant(current.role >= 50, "FORBIDDEN", "Select a course you teach.", 403);
1213
1707
  const filter = courseId ? { courseId } : {};
@@ -1279,7 +1773,7 @@ var LmsService = class {
1279
1773
  return "" + lines.join("\r\n");
1280
1774
  }
1281
1775
  async cohortList(actor, cursor) {
1282
- invariant(user(actor).role >= 50, "FORBIDDEN", "Only administrators can manage cohorts.", 403);
1776
+ invariant(user$1(actor).role >= 50, "FORBIDDEN", "Only administrators can manage cohorts.", 403);
1283
1777
  const page = await this.stores.cohorts.query({
1284
1778
  orderBy: { createdAt: "desc" },
1285
1779
  limit: 24,
@@ -1307,7 +1801,7 @@ var LmsService = class {
1307
1801
  };
1308
1802
  }
1309
1803
  async requestCohort(actor, cohortId) {
1310
- const current = user(actor);
1804
+ const current = user$1(actor);
1311
1805
  const cohort = await this.stores.cohorts.get(cohortId);
1312
1806
  invariant(cohort, "NOT_FOUND", "Cohort not found.", 404);
1313
1807
  invariant(!cohort.endsAt || this.now().getTime() < Date.parse(cohort.endsAt), "COHORT_CLOSED", "This cohort has ended.", 403);
@@ -1337,7 +1831,7 @@ var LmsService = class {
1337
1831
  });
1338
1832
  }
1339
1833
  async cohortMemberships(actor, options = {}) {
1340
- invariant(user(actor).role >= 50, "FORBIDDEN", "Only administrators can review cohort membership.", 403);
1834
+ invariant(user$1(actor).role >= 50, "FORBIDDEN", "Only administrators can review cohort membership.", 403);
1341
1835
  return queryPage(this.stores.cohort_enrollments, {
1342
1836
  where: {
1343
1837
  ...options.cohortId ? { cohortId: options.cohortId } : {},
@@ -1349,7 +1843,7 @@ var LmsService = class {
1349
1843
  });
1350
1844
  }
1351
1845
  async decideCohort(actor, cohortId, userId, status, reason = "") {
1352
- const current = user(actor);
1846
+ const current = user$1(actor);
1353
1847
  invariant(current.role >= 50, "FORBIDDEN", "Only administrators can make cohort decisions.", 403);
1354
1848
  const cohort = await this.stores.cohorts.get(cohortId);
1355
1849
  invariant(cohort && this.ctx.users, "NOT_FOUND", "Cohort not found.", 404);
@@ -1421,7 +1915,7 @@ var LmsService = class {
1421
1915
  };
1422
1916
  }
1423
1917
  async saveCohort(actor, input) {
1424
- const current = user(actor);
1918
+ const current = user$1(actor);
1425
1919
  invariant(current.role >= 50, "FORBIDDEN", "Only administrators can manage cohorts.", 403);
1426
1920
  const parsed = parse(cohortSaveSchema, input);
1427
1921
  for (const id of parsed.data.courseIds) await this.course(id);
@@ -1445,7 +1939,7 @@ var LmsService = class {
1445
1939
  };
1446
1940
  }
1447
1941
  async applyCohort(actor, id, expectedRevision, offset) {
1448
- const current = user(actor);
1942
+ const current = user$1(actor);
1449
1943
  invariant(current.role >= 50, "FORBIDDEN", "Only administrators can enroll cohorts.", 403);
1450
1944
  const stored = await this.stores.cohorts.getVersioned(id);
1451
1945
  invariant(stored, "NOT_FOUND", "Cohort not found.", 404);
@@ -1476,6 +1970,7 @@ var LmsService = class {
1476
1970
  }
1477
1971
  async notify(enrollment, key, title, message) {
1478
1972
  const settings = await this.settings();
1973
+ const preferences = await this.stores.preferences.get(enrollment.userId);
1479
1974
  const id = `${enrollment.id}:${key}`;
1480
1975
  const notification = {
1481
1976
  id,
@@ -1486,14 +1981,14 @@ var LmsService = class {
1486
1981
  courseId: enrollment.courseId,
1487
1982
  read: false,
1488
1983
  createdAt: this.time(),
1489
- emailStatus: settings.emailNotifications ? "pending" : "disabled",
1984
+ emailStatus: settings.emailNotifications && preferences?.emailNotifications !== false ? "pending" : "disabled",
1490
1985
  emailAttempts: 0,
1491
1986
  claimedAt: null
1492
1987
  };
1493
1988
  await putOnce(this.stores.notifications, id, notification);
1494
1989
  }
1495
1990
  async notifications(actor, cursor) {
1496
- const current = user(actor);
1991
+ const current = user$1(actor);
1497
1992
  const page = await queryPage(this.stores.notifications, {
1498
1993
  where: { userId: current.id },
1499
1994
  orderBy: { createdAt: "desc" },
@@ -1506,7 +2001,7 @@ var LmsService = class {
1506
2001
  };
1507
2002
  }
1508
2003
  async readNotification(actor, id) {
1509
- const current = user(actor);
2004
+ const current = user$1(actor);
1510
2005
  await change(this.stores.notifications, id, async (stored) => {
1511
2006
  invariant(stored && stored.userId === current.id, "NOT_FOUND", "Notification not found.", 404);
1512
2007
  return {
@@ -1561,6 +2056,17 @@ var LmsService = class {
1561
2056
  delta: { emailAttempts: { inc: 1 } }
1562
2057
  })).applied) continue;
1563
2058
  try {
2059
+ if ((await this.stores.preferences.get(item.data.userId))?.emailNotifications === false) {
2060
+ await this.stores.notifications.updateIf(item.id, {
2061
+ where: {
2062
+ emailStatus: "sending",
2063
+ emailAttempts,
2064
+ claimedAt
2065
+ },
2066
+ set: { emailStatus: "disabled" }
2067
+ });
2068
+ continue;
2069
+ }
1564
2070
  await this.ctx.email.send({
1565
2071
  to: item.data.email,
1566
2072
  subject: item.data.title,
@@ -1611,11 +2117,13 @@ var LmsService = class {
1611
2117
  }
1612
2118
  async exportCourse(actor, id) {
1613
2119
  const { course } = await this.editableCourse(actor, id);
1614
- const { id: _id, searchTitle: _search, createdBy: _creator, createdAt: _created, updatedAt: _updated, schemaVersion: _version, ...data } = course;
1615
2120
  return {
1616
2121
  format: "anantagnanam-lms-course",
1617
2122
  version: 1,
1618
- course: data
2123
+ course: courseDataSchema.parse({
2124
+ ...course,
2125
+ status: "draft"
2126
+ })
1619
2127
  };
1620
2128
  }
1621
2129
  async importCourse(actor, input) {
@@ -1642,7 +2150,7 @@ var LmsService = class {
1642
2150
  return { deleted: true };
1643
2151
  }
1644
2152
  async retryNotification(actor, id) {
1645
- invariant(user(actor).role >= 50, "FORBIDDEN", "Only administrators can retry email delivery.", 403);
2153
+ invariant(user$1(actor).role >= 50, "FORBIDDEN", "Only administrators can retry email delivery.", 403);
1646
2154
  invariant((await this.stores.notifications.updateIf(id, {
1647
2155
  where: {
1648
2156
  emailStatus: "failed",
@@ -1657,7 +2165,7 @@ var LmsService = class {
1657
2165
  return { queued: true };
1658
2166
  }
1659
2167
  async notificationQueue(actor, options = {}) {
1660
- invariant(user(actor).role >= 50, "FORBIDDEN", "Administrator access required.", 403);
2168
+ invariant(user$1(actor).role >= 50, "FORBIDDEN", "Administrator access required.", 403);
1661
2169
  return queryPage(this.stores.notifications, {
1662
2170
  where: options.status ? { emailStatus: options.status } : {},
1663
2171
  orderBy: { createdAt: "desc" },
@@ -1666,7 +2174,7 @@ var LmsService = class {
1666
2174
  });
1667
2175
  }
1668
2176
  async auditList(actor, options = {}) {
1669
- invariant(user(actor).role >= 50, "FORBIDDEN", "Administrator access required.", 403);
2177
+ invariant(user$1(actor).role >= 50, "FORBIDDEN", "Administrator access required.", 403);
1670
2178
  return queryPage(this.stores.audit, {
1671
2179
  orderBy: { createdAt: "desc" },
1672
2180
  limit: options.limit ?? 30,
@@ -1696,6 +2204,477 @@ var LmsService = class {
1696
2204
  }
1697
2205
  };
1698
2206
 
2207
+ //#endregion
2208
+ //#region src/learning.ts
2209
+ const exportSchema = pageSchema.extend({ section: z.enum([
2210
+ "enrollments",
2211
+ "discussions",
2212
+ "reviews",
2213
+ "notifications",
2214
+ "saved_courses",
2215
+ "path_enrollments",
2216
+ "cohort_enrollments"
2217
+ ]) });
2218
+ function user(actor) {
2219
+ invariant(actor, "UNAUTHORIZED", "Sign in to continue.", 401);
2220
+ return actor;
2221
+ }
2222
+ function admin(actor) {
2223
+ const current = user(actor);
2224
+ invariant(current.role >= 50, "FORBIDDEN", "Administrator access required.", 403);
2225
+ return current;
2226
+ }
2227
+ function pathPage(input) {
2228
+ const options = parse(pageSchema, input);
2229
+ return {
2230
+ ...options,
2231
+ limit: Math.min(options.limit, 8)
2232
+ };
2233
+ }
2234
+ /** Optional learning features share the existing access and immutable-edition service. */
2235
+ var LearningFeatures = class {
2236
+ constructor(service) {
2237
+ this.service = service;
2238
+ }
2239
+ get stores() {
2240
+ return this.service.stores;
2241
+ }
2242
+ async preferences(actor) {
2243
+ const stored = await this.stores.preferences.getVersioned(user(actor).id);
2244
+ return {
2245
+ value: preferencesSchema.parse(stored?.value ?? {}),
2246
+ revision: stored?.revision ?? null
2247
+ };
2248
+ }
2249
+ async savePreferences(actor, input) {
2250
+ const current = user(actor);
2251
+ const parsed = parse(z.object({
2252
+ data: preferencesSchema,
2253
+ expectedRevision: z.string().max(128).nullable()
2254
+ }), input);
2255
+ const saved = await this.stores.preferences.compareAndSet(current.id, parsed.expectedRevision, parsed.data);
2256
+ invariant(saved.applied, "CONFLICT", "Preferences changed in another tab. Reload before saving.", 409);
2257
+ return {
2258
+ value: parsed.data,
2259
+ revision: saved.revision
2260
+ };
2261
+ }
2262
+ async saveCourse(actor, input) {
2263
+ const current = user(actor);
2264
+ const parsed = parse(courseIdSchema.extend({ saved: z.boolean() }), input);
2265
+ if (parsed.saved) invariant((await this.service.course(parsed.courseId)).status === "published", "NOT_FOUND", "Course not found.", 404);
2266
+ await change(this.stores.saved_courses, enrollmentKey(parsed.courseId, current.id), () => ({
2267
+ value: {
2268
+ userId: current.id,
2269
+ courseId: parsed.courseId,
2270
+ saved: parsed.saved,
2271
+ updatedAt: this.service.time()
2272
+ },
2273
+ result: true
2274
+ }));
2275
+ return { saved: parsed.saved };
2276
+ }
2277
+ async savedCourses(actor, input) {
2278
+ const current = user(actor);
2279
+ const options = parse(pageSchema, input);
2280
+ const page = await queryPage(this.stores.saved_courses, {
2281
+ ...options,
2282
+ where: {
2283
+ userId: current.id,
2284
+ saved: true
2285
+ },
2286
+ orderBy: { updatedAt: "desc" }
2287
+ });
2288
+ return {
2289
+ ...page,
2290
+ items: await Promise.all(page.items.map(async (item) => ({
2291
+ courseId: item.courseId,
2292
+ course: await this.card(item.courseId)
2293
+ })))
2294
+ };
2295
+ }
2296
+ async card(courseId) {
2297
+ const course = await this.stores.courses.get(courseId);
2298
+ return course?.status === "published" ? courseCard(course) : null;
2299
+ }
2300
+ async savePath(actor, input) {
2301
+ const current = admin(actor);
2302
+ const parsed = parse(pathSaveSchema, input);
2303
+ const id = parsed.id ?? crypto.randomUUID();
2304
+ const existing = await this.stores.learning_paths.getVersioned(id);
2305
+ invariant(!parsed.id || existing, "NOT_FOUND", "Learning path not found.", 404);
2306
+ invariant((existing?.revision ?? null) === parsed.expectedRevision, "CONFLICT", "This path changed. Reload before saving.", 409);
2307
+ for (const courseId of parsed.data.courseIds) {
2308
+ const course = await this.service.course(courseId);
2309
+ invariant(parsed.data.status !== "published" || course.status === "published", "VALIDATION_ERROR", "Publish all courses before publishing the path.");
2310
+ }
2311
+ const path = {
2312
+ ...parsed.data,
2313
+ id,
2314
+ createdAt: existing?.value.createdAt ?? this.service.time(),
2315
+ updatedAt: this.service.time()
2316
+ };
2317
+ assertSize(path);
2318
+ const saved = await this.stores.learning_paths.compareAndSet(id, parsed.expectedRevision, path);
2319
+ invariant(saved.applied, "CONFLICT", "This path changed. Reload before saving.", 409);
2320
+ await this.service.audit(current, "path.save", id);
2321
+ return {
2322
+ path,
2323
+ revision: saved.revision
2324
+ };
2325
+ }
2326
+ async adminPaths(actor, input) {
2327
+ admin(actor);
2328
+ const page = await this.stores.learning_paths.query({
2329
+ ...parse(pageSchema, input),
2330
+ orderBy: { updatedAt: "desc" }
2331
+ });
2332
+ const versions = await Promise.all(page.items.map((item) => this.stores.learning_paths.getVersioned(item.id)));
2333
+ return {
2334
+ ...page,
2335
+ items: versions.flatMap((item) => item ? [{
2336
+ ...item.value,
2337
+ revision: item.revision
2338
+ }] : [])
2339
+ };
2340
+ }
2341
+ async paths(input) {
2342
+ const page = await queryPage(this.stores.learning_paths, {
2343
+ ...pathPage(input),
2344
+ where: { status: "published" },
2345
+ orderBy: { updatedAt: "desc" }
2346
+ });
2347
+ const items = await Promise.all(page.items.map(async (path) => ({
2348
+ id: path.id,
2349
+ title: path.title,
2350
+ description: path.description,
2351
+ courses: await Promise.all(path.courseIds.map((id) => this.card(id)))
2352
+ })));
2353
+ return {
2354
+ ...page,
2355
+ items
2356
+ };
2357
+ }
2358
+ async joinPath(actor, input) {
2359
+ const current = user(actor);
2360
+ const { pathId } = parse(z.object({ pathId: idSchema }), input);
2361
+ const id = enrollmentKey(pathId, current.id);
2362
+ if (await this.stores.path_enrollments.get(id)) return { joined: true };
2363
+ const path = await this.stores.learning_paths.get(pathId);
2364
+ invariant(path?.status === "published", "NOT_FOUND", "Learning path not found.", 404);
2365
+ await putOnce(this.stores.path_enrollments, id, {
2366
+ pathId,
2367
+ userId: current.id,
2368
+ title: path.title,
2369
+ courseIds: path.courseIds,
2370
+ joinedAt: this.service.time()
2371
+ });
2372
+ return { joined: true };
2373
+ }
2374
+ async learnerPaths(actor, input) {
2375
+ const current = user(actor);
2376
+ const page = await queryPage(this.stores.path_enrollments, {
2377
+ ...pathPage(input),
2378
+ where: { userId: current.id },
2379
+ orderBy: { joinedAt: "desc" }
2380
+ });
2381
+ const items = await Promise.all(page.items.map(async (path) => {
2382
+ const courses = await Promise.all(path.courseIds.map(async (courseId) => {
2383
+ const enrollment = await this.stores.enrollments.get(enrollmentKey(courseId, current.id));
2384
+ const course = await this.stores.courses.get(courseId);
2385
+ return {
2386
+ course: course && enrollment ? courseCard(await this.service.editions.learning(course, enrollment)) : await this.card(courseId),
2387
+ completed: Boolean(enrollment?.completedAt)
2388
+ };
2389
+ }));
2390
+ return {
2391
+ pathId: path.pathId,
2392
+ title: path.title,
2393
+ joinedAt: path.joinedAt,
2394
+ total: courses.length,
2395
+ completed: courses.filter((c) => c.completed).length,
2396
+ courses
2397
+ };
2398
+ }));
2399
+ return {
2400
+ ...page,
2401
+ items
2402
+ };
2403
+ }
2404
+ async playback(actor, input) {
2405
+ const current = user(actor);
2406
+ const parsed = parse(playbackSchema, input);
2407
+ const course = await this.service.course(parsed.courseId);
2408
+ return change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
2409
+ const lesson = await this.service.lessonAccess(course, parsed.lessonId, stored);
2410
+ invariant(["audio", "video"].includes(lesson.kind), "VALIDATION_ERROR", "Only audio and video lessons store playback positions.");
2411
+ const next = structuredClone(stored);
2412
+ const progress = next.progress[lesson.id] ?? emptyProgress();
2413
+ invariant((progress.playback?.revision ?? 0) === parsed.expectedRevision, "CONFLICT", "Playback changed in another tab. Reload to resume saving.", 409);
2414
+ const playback = {
2415
+ positionSeconds: parsed.positionSeconds,
2416
+ playbackRate: parsed.playbackRate,
2417
+ revision: parsed.expectedRevision + 1,
2418
+ updatedAt: this.service.time()
2419
+ };
2420
+ progress.playback = playback;
2421
+ next.progress[lesson.id] = progress;
2422
+ next.lastActivityAt = this.service.time();
2423
+ return {
2424
+ value: next,
2425
+ result: playback
2426
+ };
2427
+ });
2428
+ }
2429
+ async exportData(actor, input) {
2430
+ const current = user(actor);
2431
+ const parsed = parse(exportSchema, input);
2432
+ const { section, cursor } = parsed;
2433
+ const options = {
2434
+ where: section === "discussions" ? { authorId: current.id } : { userId: current.id },
2435
+ cursor,
2436
+ limit: Math.min(parsed.limit, 5)
2437
+ };
2438
+ let page;
2439
+ if (section === "enrollments") {
2440
+ const raw = await queryPage(this.stores.enrollments, options);
2441
+ page = {
2442
+ ...raw,
2443
+ items: await Promise.all(raw.items.map((enrollment) => this.service.enrollmentView(enrollment)))
2444
+ };
2445
+ } else {
2446
+ const raw = await this.stores[section].query(options);
2447
+ page = {
2448
+ ...raw,
2449
+ items: raw.items.map((item) => item.data)
2450
+ };
2451
+ }
2452
+ return {
2453
+ format: "anantagnanam-lms-learner",
2454
+ version: 1,
2455
+ exportedAt: this.service.time(),
2456
+ section,
2457
+ profile: {
2458
+ id: current.id,
2459
+ name: current.name,
2460
+ email: current.email
2461
+ },
2462
+ preferences: (await this.preferences(current)).value,
2463
+ ...page
2464
+ };
2465
+ }
2466
+ async health(actor) {
2467
+ admin(actor);
2468
+ const [maintenance, pendingEmails, failedEmails, pending, settings, migration] = await Promise.all([
2469
+ this.service.ctx.kv.get("maintenanceHealth"),
2470
+ this.stores.notifications.count({ emailStatus: "pending" }),
2471
+ this.stores.notifications.count({ emailStatus: "failed" }),
2472
+ this.stores.notifications.query({
2473
+ where: { emailStatus: "pending" },
2474
+ orderBy: { createdAt: "asc" },
2475
+ limit: 1
2476
+ }),
2477
+ this.service.settings(),
2478
+ this.service.migrationStatus(actor)
2479
+ ]);
2480
+ return {
2481
+ version: PLUGIN_VERSION,
2482
+ maintenance,
2483
+ maintenanceStale: !maintenance?.completedAt || this.service.now().getTime() - Date.parse(maintenance.completedAt) > 45 * 6e4,
2484
+ pendingEmails,
2485
+ failedEmails,
2486
+ oldestPendingAt: pending.items[0]?.data.createdAt ?? null,
2487
+ emailEnabled: settings.emailNotifications,
2488
+ migration
2489
+ };
2490
+ }
2491
+ };
2492
+
2493
+ //#endregion
2494
+ //#region src/study.ts
2495
+ function owner(actor) {
2496
+ invariant(actor, "UNAUTHORIZED", "Sign in to continue.", 401);
2497
+ return actor;
2498
+ }
2499
+ /** Personal study data is retained separately from access and completion decisions. */
2500
+ var StudyTools = class {
2501
+ constructor(service) {
2502
+ this.service = service;
2503
+ }
2504
+ async courses(actor, input) {
2505
+ const current = owner(actor);
2506
+ const page = await queryPage(this.service.stores.enrollments, {
2507
+ ...parse(pageSchema, input),
2508
+ where: { userId: current.id },
2509
+ orderBy: { enrolledAt: "desc" }
2510
+ });
2511
+ return {
2512
+ ...page,
2513
+ items: await Promise.all(page.items.map(async (enrollment) => {
2514
+ const source = await this.service.stores.courses.get(enrollment.courseId);
2515
+ const course = source ? await this.service.editions.learning(source, enrollment) : null;
2516
+ return {
2517
+ courseId: enrollment.courseId,
2518
+ title: course?.title ?? "Unavailable course",
2519
+ slug: course?.slug ?? null
2520
+ };
2521
+ }))
2522
+ };
2523
+ }
2524
+ async notebook(actor, input) {
2525
+ const current = owner(actor);
2526
+ const { courseId } = parse(courseIdSchema, input);
2527
+ const stored = await this.service.stores.enrollments.get(enrollmentKey(courseId, current.id));
2528
+ invariant(stored, "NOT_FOUND", "Your course notebook was not found.", 404);
2529
+ const source = await this.service.stores.courses.get(courseId);
2530
+ const course = source ? await this.service.editions.learning(source, stored) : null;
2531
+ const resolved = await this.service.accessEnrollment(stored);
2532
+ const all = course ? lessons(course) : [];
2533
+ return {
2534
+ course: {
2535
+ id: courseId,
2536
+ title: course?.title ?? "Unavailable course",
2537
+ slug: course?.slug ?? ""
2538
+ },
2539
+ notes: stored.studyNotes ?? [],
2540
+ summaries: Object.entries(stored.progress).flatMap(([lessonId, progress]) => progress.note ? [{
2541
+ lessonId,
2542
+ lessonTitle: all.find((lesson) => lesson.id === lessonId)?.title ?? "Lesson",
2543
+ body: progress.note
2544
+ }] : []),
2545
+ availableLessonIds: course ? all.filter((lesson) => lessonAvailability(course, lesson.id, resolved, this.service.now()).available).map((lesson) => lesson.id) : []
2546
+ };
2547
+ }
2548
+ async saveNote(actor, input) {
2549
+ const current = owner(actor);
2550
+ const parsed = parse(studyNoteSchema, input);
2551
+ const course = await this.service.course(parsed.courseId);
2552
+ return change(this.service.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
2553
+ const lesson = await this.service.lessonAccess(course, parsed.lessonId, stored);
2554
+ invariant(parsed.positionSeconds === null || ["video", "audio"].includes(lesson.kind), "INVALID_TIMESTAMP", "Timestamps belong to audio or video lessons.");
2555
+ const next = structuredClone(stored);
2556
+ const notes = next.studyNotes ??= [];
2557
+ const existing = notes.find((note) => note.id === parsed.id);
2558
+ invariant(!existing || existing.lessonId === lesson.id, "NOTE_CONFLICT", "This note belongs to another lesson.", 409);
2559
+ const same = existing && existing.body === parsed.body && existing.positionSeconds === parsed.positionSeconds && existing.starred === parsed.starred && existing.archived === parsed.archived;
2560
+ if (existing && same && existing.revision === parsed.expectedRevision + 1) return {
2561
+ value: next,
2562
+ result: existing
2563
+ };
2564
+ invariant((existing?.revision ?? 0) === parsed.expectedRevision, "NOTE_CONFLICT", "This note changed in another tab. Your edits are still here; reload the saved note to compare.", 409);
2565
+ invariant(existing || notes.length < 100, "NOTE_LIMIT", "This course notebook has reached its limit of 100 notes, including archived notes. Edit an existing note instead.", 409);
2566
+ const note = {
2567
+ id: parsed.id,
2568
+ lessonId: lesson.id,
2569
+ lessonTitle: existing?.lessonTitle ?? lesson.title,
2570
+ body: parsed.body,
2571
+ positionSeconds: parsed.positionSeconds,
2572
+ starred: parsed.starred,
2573
+ archived: parsed.archived,
2574
+ revision: (existing?.revision ?? 0) + 1,
2575
+ createdAt: existing?.createdAt ?? this.service.time(),
2576
+ updatedAt: this.service.time()
2577
+ };
2578
+ if (existing) notes[notes.indexOf(existing)] = note;
2579
+ else notes.push(note);
2580
+ invariant(new TextEncoder().encode(JSON.stringify(notes)).byteLength <= 15e4, "NOTE_LIMIT", "This course notebook has reached its storage limit. Shorten an existing note to make room; archived notes still count.", 409);
2581
+ return {
2582
+ value: next,
2583
+ result: note
2584
+ };
2585
+ });
2586
+ }
2587
+ async agenda(actor, input) {
2588
+ const current = owner(actor);
2589
+ const { courseId } = parse(courseIdSchema, input);
2590
+ const source = await this.service.course(courseId);
2591
+ const stored = await this.service.stores.enrollments.get(enrollmentKey(courseId, current.id));
2592
+ const enrollment = await this.service.accessEnrollment(stored);
2593
+ assertEnrollmentAccess(enrollment, this.service.now());
2594
+ const course = await this.service.editions.learning(source, enrollment);
2595
+ const now = this.service.now();
2596
+ const events = [];
2597
+ for (const lesson of lessons(course)) {
2598
+ const access = lessonAvailability(course, lesson.id, enrollment, now);
2599
+ const progress = enrollment.progress[lesson.id];
2600
+ const append = (kind, startsAt, endsAt) => {
2601
+ const past = Date.parse(startsAt) < now.getTime();
2602
+ events.push({
2603
+ id: `${course.id}-${lesson.id}-${kind}`,
2604
+ kind,
2605
+ lessonId: lesson.id,
2606
+ title: lesson.title,
2607
+ startsAt,
2608
+ endsAt,
2609
+ available: access.available,
2610
+ reason: access.reason,
2611
+ status: progress?.completedAt ? "completed" : kind === "assignment" && progress?.assignments.at(-1)?.status === "submitted" ? "submitted" : past ? kind === "assignment" ? "overdue" : "past" : "upcoming"
2612
+ });
2613
+ };
2614
+ if (lesson.assignment?.dueAt) append("assignment", lesson.assignment.dueAt, null);
2615
+ if (lesson.kind === "live" && lesson.liveStartsAt) append("live", lesson.liveStartsAt, new Date(Date.parse(lesson.liveStartsAt) + Math.max(1, lesson.durationMinutes) * 6e4).toISOString());
2616
+ if (access.code === "LESSON_LOCKED" && access.releaseAt) append("release", access.releaseAt, null);
2617
+ }
2618
+ events.sort((a, b) => Date.parse(a.startsAt) - Date.parse(b.startsAt) || a.id.localeCompare(b.id));
2619
+ return {
2620
+ course: {
2621
+ id: course.id,
2622
+ title: course.title,
2623
+ slug: course.slug
2624
+ },
2625
+ generatedAt: now.toISOString(),
2626
+ events
2627
+ };
2628
+ }
2629
+ };
2630
+
2631
+ //#endregion
2632
+ //#region src/calendar.ts
2633
+ const text = (value) => value.replace(/\r\n|\r/g, "\n").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/;/g, "\\;").replace(/,/g, "\\,");
2634
+ const date = (value) => new Date(value).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
2635
+ /** RFC 5545 folding counts UTF-8 octets, including the continuation space. */
2636
+ function foldCalendarLine(value) {
2637
+ const encoder = new TextEncoder();
2638
+ let line = "";
2639
+ let bytes = 0;
2640
+ const lines = [];
2641
+ for (const char of value) {
2642
+ const size = encoder.encode(char).byteLength;
2643
+ if (bytes + size > 75) {
2644
+ lines.push(line);
2645
+ line = " ";
2646
+ bytes = 1;
2647
+ }
2648
+ line += char;
2649
+ bytes += size;
2650
+ }
2651
+ lines.push(line);
2652
+ return lines.join("\r\n");
2653
+ }
2654
+ /** A private downloadable snapshot: no subscription token, meeting URL or alarm. */
2655
+ function courseCalendar(agenda, origin, basePath) {
2656
+ const lines = [
2657
+ "BEGIN:VCALENDAR",
2658
+ "VERSION:2.0",
2659
+ "PRODID:-//Ananta Gnanam//LMS//EN",
2660
+ "CALSCALE:GREGORIAN",
2661
+ "METHOD:PUBLISH"
2662
+ ];
2663
+ for (const event of agenda.events.filter((item) => item.status === "upcoming")) {
2664
+ const kind = {
2665
+ assignment: "Assignment due",
2666
+ live: "Live session",
2667
+ release: "Lesson release"
2668
+ }[event.kind];
2669
+ const url = new URL(`${basePath}/courses/${encodeURIComponent(agenda.course.slug)}/lessons/${encodeURIComponent(event.lessonId)}`, origin).href;
2670
+ lines.push("BEGIN:VEVENT", `UID:${event.id}@anantagnanam-lms`, `DTSTAMP:${date(agenda.generatedAt)}`, `DTSTART:${date(event.startsAt)}`);
2671
+ if (event.endsAt) lines.push(`DTEND:${date(event.endsAt)}`);
2672
+ lines.push(`SUMMARY:${text(`${kind}: ${event.title}`)}`, `DESCRIPTION:${text(`${agenda.course.title}. Check the course for current access and requirements. This downloaded calendar does not update automatically.`)}`, `URL:${url}`, "CLASS:PRIVATE", `STATUS:${event.available ? "CONFIRMED" : "TENTATIVE"}`, "END:VEVENT");
2673
+ }
2674
+ lines.push("END:VCALENDAR");
2675
+ return lines.map(foldCalendarLine).join("\r\n") + "\r\n";
2676
+ }
2677
+
1699
2678
  //#endregion
1700
2679
  //#region src/runtime.ts
1701
2680
  const listSchema = pageSchema.extend({
@@ -1719,6 +2698,10 @@ const communityListSchema = pageSchema.extend({
1719
2698
  });
1720
2699
  const slugSchema = z.object({ slug: z.string().min(1).max(100) });
1721
2700
  const keyInput = z.object({ id: keySchema });
2701
+ const courseRevisionInput = z.object({
2702
+ id: idSchema,
2703
+ expectedRevision: z.string().min(1).max(128)
2704
+ });
1722
2705
  function get(handler, publicRoute = false, permission = "content:read") {
1723
2706
  return definePluginRoute({
1724
2707
  methods: ["GET"],
@@ -1762,7 +2745,7 @@ function createPlugin(_options = {}) {
1762
2745
  },
1763
2746
  hooks: {
1764
2747
  "plugin:install": async (_event, ctx) => {
1765
- await ctx.kv.set("schemaVersion", 1);
2748
+ await ctx.kv.compareAndSet("schemaVersion", null, 1);
1766
2749
  },
1767
2750
  "plugin:activate": async (_event, ctx) => {
1768
2751
  await ctx.cron?.schedule("maintenance", { schedule: "*/15 * * * *" });
@@ -1773,12 +2756,61 @@ function createPlugin(_options = {}) {
1773
2756
  cron: async (event, ctx) => {
1774
2757
  if (event.name === "maintenance") {
1775
2758
  const service = new LmsService(ctx);
1776
- await service.reminders();
1777
- await service.deliverNotifications();
2759
+ const startedAt = service.time();
2760
+ try {
2761
+ await service.reminders();
2762
+ await service.deliverNotifications();
2763
+ await ctx.kv.set("maintenanceHealth", {
2764
+ startedAt,
2765
+ completedAt: service.time(),
2766
+ failed: false
2767
+ });
2768
+ } catch (error) {
2769
+ await ctx.kv.set("maintenanceHealth", {
2770
+ startedAt,
2771
+ completedAt: null,
2772
+ failed: true
2773
+ });
2774
+ throw error;
2775
+ }
1778
2776
  }
1779
2777
  }
1780
2778
  },
1781
2779
  routes: {
2780
+ "learner/study/courses": get((ctx, service) => new StudyTools(service).courses(ctx.user, ctx.input)),
2781
+ "learner/notebook": get((ctx, service) => new StudyTools(service).notebook(ctx.user, ctx.input)),
2782
+ "learner/notebook/save": post((ctx, service) => new StudyTools(service).saveNote(ctx.user, ctx.input)),
2783
+ "learner/agenda": get((ctx, service) => new StudyTools(service).agenda(ctx.user, ctx.input)),
2784
+ "learner/agenda/calendar": definePluginRoute({
2785
+ methods: ["GET"],
2786
+ permission: "content:read",
2787
+ request: { body: "none" },
2788
+ response: "raw",
2789
+ handler: async (ctx) => {
2790
+ return pluginResponse({
2791
+ body: {
2792
+ kind: "text",
2793
+ value: courseCalendar(await new StudyTools(new LmsService(ctx)).agenda(ctx.user, ctx.input), new URL(ctx.request.url).origin, options.basePath)
2794
+ },
2795
+ headers: {
2796
+ "Content-Type": "text/calendar; charset=utf-8",
2797
+ "Content-Disposition": "attachment; filename=\"lms-course-agenda.ics\""
2798
+ }
2799
+ });
2800
+ }
2801
+ }),
2802
+ paths: get(async (ctx, service) => new LearningFeatures(service).paths(ctx.input), true),
2803
+ "learner/paths": get(async (ctx, service) => new LearningFeatures(service).learnerPaths(ctx.user, ctx.input)),
2804
+ "learner/path/join": post(async (ctx, service) => new LearningFeatures(service).joinPath(ctx.user, ctx.input)),
2805
+ "learner/saved": get(async (ctx, service) => new LearningFeatures(service).savedCourses(ctx.user, ctx.input)),
2806
+ "learner/saved/save": post(async (ctx, service) => new LearningFeatures(service).saveCourse(ctx.user, ctx.input)),
2807
+ "learner/preferences": get(async (ctx, service) => new LearningFeatures(service).preferences(ctx.user)),
2808
+ "learner/preferences/save": post(async (ctx, service) => new LearningFeatures(service).savePreferences(ctx.user, ctx.input)),
2809
+ "learner/playback": post(async (ctx, service) => new LearningFeatures(service).playback(ctx.user, ctx.input)),
2810
+ "learner/export": get(async (ctx, service) => new LearningFeatures(service).exportData(ctx.user, ctx.input)),
2811
+ "admin/paths": get(async (ctx, service) => new LearningFeatures(service).adminPaths(ctx.user, ctx.input), false, "plugins:manage"),
2812
+ "admin/path/save": post(async (ctx, service) => new LearningFeatures(service).savePath(ctx.user, ctx.input), "plugins:manage"),
2813
+ "admin/health": get(async (ctx, service) => new LearningFeatures(service).health(ctx.user), false, "plugins:manage"),
1782
2814
  academy: get(async (_ctx, service) => {
1783
2815
  const settings = await service.settings();
1784
2816
  return {
@@ -1827,6 +2859,11 @@ function createPlugin(_options = {}) {
1827
2859
  "learner/quiz/submit": post(async (ctx, service) => service.submitQuiz(ctx.user, ctx.input)),
1828
2860
  "learner/quiz/draft": post(async (ctx, service) => service.saveQuizDraft(ctx.user, ctx.input)),
1829
2861
  "learner/assignment": post(async (ctx, service) => service.submitAssignment(ctx.user, ctx.input)),
2862
+ "learner/assignment/state": get(async (ctx, service) => {
2863
+ const input = parse(lessonIdSchema, ctx.input);
2864
+ return service.assignmentState(ctx.user, input.courseId, input.lessonId);
2865
+ }),
2866
+ "learner/assignment/draft": post(async (ctx, service) => service.saveAssignmentDraft(ctx.user, ctx.input)),
1830
2867
  "learner/note": post(async (ctx, service) => service.saveNote(ctx.user, ctx.input)),
1831
2868
  "learner/discussions": get(async (ctx, service) => {
1832
2869
  const input = parse(lessonIdSchema.extend({ cursor: z.string().optional() }), ctx.input);
@@ -1854,6 +2891,34 @@ function createPlugin(_options = {}) {
1854
2891
  ]).optional() }), ctx.input))),
1855
2892
  "admin/course": get(async (ctx, service) => service.editableCourse(ctx.user, parse(z.object({ id: idSchema }), ctx.input).id)),
1856
2893
  "admin/course/save": post(async (ctx, service) => service.saveCourse(ctx.user, ctx.input)),
2894
+ "admin/course/publish": post(async (ctx, service) => {
2895
+ const input = parse(courseRevisionInput, ctx.input);
2896
+ return service.publishCourse(ctx.user, input.id, input.expectedRevision);
2897
+ }),
2898
+ "admin/course/archive": post(async (ctx, service) => {
2899
+ const input = parse(courseRevisionInput, ctx.input);
2900
+ return service.archiveCourse(ctx.user, input.id, input.expectedRevision);
2901
+ }),
2902
+ "admin/course/history": get(async (ctx, service) => {
2903
+ const input = parse(z.object({
2904
+ id: idSchema,
2905
+ cursor: idSchema.optional()
2906
+ }), ctx.input);
2907
+ return service.courseHistory(ctx.user, input.id, input.cursor);
2908
+ }),
2909
+ "admin/course/restore": post(async (ctx, service) => {
2910
+ const input = parse(courseRevisionInput.extend({ revisionId: idSchema }), ctx.input);
2911
+ return service.restoreCourse(ctx.user, input.id, input.expectedRevision, input.revisionId);
2912
+ }),
2913
+ "admin/migrations": get(async (ctx, service) => service.migrationStatus(ctx.user), false, "plugins:manage"),
2914
+ "admin/migrations/run": post(async (ctx, service) => {
2915
+ const input = parse(z.object({ limit: z.number().int().min(1).max(20).default(20) }), ctx.input);
2916
+ return service.migrateBatch(ctx.user, input.limit);
2917
+ }, "plugins:manage"),
2918
+ "admin/lesson/preview": get(async (ctx, service) => {
2919
+ const input = parse(lessonIdSchema, ctx.input);
2920
+ return service.authorLessonPreview(ctx.user, input.courseId, input.lessonId);
2921
+ }),
1857
2922
  "admin/course/delete": post(async (ctx, service) => {
1858
2923
  const input = parse(z.object({
1859
2924
  id: idSchema,
@@ -2035,10 +3100,16 @@ function createPlugin(_options = {}) {
2035
3100
  },
2036
3101
  save_course: {
2037
3102
  route: "admin/course/save",
2038
- description: "Create or update a course using its current storage revision. Can publish course content.",
3103
+ 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
3104
  input: courseSaveSchema,
2040
3105
  destructive: true
2041
3106
  },
3107
+ publish_course: {
3108
+ route: "admin/course/publish",
3109
+ description: "Publish the saved draft as a new course edition using its current storage revision. Existing learners retain their enrolled edition.",
3110
+ input: courseRevisionInput,
3111
+ destructive: true
3112
+ },
2042
3113
  enroll_learner: {
2043
3114
  route: "admin/enrollment/save",
2044
3115
  description: "Approve, reject, suspend, or manually enroll a learner. Requires an administrator account.",