@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.
- package/LICENSE +21 -21
- package/README.md +40 -2
- package/astro/assignment-player.ts +231 -0
- package/astro/client.ts +97 -152
- package/astro/components/AssignmentFeedback.astro +32 -0
- package/astro/components/AssignmentRubric.astro +27 -0
- package/astro/components/CourseOverview.astro +25 -3
- package/astro/components/LearnerDashboard.astro +9 -0
- package/astro/components/LearningShell.astro +16 -1
- package/astro/components/LessonAvailabilityNote.astro +26 -0
- package/astro/components/LessonContent.astro +67 -0
- package/astro/components/LessonPlayer.astro +166 -99
- package/astro/components/MediaPlayer.astro +55 -4
- package/astro/draft-queue.ts +95 -0
- package/astro/media-player.ts +124 -0
- package/astro/pages/author-preview.astro +73 -0
- package/astro/pages/course.astro +10 -0
- package/astro/pages/lesson.astro +3 -0
- package/astro/pages/library.astro +173 -0
- package/astro/pages/paths.astro +73 -0
- package/astro/pages/study.astro +180 -0
- package/astro/quiz-drafts.ts +23 -0
- package/astro/quiz-player.ts +362 -0
- package/astro/study-tools.ts +386 -0
- package/astro/styles.css +30 -0
- package/astro/transcript-search.ts +56 -0
- package/dist/admin.d.ts +3 -1
- package/dist/admin.js +756 -127
- package/dist/admin.js.map +1 -1
- package/dist/assignments-CsuAjxGO.js +75 -0
- package/dist/assignments-CsuAjxGO.js.map +1 -0
- package/dist/astro.js +4 -0
- package/dist/astro.js.map +1 -1
- package/dist/{constants-C0kT94tc.js → constants-DyQbwNbP.js} +25 -3
- package/dist/constants-DyQbwNbP.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/runtime.d.ts +129 -16
- package/dist/runtime.js +1201 -130
- package/dist/runtime.js.map +1 -1
- package/dist/schemas.d.ts +136 -3
- package/dist/schemas.js +80 -7
- package/dist/schemas.js.map +1 -1
- package/dist/server.d.ts +18 -1
- package/dist/server.js +17 -3
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +172 -4
- package/package.json +1 -1
- package/dist/constants-C0kT94tc.js.map +0 -1
- package/dist/errors-ChzJS9al.js +0 -16
- 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-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
322
|
-
|
|
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:
|
|
602
|
+
schemaVersion: 2
|
|
333
603
|
};
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1273
|
+
const current = user$1(actor);
|
|
1274
|
+
const parsed = parse(quizDraftSchema, input);
|
|
873
1275
|
const course = await this.course(parsed.courseId);
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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
|
-
|
|
939
|
-
submission.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 (
|
|
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
|
-
|
|
1436
|
+
submissionId: parsed.submissionId,
|
|
1437
|
+
points: enrollment.progress[parsed.lessonId].assignments.find((item) => item.id === parsed.submissionId).points
|
|
961
1438
|
});
|
|
962
|
-
return await this.
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
1777
|
-
|
|
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
|
|
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.",
|