@anantagnanam/lms 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +23 -2
- package/astro/client.ts +73 -151
- package/astro/components/CourseOverview.astro +25 -3
- package/astro/components/LearnerDashboard.astro +6 -0
- package/astro/components/LearningShell.astro +10 -1
- package/astro/components/LessonAvailabilityNote.astro +26 -0
- package/astro/components/LessonContent.astro +53 -0
- package/astro/components/LessonPlayer.astro +40 -61
- package/astro/components/MediaPlayer.astro +55 -4
- package/astro/media-player.ts +99 -0
- package/astro/pages/author-preview.astro +71 -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/quiz-drafts.ts +87 -0
- package/astro/quiz-player.ts +362 -0
- package/astro/styles.css +12 -0
- package/dist/admin.d.ts +3 -1
- package/dist/admin.js +585 -116
- package/dist/admin.js.map +1 -1
- package/dist/astro.js +3 -0
- package/dist/astro.js.map +1 -1
- package/dist/{constants-C0kT94tc.js → constants-Cnx7k82Z.js} +25 -3
- package/dist/constants-Cnx7k82Z.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/runtime.d.ts +104 -12
- package/dist/runtime.js +896 -116
- package/dist/runtime.js.map +1 -1
- package/dist/schemas.d.ts +68 -1
- package/dist/schemas.js +34 -2
- package/dist/schemas.js.map +1 -1
- package/dist/server.d.ts +11 -1
- package/dist/server.js +13 -2
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +107 -4
- package/package.json +1 -1
- package/dist/constants-C0kT94tc.js.map +0 -1
package/dist/runtime.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { a as adminPages, i as PLUGIN_VERSION, n as MAX_DOCUMENT_BYTES, o as storage, r as PLUGIN_ID } from "./constants-
|
|
1
|
+
import { a as adminPages, i as PLUGIN_VERSION, n as MAX_DOCUMENT_BYTES, o as storage, r as PLUGIN_ID } from "./constants-Cnx7k82Z.js";
|
|
2
2
|
import { t as normalizeLmsOptions } from "./config-BX3beTpM.js";
|
|
3
|
-
import { assignmentSubmitSchema, catalogSchema, certificateActionSchema, certificateRevokeSchema, cohortSaveSchema, courseIdSchema, courseSaveSchema, discussionSchema, enrollmentAdminSchema, enrollmentRequestSchema, gradeSchema, idSchema, keySchema, lessonIdSchema, moderateSchema, noteSchema, pageSchema, quizSubmitSchema, reviewSchema, settingsSchema } from "./schemas.js";
|
|
3
|
+
import { assignmentSubmitSchema, catalogSchema, certificateActionSchema, certificateRevokeSchema, cohortSaveSchema, courseDataSchema, courseIdSchema, courseSaveSchema, discussionSchema, enrollmentAdminSchema, enrollmentRequestSchema, gradeSchema, idSchema, keySchema, lessonIdSchema, moderateSchema, noteSchema, pageSchema, pathSaveSchema, playbackSchema, preferencesSchema, quizDraftSchema, quizSubmitSchema, reviewSchema, settingsSchema } from "./schemas.js";
|
|
4
4
|
import { n as invariant, t as LmsError } from "./errors-ChzJS9al.js";
|
|
5
5
|
import { n as enrollmentCsvLines, t as enrollmentCsvHeader } from "./csv-DKq_4OWU.js";
|
|
6
6
|
import { definePlugin, definePluginRoute, pluginResponse } from "emdash";
|
|
@@ -37,6 +37,212 @@ async function putOnce(store, id, value) {
|
|
|
37
37
|
await store.compareAndSet(id, null, value);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/editions.ts
|
|
42
|
+
/** Child snapshots are immutable. Only the root CAS makes a revision visible. */
|
|
43
|
+
var CourseEditions = class {
|
|
44
|
+
constructor(stores, time, resolveContent) {
|
|
45
|
+
this.stores = stores;
|
|
46
|
+
this.time = time;
|
|
47
|
+
this.resolveContent = resolveContent;
|
|
48
|
+
}
|
|
49
|
+
async snapshot(courseId, data) {
|
|
50
|
+
const frozen = structuredClone(data);
|
|
51
|
+
for (const module of frozen.modules) for (let i = 0; i < module.lessons.length; i++) module.lessons[i] = {
|
|
52
|
+
...await this.resolveContent(module.lessons[i]),
|
|
53
|
+
contentSource: null
|
|
54
|
+
};
|
|
55
|
+
const id = crypto.randomUUID();
|
|
56
|
+
await putOnce(this.stores.course_editions, id, {
|
|
57
|
+
id,
|
|
58
|
+
courseId,
|
|
59
|
+
data: frozen,
|
|
60
|
+
createdAt: this.time()
|
|
61
|
+
});
|
|
62
|
+
return id;
|
|
63
|
+
}
|
|
64
|
+
async revision(course, data, actorId, kind, extra = {}) {
|
|
65
|
+
const revision = {
|
|
66
|
+
id: crypto.randomUUID(),
|
|
67
|
+
courseId: course.id,
|
|
68
|
+
parentId: course.draftRevisionId ?? null,
|
|
69
|
+
data: courseDataSchema.parse({
|
|
70
|
+
...data,
|
|
71
|
+
status: "draft"
|
|
72
|
+
}),
|
|
73
|
+
kind,
|
|
74
|
+
createdBy: actorId,
|
|
75
|
+
createdAt: this.time(),
|
|
76
|
+
...extra
|
|
77
|
+
};
|
|
78
|
+
await putOnce(this.stores.course_revisions, revision.id, revision);
|
|
79
|
+
return revision;
|
|
80
|
+
}
|
|
81
|
+
async commit(current, course) {
|
|
82
|
+
assertSize(course);
|
|
83
|
+
const saved = await this.stores.courses.compareAndSet(course.id, current?.revision ?? null, course);
|
|
84
|
+
invariant(saved.applied, "CONFLICT", "The course changed. Reload before continuing.", 409);
|
|
85
|
+
return {
|
|
86
|
+
value: course,
|
|
87
|
+
revision: saved.revision
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
async ensure(id, expectedRevision) {
|
|
91
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
92
|
+
const current = await this.stores.courses.getVersioned(id);
|
|
93
|
+
invariant(current, "NOT_FOUND", "Course not found.", 404);
|
|
94
|
+
invariant(expectedRevision === void 0 || current.revision === expectedRevision, "CONFLICT", "The course changed. Reload before continuing.", 409);
|
|
95
|
+
if (current.value.schemaVersion === 2) return current;
|
|
96
|
+
const course = current.value;
|
|
97
|
+
invariant(course.schemaVersion === 1, "UNSUPPORTED_SCHEMA", "This course needs a different LMS version. No migration was applied.", 409);
|
|
98
|
+
const data = courseDataSchema.parse(course);
|
|
99
|
+
const editionId = await this.snapshot(id, data);
|
|
100
|
+
const revision = await this.revision(course, data, course.createdBy, "migration", { editionId });
|
|
101
|
+
const edition = await this.stores.course_editions.get(editionId);
|
|
102
|
+
const next = {
|
|
103
|
+
...course,
|
|
104
|
+
...edition.data,
|
|
105
|
+
schemaVersion: 2,
|
|
106
|
+
draftRevisionId: revision.id,
|
|
107
|
+
legacyEditionId: editionId,
|
|
108
|
+
...course.status !== "draft" ? {
|
|
109
|
+
publishedEditionId: editionId,
|
|
110
|
+
publishedDraftRevisionId: revision.id
|
|
111
|
+
} : {}
|
|
112
|
+
};
|
|
113
|
+
try {
|
|
114
|
+
return await this.commit(current, next);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (!(error instanceof LmsError) || error.code !== "CONFLICT" || expectedRevision !== void 0) throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
throw new LmsError("CONFLICT", "Course migration conflicted. Please retry.", 409);
|
|
120
|
+
}
|
|
121
|
+
async draft(course) {
|
|
122
|
+
if (!course.draftRevisionId) return course;
|
|
123
|
+
const revision = await this.stores.course_revisions.get(course.draftRevisionId);
|
|
124
|
+
invariant(revision?.courseId === course.id, "EDITION_UNAVAILABLE", "The saved course draft is unavailable.", 503);
|
|
125
|
+
return {
|
|
126
|
+
...course,
|
|
127
|
+
...revision.data,
|
|
128
|
+
status: course.status
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
async learning(course, enrollment) {
|
|
132
|
+
if (!enrollment) return course;
|
|
133
|
+
const id = enrollment.courseEditionId ?? course.legacyEditionId ?? course.publishedEditionId;
|
|
134
|
+
if (!id) return course;
|
|
135
|
+
const edition = await this.stores.course_editions.get(id);
|
|
136
|
+
invariant(edition?.courseId === course.id, "EDITION_UNAVAILABLE", "The enrolled course edition is unavailable.", 503);
|
|
137
|
+
return {
|
|
138
|
+
...course,
|
|
139
|
+
...edition.data,
|
|
140
|
+
status: course.status,
|
|
141
|
+
slug: course.slug,
|
|
142
|
+
instructorIds: course.instructorIds
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
async history(course, cursor) {
|
|
146
|
+
let id = course.draftRevisionId;
|
|
147
|
+
let found = !cursor;
|
|
148
|
+
const items = [];
|
|
149
|
+
for (let i = 0; id && i < 1e3; i++) {
|
|
150
|
+
const revision = await this.stores.course_revisions.get(id);
|
|
151
|
+
invariant(revision?.courseId === course.id, "EDITION_UNAVAILABLE", "Course history is unavailable.", 503);
|
|
152
|
+
if (id === cursor) found = true;
|
|
153
|
+
if (found) {
|
|
154
|
+
const { data, ...summary } = revision;
|
|
155
|
+
items.push({
|
|
156
|
+
...summary,
|
|
157
|
+
title: data.title
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
id = revision.parentId ?? void 0;
|
|
161
|
+
if (items.length === 20) return {
|
|
162
|
+
items,
|
|
163
|
+
cursor: id,
|
|
164
|
+
hasMore: !!id
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
invariant(found && !id, "INVALID_CURSOR", "History cursor is invalid or exceeds the 1,000-revision browsing limit.", 400);
|
|
168
|
+
return {
|
|
169
|
+
items,
|
|
170
|
+
hasMore: false
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
async migrationStatus() {
|
|
174
|
+
return await this.stores.migrations.get("course-editions-v2") ?? {
|
|
175
|
+
phase: "courses",
|
|
176
|
+
coursesProcessed: 0,
|
|
177
|
+
enrollmentsProcessed: 0,
|
|
178
|
+
updatedAt: this.time(),
|
|
179
|
+
lastError: null
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
async migrateBatch(limit) {
|
|
183
|
+
const id = "course-editions-v2";
|
|
184
|
+
const current = await this.stores.migrations.getVersioned(id);
|
|
185
|
+
const state = current?.value ?? await this.migrationStatus();
|
|
186
|
+
if (state.phase === "complete") return state;
|
|
187
|
+
const next = {
|
|
188
|
+
...state,
|
|
189
|
+
lastError: null,
|
|
190
|
+
updatedAt: this.time()
|
|
191
|
+
};
|
|
192
|
+
try {
|
|
193
|
+
if (state.phase === "courses") {
|
|
194
|
+
const page = await this.stores.courses.query({
|
|
195
|
+
orderBy: { createdAt: "asc" },
|
|
196
|
+
cursor: state.cursor,
|
|
197
|
+
limit
|
|
198
|
+
});
|
|
199
|
+
for (const item of page.items) await this.ensure(item.id);
|
|
200
|
+
next.coursesProcessed += page.items.length;
|
|
201
|
+
next.cursor = page.cursor;
|
|
202
|
+
if (!page.hasMore) {
|
|
203
|
+
next.phase = "enrollments";
|
|
204
|
+
next.cursor = void 0;
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
const page = await this.stores.enrollments.query({
|
|
208
|
+
orderBy: { enrolledAt: "asc" },
|
|
209
|
+
cursor: state.cursor,
|
|
210
|
+
limit
|
|
211
|
+
});
|
|
212
|
+
for (const item of page.items) {
|
|
213
|
+
if (item.data.courseEditionId) continue;
|
|
214
|
+
const course = (await this.ensure(item.data.courseId)).value;
|
|
215
|
+
await change(this.stores.enrollments, item.id, async (enrollment) => {
|
|
216
|
+
invariant(enrollment, "NOT_FOUND", "Enrollment disappeared during migration.", 409);
|
|
217
|
+
return {
|
|
218
|
+
value: {
|
|
219
|
+
...enrollment,
|
|
220
|
+
courseEditionId: enrollment.courseEditionId ?? course.legacyEditionId ?? course.publishedEditionId
|
|
221
|
+
},
|
|
222
|
+
result: null
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
next.enrollmentsProcessed += page.items.length;
|
|
227
|
+
next.cursor = page.cursor;
|
|
228
|
+
if (!page.hasMore) {
|
|
229
|
+
next.phase = "complete";
|
|
230
|
+
next.cursor = void 0;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
} catch (error) {
|
|
234
|
+
await this.stores.migrations.compareAndSet(id, current?.revision ?? null, {
|
|
235
|
+
...state,
|
|
236
|
+
updatedAt: this.time(),
|
|
237
|
+
lastError: error instanceof LmsError ? error.message : "Migration failed. Retry the batch after checking storage availability."
|
|
238
|
+
});
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
invariant((await this.stores.migrations.compareAndSet(id, current?.revision ?? null, next)).applied, "CONFLICT", "Another migration batch advanced the checkpoint. Refresh and continue.", 409);
|
|
242
|
+
return next;
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
40
246
|
//#endregion
|
|
41
247
|
//#region src/curriculum.ts
|
|
42
248
|
function lessons(course) {
|
|
@@ -103,6 +309,8 @@ function publicLesson(lesson) {
|
|
|
103
309
|
const { quiz, contentSource: _source, ...safe } = lesson;
|
|
104
310
|
return {
|
|
105
311
|
...safe,
|
|
312
|
+
transcript: lesson.transcript ?? "",
|
|
313
|
+
captions: lesson.captions ?? [],
|
|
106
314
|
quiz: quiz ? {
|
|
107
315
|
passingPercent: quiz.passingPercent,
|
|
108
316
|
maxAttempts: quiz.maxAttempts,
|
|
@@ -116,7 +324,10 @@ function publicLesson(lesson) {
|
|
|
116
324
|
}
|
|
117
325
|
function publicAttempt(attempt) {
|
|
118
326
|
const { quiz: _quiz, ...safe } = attempt;
|
|
119
|
-
return
|
|
327
|
+
return {
|
|
328
|
+
...safe,
|
|
329
|
+
draftRevision: attempt.draftRevision ?? 0
|
|
330
|
+
};
|
|
120
331
|
}
|
|
121
332
|
function effectiveAccess(enrollment, now = /* @__PURE__ */ new Date()) {
|
|
122
333
|
if (enrollment.status === "suspended" || enrollment.accessStatus === "suspended") return "suspended";
|
|
@@ -142,16 +353,65 @@ function publicEnrollment(enrollment, now = /* @__PURE__ */ new Date()) {
|
|
|
142
353
|
}]))
|
|
143
354
|
};
|
|
144
355
|
}
|
|
145
|
-
|
|
356
|
+
/** Staff need assessment and completion records, not a learner's private study notes. */
|
|
357
|
+
function staffEnrollment(enrollment, now = /* @__PURE__ */ new Date()) {
|
|
358
|
+
const view = publicEnrollment(enrollment, now);
|
|
359
|
+
for (const progress of Object.values(view.progress)) {
|
|
360
|
+
progress.note = "";
|
|
361
|
+
delete progress.playback;
|
|
362
|
+
}
|
|
363
|
+
return view;
|
|
364
|
+
}
|
|
365
|
+
function progressSummary(course, enrollment, now = /* @__PURE__ */ new Date()) {
|
|
146
366
|
const required = enrollment?.completedAt ? enrollment.completionLessonIds : lessons(course).filter((lesson) => lesson.required).map((lesson) => lesson.id);
|
|
147
367
|
const completed = required.filter((id) => enrollment?.progress[id]?.completedAt).length;
|
|
368
|
+
const all = lessons(course);
|
|
369
|
+
const availability = Object.fromEntries(all.map((lesson) => [lesson.id, lessonAvailability(course, lesson.id, enrollment, now)]));
|
|
370
|
+
const incomplete = all.filter((lesson) => !enrollment?.progress[lesson.id]?.completedAt);
|
|
371
|
+
const nextLessonId = incomplete.find((lesson) => availability[lesson.id].available)?.id ?? null;
|
|
148
372
|
return {
|
|
149
373
|
completed,
|
|
150
374
|
total: required.length,
|
|
151
375
|
percent: required.length ? Math.floor(completed / required.length * 100) : 0,
|
|
152
|
-
nextLessonId
|
|
376
|
+
nextLessonId,
|
|
377
|
+
blockedLessonId: nextLessonId ? null : incomplete[0]?.id ?? null,
|
|
378
|
+
availability
|
|
153
379
|
};
|
|
154
380
|
}
|
|
381
|
+
/** Use the same access boundary for navigation and private lesson reads. Enrollment
|
|
382
|
+
* must already have its cohort grants resolved against authoritative membership. */
|
|
383
|
+
function lessonAvailability(course, lessonId, enrollment, now) {
|
|
384
|
+
try {
|
|
385
|
+
assertLessonAccess(course, lessonId, enrollment, now);
|
|
386
|
+
return {
|
|
387
|
+
available: true,
|
|
388
|
+
code: null,
|
|
389
|
+
reason: null,
|
|
390
|
+
releaseAt: null
|
|
391
|
+
};
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (!(error instanceof LmsError)) throw error;
|
|
394
|
+
let releaseAt = null;
|
|
395
|
+
if (enrollment && error.code === "LESSON_LOCKED") releaseAt = new Date(moduleReleaseAt(course, lessonId, enrollment)).toISOString();
|
|
396
|
+
else if (enrollment && effectiveAccess(enrollment, now) === "scheduled") {
|
|
397
|
+
const starts = Object.values(enrollment.grants).filter((grant) => grant.status === "granted" && (!grant.expiresAt || Date.parse(grant.expiresAt) > now.getTime())).flatMap((grant) => {
|
|
398
|
+
const date = Math.max(grant.startsAt ? Date.parse(grant.startsAt) : 0, enrollment.accessStartsAt ? Date.parse(enrollment.accessStartsAt) : 0);
|
|
399
|
+
return date > now.getTime() && (!grant.expiresAt || date < Date.parse(grant.expiresAt)) && (!enrollment.accessExpiresAt || date < Date.parse(enrollment.accessExpiresAt)) ? [date] : [];
|
|
400
|
+
});
|
|
401
|
+
if (starts.length) releaseAt = new Date(Math.min(...starts)).toISOString();
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
available: false,
|
|
405
|
+
code: error.code,
|
|
406
|
+
reason: error.message,
|
|
407
|
+
releaseAt
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function moduleReleaseAt(course, lessonId, enrollment) {
|
|
412
|
+
const module = course.modules.find((item) => item.lessons.some((entry) => entry.id === lessonId));
|
|
413
|
+
return Math.max(module.unlockAt ? Date.parse(module.unlockAt) : 0, Date.parse(enrollment.approvedAt ?? enrollment.enrolledAt) + module.releaseDays * 864e5);
|
|
414
|
+
}
|
|
155
415
|
function assertEnrollmentAccess(enrollment, now) {
|
|
156
416
|
invariant(enrollment && (enrollment.status === "active" || enrollment.status === "completed"), "ENROLLMENT_REQUIRED", "An active enrollment is required for this lesson.", 403);
|
|
157
417
|
invariant(enrollment.accessStatus === "granted", "ACCESS_RESTRICTED", "Your course access is not currently granted.", 403);
|
|
@@ -173,8 +433,7 @@ function assertLessonAccess(course, lessonId, enrollment, now, allowPreview = fa
|
|
|
173
433
|
].includes(lesson.kind)) return lesson;
|
|
174
434
|
assertEnrollmentAccess(enrollment, now);
|
|
175
435
|
invariant(course.status !== "draft", "NOT_FOUND", "Course not found.", 404);
|
|
176
|
-
const
|
|
177
|
-
const releaseAt = Math.max(module.unlockAt ? Date.parse(module.unlockAt) : 0, Date.parse(enrollment.approvedAt ?? enrollment.enrolledAt) + module.releaseDays * 864e5);
|
|
436
|
+
const releaseAt = moduleReleaseAt(course, lessonId, enrollment);
|
|
178
437
|
invariant(now.getTime() >= releaseAt, "LESSON_LOCKED", "This module has not been released yet.", 403);
|
|
179
438
|
if (course.sequential) {
|
|
180
439
|
const all = lessons(course);
|
|
@@ -233,16 +492,18 @@ function parse(schema, input) {
|
|
|
233
492
|
if (!result.success) throw new LmsError("VALIDATION_ERROR", result.error.issues.map((issue) => `${issue.path.join(".") || "Input"}: ${issue.message}`).slice(0, 5).join(" "), 400);
|
|
234
493
|
return result.data;
|
|
235
494
|
}
|
|
236
|
-
function user(actor) {
|
|
495
|
+
function user$1(actor) {
|
|
237
496
|
invariant(actor, "UNAUTHORIZED", "Sign in to continue.", 401);
|
|
238
497
|
return actor;
|
|
239
498
|
}
|
|
240
499
|
var LmsService = class {
|
|
241
500
|
stores;
|
|
501
|
+
editions;
|
|
242
502
|
constructor(ctx, now = () => /* @__PURE__ */ new Date()) {
|
|
243
503
|
this.ctx = ctx;
|
|
244
504
|
this.now = now;
|
|
245
505
|
this.stores = collections(ctx);
|
|
506
|
+
this.editions = new CourseEditions(this.stores, () => this.time(), (lesson) => this.resolveContent(lesson));
|
|
246
507
|
}
|
|
247
508
|
time() {
|
|
248
509
|
return this.now().toISOString();
|
|
@@ -251,7 +512,7 @@ var LmsService = class {
|
|
|
251
512
|
return settingsSchema.parse(await this.ctx.settings.get("configuration") ?? {});
|
|
252
513
|
}
|
|
253
514
|
async saveSettings(actor, input) {
|
|
254
|
-
invariant(user(actor).role >= 50, "FORBIDDEN", "Only administrators can change LMS settings.", 403);
|
|
515
|
+
invariant(user$1(actor).role >= 50, "FORBIDDEN", "Only administrators can change LMS settings.", 403);
|
|
255
516
|
const settings = parse(settingsSchema, input);
|
|
256
517
|
invariant(!settings.emailNotifications || this.ctx.email, "EMAIL_UNAVAILABLE", "Configure an email transport in EmDash before enabling LMS emails.");
|
|
257
518
|
await this.ctx.settings.set("configuration", settings);
|
|
@@ -295,51 +556,182 @@ var LmsService = class {
|
|
|
295
556
|
async enrollmentView(enrollment) {
|
|
296
557
|
return publicEnrollment(await this.accessEnrollment(enrollment), this.now());
|
|
297
558
|
}
|
|
559
|
+
async learningState(course, enrollment) {
|
|
560
|
+
course = await this.editions.learning(course, enrollment);
|
|
561
|
+
const resolved = await this.accessEnrollment(enrollment);
|
|
562
|
+
const now = this.now();
|
|
563
|
+
return {
|
|
564
|
+
enrollment: resolved ? publicEnrollment(resolved, now) : null,
|
|
565
|
+
progress: progressSummary(course, resolved, now)
|
|
566
|
+
};
|
|
567
|
+
}
|
|
298
568
|
async lessonAccess(course, lessonId, enrollment) {
|
|
299
|
-
return assertLessonAccess(course, lessonId, await this.accessEnrollment(enrollment), this.now());
|
|
569
|
+
return assertLessonAccess(await this.editions.learning(course, enrollment), lessonId, await this.accessEnrollment(enrollment), this.now());
|
|
300
570
|
}
|
|
301
571
|
instructor(course, actor) {
|
|
302
|
-
const current = user(actor);
|
|
303
|
-
invariant(current.role >= 50 || course.createdBy === current.id || course.instructorIds.includes(current.id), "FORBIDDEN", "You are not an instructor for this course.", 403);
|
|
572
|
+
const current = user$1(actor);
|
|
573
|
+
invariant(current.role >= 20 && (current.role >= 50 || course.createdBy === current.id || course.instructorIds.includes(current.id)), "FORBIDDEN", "You are not an instructor for this course.", 403);
|
|
304
574
|
return current;
|
|
305
575
|
}
|
|
306
576
|
async saveCourse(actor, input) {
|
|
307
|
-
const currentActor = user(actor);
|
|
577
|
+
const currentActor = user$1(actor);
|
|
308
578
|
const parsed = parse(courseSaveSchema, input);
|
|
309
579
|
const id = parsed.id ?? crypto.randomUUID();
|
|
310
|
-
|
|
580
|
+
let current = await this.stores.courses.getVersioned(id);
|
|
311
581
|
if (current) this.instructor(current.value, currentActor);
|
|
312
582
|
else invariant(currentActor.role >= 20, "FORBIDDEN", "Course authoring requires a contributor account or higher.", 403);
|
|
313
583
|
invariant((current?.revision ?? null) === parsed.expectedRevision, "CONFLICT", "The course changed since you opened it. Reload before saving.", 409);
|
|
314
|
-
if (parsed.data.status === "published") invariant(currentActor.role >= 50 || currentActor.role >= 30, "FORBIDDEN", "Publishing requires an author account or higher.", 403);
|
|
315
|
-
invariant(parsed.data.status !== "published" || parsed.data.commerce.accessModel === "free", "PAID_COURSES_DISABLED", "Payment processing is not enabled in this release. Publish free courses only.", 409);
|
|
584
|
+
if (!current && parsed.data.status === "published") invariant(currentActor.role >= 50 || currentActor.role >= 30, "FORBIDDEN", "Publishing requires an author account or higher.", 403);
|
|
585
|
+
invariant(current || parsed.data.status !== "published" || parsed.data.commerce.accessModel === "free", "PAID_COURSES_DISABLED", "Payment processing is not enabled in this release. Publish free courses only.", 409);
|
|
316
586
|
invariant(!(await this.stores.courses.query({
|
|
317
587
|
where: { slug: parsed.data.slug },
|
|
318
588
|
limit: 1
|
|
319
589
|
})).items.some((item) => item.id !== id), "SLUG_TAKEN", "A course already uses this URL.", 409);
|
|
320
590
|
await this.assertPrerequisiteGraph(id, parsed.data.prerequisiteCourseIds);
|
|
321
|
-
if (current
|
|
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 = {
|
|
591
|
+
if (current) current = await this.editions.ensure(id, current.revision);
|
|
592
|
+
let course = {
|
|
326
593
|
...parsed.data,
|
|
327
594
|
id,
|
|
328
595
|
searchTitle: parsed.data.title.toLocaleLowerCase("en"),
|
|
329
596
|
createdBy: current?.value.createdBy ?? currentActor.id,
|
|
330
597
|
createdAt: current?.value.createdAt ?? this.time(),
|
|
331
598
|
updatedAt: this.time(),
|
|
332
|
-
schemaVersion:
|
|
599
|
+
schemaVersion: 2
|
|
333
600
|
};
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
601
|
+
const draft = await this.editions.revision(current?.value ?? course, parsed.data, currentActor.id, "draft");
|
|
602
|
+
if (current) {
|
|
603
|
+
course = {
|
|
604
|
+
...current.value,
|
|
605
|
+
...current.value.publishedEditionId ? {} : parsed.data,
|
|
606
|
+
status: current.value.status,
|
|
607
|
+
draftRevisionId: draft.id,
|
|
608
|
+
updatedAt: this.time()
|
|
609
|
+
};
|
|
610
|
+
course.instructorIds = parsed.data.instructorIds;
|
|
611
|
+
} else {
|
|
612
|
+
course.draftRevisionId = draft.id;
|
|
613
|
+
if (course.status === "published") {
|
|
614
|
+
const editionId = await this.editions.snapshot(id, parsed.data);
|
|
615
|
+
const edition = await this.stores.course_editions.get(editionId);
|
|
616
|
+
course = {
|
|
617
|
+
...course,
|
|
618
|
+
...edition.data,
|
|
619
|
+
publishedEditionId: editionId,
|
|
620
|
+
publishedDraftRevisionId: draft.id
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
course.searchTitle = course.title.toLocaleLowerCase("en");
|
|
625
|
+
const saved = await this.editions.commit(current, course);
|
|
626
|
+
await this.audit(currentActor, "course.save", id, { draftRevisionId: draft.id });
|
|
627
|
+
return {
|
|
628
|
+
course: await this.editions.draft(course),
|
|
629
|
+
revision: saved.revision
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
async publishCourse(actor, id, expectedRevision) {
|
|
633
|
+
const owner = this.instructor(await this.course(id), actor);
|
|
634
|
+
invariant(owner.role >= 30, "FORBIDDEN", "Publishing requires an author account or higher.", 403);
|
|
635
|
+
const current = await this.editions.ensure(id, expectedRevision);
|
|
636
|
+
const data = parse(courseDataSchema, {
|
|
637
|
+
...await this.editions.draft(current.value),
|
|
638
|
+
status: "published"
|
|
639
|
+
});
|
|
640
|
+
invariant(data.commerce.accessModel === "free", "PAID_COURSES_DISABLED", "Publish free courses only.", 409);
|
|
641
|
+
await this.assertPrerequisiteGraph(id, data.prerequisiteCourseIds);
|
|
642
|
+
invariant(!(await this.stores.courses.query({
|
|
643
|
+
where: { slug: data.slug },
|
|
644
|
+
limit: 1
|
|
645
|
+
})).items.some((row) => row.id !== id), "SLUG_TAKEN", "A course already uses this URL.", 409);
|
|
646
|
+
const editionId = await this.editions.snapshot(id, data);
|
|
647
|
+
const revision = await this.editions.revision(current.value, data, owner.id, "publish", { editionId });
|
|
648
|
+
const edition = await this.stores.course_editions.get(editionId);
|
|
649
|
+
const next = {
|
|
650
|
+
...current.value,
|
|
651
|
+
...edition.data,
|
|
652
|
+
searchTitle: data.title.toLocaleLowerCase("en"),
|
|
653
|
+
draftRevisionId: revision.id,
|
|
654
|
+
publishedDraftRevisionId: revision.id,
|
|
655
|
+
publishedEditionId: editionId,
|
|
656
|
+
updatedAt: this.time()
|
|
657
|
+
};
|
|
658
|
+
const saved = await this.editions.commit(current, next);
|
|
659
|
+
await this.audit(owner, "course.publish", id, { editionId });
|
|
338
660
|
return {
|
|
339
|
-
course,
|
|
661
|
+
course: await this.editions.draft(next),
|
|
340
662
|
revision: saved.revision
|
|
341
663
|
};
|
|
342
664
|
}
|
|
665
|
+
async courseHistory(actor, id, cursor) {
|
|
666
|
+
const course = await this.course(id);
|
|
667
|
+
this.instructor(course, actor);
|
|
668
|
+
return this.editions.history(course, cursor);
|
|
669
|
+
}
|
|
670
|
+
async restoreCourse(actor, id, expectedRevision, revisionId) {
|
|
671
|
+
const owner = this.instructor(await this.course(id), actor);
|
|
672
|
+
const current = await this.editions.ensure(id, expectedRevision);
|
|
673
|
+
invariant((await this.editions.history(current.value, revisionId)).items[0]?.id === revisionId, "NOT_FOUND", "Saved revision not found.", 404);
|
|
674
|
+
const data = {
|
|
675
|
+
...(await this.stores.course_revisions.get(revisionId)).data,
|
|
676
|
+
instructorIds: current.value.instructorIds
|
|
677
|
+
};
|
|
678
|
+
const revision = await this.editions.revision(current.value, data, owner.id, "restore", { restoredFrom: revisionId });
|
|
679
|
+
const next = {
|
|
680
|
+
...current.value,
|
|
681
|
+
draftRevisionId: revision.id,
|
|
682
|
+
updatedAt: this.time()
|
|
683
|
+
};
|
|
684
|
+
const saved = await this.editions.commit(current, next);
|
|
685
|
+
await this.audit(owner, "course.restore", id, { revisionId });
|
|
686
|
+
return {
|
|
687
|
+
course: await this.editions.draft(next),
|
|
688
|
+
revision: saved.revision
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
async archiveCourse(actor, id, expectedRevision) {
|
|
692
|
+
const owner = this.instructor(await this.course(id), actor);
|
|
693
|
+
invariant(owner.role >= 30, "FORBIDDEN", "Archiving requires an author account or higher.", 403);
|
|
694
|
+
const legacy = await this.stores.courses.getVersioned(id);
|
|
695
|
+
if (legacy?.value.schemaVersion === 1) {
|
|
696
|
+
invariant(legacy.revision === expectedRevision, "CONFLICT", "The course changed. Reload before continuing.", 409);
|
|
697
|
+
const next = {
|
|
698
|
+
...legacy.value,
|
|
699
|
+
status: "archived",
|
|
700
|
+
updatedAt: this.time()
|
|
701
|
+
};
|
|
702
|
+
const saved = await this.editions.commit(legacy, next);
|
|
703
|
+
await this.audit(owner, "course.archive", id);
|
|
704
|
+
return {
|
|
705
|
+
course: next,
|
|
706
|
+
revision: saved.revision
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
const current = await this.editions.ensure(id, expectedRevision);
|
|
710
|
+
const draft = await this.editions.draft(current.value);
|
|
711
|
+
const revision = await this.editions.revision(current.value, draft, owner.id, "archive");
|
|
712
|
+
const next = {
|
|
713
|
+
...current.value,
|
|
714
|
+
status: "archived",
|
|
715
|
+
draftRevisionId: revision.id,
|
|
716
|
+
updatedAt: this.time()
|
|
717
|
+
};
|
|
718
|
+
const saved = await this.editions.commit(current, next);
|
|
719
|
+
await this.audit(owner, "course.archive", id);
|
|
720
|
+
return {
|
|
721
|
+
course: await this.editions.draft(next),
|
|
722
|
+
revision: saved.revision
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
async migrationStatus(actor) {
|
|
726
|
+
invariant(user$1(actor).role >= 50, "FORBIDDEN", "Only administrators can manage migrations.", 403);
|
|
727
|
+
return this.editions.migrationStatus();
|
|
728
|
+
}
|
|
729
|
+
async migrateBatch(actor, limit) {
|
|
730
|
+
await this.migrationStatus(actor);
|
|
731
|
+
const state = await this.editions.migrateBatch(limit);
|
|
732
|
+
if (state.phase === "complete") await this.ctx.kv.set("schemaVersion", 2);
|
|
733
|
+
return state;
|
|
734
|
+
}
|
|
343
735
|
async assertPrerequisiteGraph(courseId, prerequisites) {
|
|
344
736
|
const visited = /* @__PURE__ */ new Set();
|
|
345
737
|
const visit = async (id) => {
|
|
@@ -357,12 +749,12 @@ var LmsService = class {
|
|
|
357
749
|
invariant(current, "NOT_FOUND", "Course not found.", 404);
|
|
358
750
|
this.instructor(current.value, actor);
|
|
359
751
|
return {
|
|
360
|
-
course: current.value,
|
|
752
|
+
course: await this.editions.draft(current.value),
|
|
361
753
|
revision: current.revision
|
|
362
754
|
};
|
|
363
755
|
}
|
|
364
756
|
async adminCourses(actor, options = {}) {
|
|
365
|
-
const current = user(actor);
|
|
757
|
+
const current = user$1(actor);
|
|
366
758
|
const page = await queryPage(this.stores.courses, {
|
|
367
759
|
where: options.status ? { status: options.status } : {},
|
|
368
760
|
orderBy: { updatedAt: "desc" },
|
|
@@ -371,7 +763,7 @@ var LmsService = class {
|
|
|
371
763
|
});
|
|
372
764
|
return {
|
|
373
765
|
...page,
|
|
374
|
-
items: page.items.filter((course) => current.role >= 50 || course.createdBy === current.id || course.instructorIds.includes(current.id))
|
|
766
|
+
items: await Promise.all(page.items.filter((course) => current.role >= 20 && (current.role >= 50 || course.createdBy === current.id || course.instructorIds.includes(current.id))).map((course) => this.editions.draft(course)))
|
|
375
767
|
};
|
|
376
768
|
}
|
|
377
769
|
async catalog(input) {
|
|
@@ -449,6 +841,13 @@ var LmsService = class {
|
|
|
449
841
|
portableText: []
|
|
450
842
|
};
|
|
451
843
|
}
|
|
844
|
+
async authorLessonPreview(actor, courseId, lessonId) {
|
|
845
|
+
const { course } = await this.editableCourse(actor, courseId);
|
|
846
|
+
return {
|
|
847
|
+
course: courseDetail(course),
|
|
848
|
+
lesson: publicLesson(await this.resolveContent(findLesson(course, lessonId)))
|
|
849
|
+
};
|
|
850
|
+
}
|
|
452
851
|
async assertPrerequisites(course, actorId) {
|
|
453
852
|
for (const id of course.prerequisiteCourseIds) invariant((await this.stores.enrollments.get(enrollmentKey(id, actorId)))?.completedAt, "COURSE_PREREQUISITE_REQUIRED", "Complete the prerequisite courses before enrolling.", 403);
|
|
454
853
|
}
|
|
@@ -458,6 +857,7 @@ var LmsService = class {
|
|
|
458
857
|
return {
|
|
459
858
|
id: enrollmentKey(course.id, actor.id),
|
|
460
859
|
courseId: course.id,
|
|
860
|
+
courseEditionId: course.publishedEditionId ?? course.legacyEditionId,
|
|
461
861
|
userId: actor.id,
|
|
462
862
|
learnerName: actor.name || actor.email.split("@")[0] || "Learner",
|
|
463
863
|
learnerEmail: actor.email,
|
|
@@ -501,10 +901,12 @@ var LmsService = class {
|
|
|
501
901
|
};
|
|
502
902
|
}
|
|
503
903
|
async enroll(actor, courseId, options = {}) {
|
|
504
|
-
const current = user(actor);
|
|
505
|
-
|
|
904
|
+
const current = user$1(actor);
|
|
905
|
+
let course = await this.course(courseId);
|
|
506
906
|
invariant(course.status === "published", "NOT_FOUND", "Course not found.", 404);
|
|
507
907
|
invariant(course.commerce.accessModel === "free", "PAID_COURSES_DISABLED", "Payment processing is not enabled for this course.", 403);
|
|
908
|
+
course = (await this.editions.ensure(courseId)).value;
|
|
909
|
+
invariant(course.status === "published", "NOT_FOUND", "Course not found.", 404);
|
|
508
910
|
const cohort = options.cohortId ? await this.stores.cohorts.get(options.cohortId) : null;
|
|
509
911
|
if (options.cohortId) invariant(cohort && cohort.courseIds.includes(courseId), "NOT_FOUND", "This course is not part of that cohort.", 404);
|
|
510
912
|
if (cohort) invariant((await this.stores.cohort_enrollments.get(enrollmentKey(cohort.id, current.id)))?.status === "active", "COHORT_APPROVAL_REQUIRED", "Request cohort membership and wait for administrator approval.", 403);
|
|
@@ -554,10 +956,11 @@ var LmsService = class {
|
|
|
554
956
|
}
|
|
555
957
|
async adminEnrollment(actor, input) {
|
|
556
958
|
const parsed = parse(enrollmentAdminSchema, input);
|
|
557
|
-
|
|
959
|
+
let course = await this.course(parsed.courseId);
|
|
558
960
|
const currentActor = this.instructor(course, actor);
|
|
559
961
|
invariant(currentActor.role >= 50, "FORBIDDEN", "Only administrators can make enrollment decisions.", 403);
|
|
560
962
|
invariant(this.ctx.users, "USERS_UNAVAILABLE", "The user directory is unavailable.", 503);
|
|
963
|
+
if (parsed.status === "active" && !await this.stores.enrollments.get(enrollmentKey(course.id, parsed.userId))) course = (await this.editions.ensure(parsed.courseId)).value;
|
|
561
964
|
const target = await this.ctx.users.get(parsed.userId);
|
|
562
965
|
invariant(target, "NOT_FOUND", "Learner account not found.", 404);
|
|
563
966
|
invariant(!parsed.accessStartsAt || !parsed.accessExpiresAt || Date.parse(parsed.accessExpiresAt) > Date.parse(parsed.accessStartsAt), "INVALID_ACCESS_WINDOW", "Access expiry must follow its start.");
|
|
@@ -576,6 +979,7 @@ var LmsService = class {
|
|
|
576
979
|
const enrollment = await change(this.stores.enrollments, enrollmentKey(course.id, target.id), async (stored) => {
|
|
577
980
|
const next = structuredClone(stored ?? this.newEnrollment(course, target, parsed.status));
|
|
578
981
|
const firstApproval = parsed.status === "active" && !next.approvedAt;
|
|
982
|
+
next.courseEditionId ??= course.legacyEditionId ?? course.publishedEditionId;
|
|
579
983
|
next.status = parsed.status === "active" && next.completedAt ? "completed" : parsed.status;
|
|
580
984
|
next.accessStatus = parsed.status === "active" ? "granted" : parsed.status === "suspended" ? "suspended" : "revoked";
|
|
581
985
|
next.approvedBy = parsed.status === "active" ? currentActor.id : next.approvedBy;
|
|
@@ -604,7 +1008,7 @@ var LmsService = class {
|
|
|
604
1008
|
}
|
|
605
1009
|
invariant(!next.accessStartsAt || !next.accessExpiresAt || Date.parse(next.accessExpiresAt) > Date.parse(next.accessStartsAt), "INVALID_ACCESS_WINDOW", "Access expiry must follow its start.");
|
|
606
1010
|
next.lastActivityAt = this.time();
|
|
607
|
-
if (parsed.status === "active") this.finish(course, next, settings);
|
|
1011
|
+
if (parsed.status === "active") this.finish(await this.editions.learning(course, next), next, settings);
|
|
608
1012
|
return {
|
|
609
1013
|
value: next,
|
|
610
1014
|
result: next
|
|
@@ -613,22 +1017,21 @@ var LmsService = class {
|
|
|
613
1017
|
await this.audit(currentActor, "enrollment.update", enrollment.id, { status: enrollment.status });
|
|
614
1018
|
await this.notify(enrollment, `enrollment-${enrollment.status}-${enrollment.decisionAt}`, "Enrollment updated", `${course.title}: your enrollment is ${enrollment.status}.`);
|
|
615
1019
|
await this.projectCompletion(enrollment);
|
|
616
|
-
return this.
|
|
1020
|
+
return staffEnrollment(await this.accessEnrollment(enrollment), this.now());
|
|
617
1021
|
}
|
|
618
1022
|
async learnerCourse(actor, courseId) {
|
|
619
|
-
const current = user(actor);
|
|
1023
|
+
const current = user$1(actor);
|
|
620
1024
|
const course = await this.course(courseId);
|
|
621
1025
|
const enrollment = await this.stores.enrollments.get(enrollmentKey(courseId, current.id));
|
|
622
1026
|
invariant(course.status === "published" || course.status === "archived" && enrollment, "NOT_FOUND", "Course not found.", 404);
|
|
623
1027
|
if (enrollment?.completedAt) await this.projectCompletion(enrollment);
|
|
624
1028
|
return {
|
|
625
|
-
course: await this.detail(course),
|
|
626
|
-
|
|
627
|
-
progress: progressSummary(course, enrollment)
|
|
1029
|
+
course: await this.detail(await this.editions.learning(course, enrollment)),
|
|
1030
|
+
...await this.learningState(course, enrollment)
|
|
628
1031
|
};
|
|
629
1032
|
}
|
|
630
1033
|
async learnerCourseBySlug(actor, slug) {
|
|
631
|
-
user(actor);
|
|
1034
|
+
user$1(actor);
|
|
632
1035
|
const page = await this.stores.courses.query({
|
|
633
1036
|
where: { slug },
|
|
634
1037
|
limit: 1
|
|
@@ -637,7 +1040,7 @@ var LmsService = class {
|
|
|
637
1040
|
return this.learnerCourse(actor, page.items[0].id);
|
|
638
1041
|
}
|
|
639
1042
|
async dashboard(actor, options = {}) {
|
|
640
|
-
const current = user(actor);
|
|
1043
|
+
const current = user$1(actor);
|
|
641
1044
|
const page = await queryPage(this.stores.enrollments, {
|
|
642
1045
|
where: { userId: current.id },
|
|
643
1046
|
orderBy: { enrolledAt: "desc" },
|
|
@@ -649,9 +1052,8 @@ var LmsService = class {
|
|
|
649
1052
|
if (enrollment.completedAt) await this.projectCompletion(enrollment);
|
|
650
1053
|
const course = courses.get(enrollment.courseId);
|
|
651
1054
|
return course ? {
|
|
652
|
-
course: courseCard(course),
|
|
653
|
-
|
|
654
|
-
progress: progressSummary(course, enrollment)
|
|
1055
|
+
course: courseCard(await this.editions.learning(course, enrollment)),
|
|
1056
|
+
...await this.learningState(course, enrollment)
|
|
655
1057
|
} : null;
|
|
656
1058
|
}))).filter((row) => row !== null);
|
|
657
1059
|
return {
|
|
@@ -660,19 +1062,18 @@ var LmsService = class {
|
|
|
660
1062
|
};
|
|
661
1063
|
}
|
|
662
1064
|
async learnerLesson(actor, courseId, lessonId) {
|
|
663
|
-
const current = user(actor);
|
|
1065
|
+
const current = user$1(actor);
|
|
664
1066
|
const course = await this.course(courseId);
|
|
665
1067
|
const enrollment = await this.stores.enrollments.get(enrollmentKey(courseId, current.id));
|
|
666
1068
|
const lesson = await this.lessonAccess(course, lessonId, enrollment);
|
|
667
1069
|
return {
|
|
668
|
-
course: courseDetail(course),
|
|
1070
|
+
course: courseDetail(await this.editions.learning(course, enrollment)),
|
|
669
1071
|
lesson: publicLesson(await this.resolveContent(lesson)),
|
|
670
|
-
|
|
671
|
-
progress: progressSummary(course, enrollment)
|
|
1072
|
+
...await this.learningState(course, enrollment)
|
|
672
1073
|
};
|
|
673
1074
|
}
|
|
674
1075
|
async startLesson(actor, courseId, lessonId) {
|
|
675
|
-
const current = user(actor);
|
|
1076
|
+
const current = user$1(actor);
|
|
676
1077
|
const course = await this.course(courseId);
|
|
677
1078
|
const enrollment = await change(this.stores.enrollments, enrollmentKey(courseId, current.id), async (stored) => {
|
|
678
1079
|
await this.lessonAccess(course, lessonId, stored);
|
|
@@ -689,7 +1090,7 @@ var LmsService = class {
|
|
|
689
1090
|
return await this.enrollmentView(enrollment);
|
|
690
1091
|
}
|
|
691
1092
|
async completeLesson(actor, courseId, lessonId) {
|
|
692
|
-
const current = user(actor);
|
|
1093
|
+
const current = user$1(actor);
|
|
693
1094
|
const course = await this.course(courseId);
|
|
694
1095
|
const settings = await this.settings();
|
|
695
1096
|
const enrollment = await change(this.stores.enrollments, enrollmentKey(courseId, current.id), async (stored) => {
|
|
@@ -702,17 +1103,14 @@ var LmsService = class {
|
|
|
702
1103
|
invariant(this.now().getTime() - Date.parse(progress.startedAt) >= lesson.minStudySeconds * 1e3, "STUDY_TIME_REQUIRED", "Spend the minimum study time on this lesson before completing it.", 403);
|
|
703
1104
|
progress.completedAt ??= this.time();
|
|
704
1105
|
next.progress[lessonId] = progress;
|
|
705
|
-
this.finish(course, next, settings);
|
|
1106
|
+
this.finish(await this.editions.learning(course, next), next, settings);
|
|
706
1107
|
return {
|
|
707
1108
|
value: next,
|
|
708
1109
|
result: next
|
|
709
1110
|
};
|
|
710
1111
|
});
|
|
711
1112
|
await this.projectCompletion(enrollment);
|
|
712
|
-
return {
|
|
713
|
-
enrollment: await this.enrollmentView(enrollment),
|
|
714
|
-
progress: progressSummary(course, enrollment)
|
|
715
|
-
};
|
|
1113
|
+
return { ...await this.learningState(course, enrollment) };
|
|
716
1114
|
}
|
|
717
1115
|
finish(course, enrollment, settings) {
|
|
718
1116
|
enrollment.lastActivityAt = this.time();
|
|
@@ -743,7 +1141,7 @@ var LmsService = class {
|
|
|
743
1141
|
if (enrollment.completedAt) await this.notify(enrollment, "completed", "Course complete", "You have completed your course. Your achievement is saved in My Learning.");
|
|
744
1142
|
}
|
|
745
1143
|
async startQuiz(actor, courseId, lessonId) {
|
|
746
|
-
const current = user(actor);
|
|
1144
|
+
const current = user$1(actor);
|
|
747
1145
|
const course = await this.course(courseId);
|
|
748
1146
|
const result = await change(this.stores.enrollments, enrollmentKey(courseId, current.id), async (stored) => {
|
|
749
1147
|
const lesson = await this.lessonAccess(course, lessonId, stored);
|
|
@@ -790,7 +1188,8 @@ var LmsService = class {
|
|
|
790
1188
|
percent: null,
|
|
791
1189
|
passed: false,
|
|
792
1190
|
feedback: [],
|
|
793
|
-
answers: {}
|
|
1191
|
+
answers: {},
|
|
1192
|
+
draftRevision: 0
|
|
794
1193
|
};
|
|
795
1194
|
progress.quizAttempts.push(attempt);
|
|
796
1195
|
next.lastActivityAt = this.time();
|
|
@@ -806,7 +1205,7 @@ var LmsService = class {
|
|
|
806
1205
|
return publicAttempt(result.attempt);
|
|
807
1206
|
}
|
|
808
1207
|
async submitQuiz(actor, input) {
|
|
809
|
-
const current = user(actor);
|
|
1208
|
+
const current = user$1(actor);
|
|
810
1209
|
const parsed = parse(quizSubmitSchema, input);
|
|
811
1210
|
const course = await this.course(parsed.courseId);
|
|
812
1211
|
const settings = await this.settings();
|
|
@@ -838,6 +1237,7 @@ var LmsService = class {
|
|
|
838
1237
|
};
|
|
839
1238
|
}
|
|
840
1239
|
invariant(attempt.quiz, "INVALID_ATTEMPT", "This attempt cannot be graded.", 409);
|
|
1240
|
+
invariant(parsed.expectedDraftRevision === void 0 || parsed.expectedDraftRevision === (attempt.draftRevision ?? 0), "DRAFT_CONFLICT", "Saved answers changed in another request. Review them before submitting.", 409);
|
|
841
1241
|
const graded = gradeAnswers(attempt.quiz.questions, parsed.answers);
|
|
842
1242
|
attempt.passed = graded.score * 100 >= graded.maxScore * attempt.quiz.passingPercent;
|
|
843
1243
|
attempt.feedback = attempt.quiz.showFeedback === "after_submit" || attempt.quiz.showFeedback === "after_pass" && attempt.passed ? graded.feedback : [];
|
|
@@ -849,7 +1249,7 @@ var LmsService = class {
|
|
|
849
1249
|
attempt.submittedAt = this.time();
|
|
850
1250
|
attempt.quiz = null;
|
|
851
1251
|
if (attempt.passed) progress.completedAt ??= this.time();
|
|
852
|
-
this.finish(course, next, settings);
|
|
1252
|
+
this.finish(await this.editions.learning(course, next), next, settings);
|
|
853
1253
|
return {
|
|
854
1254
|
value: next,
|
|
855
1255
|
result: {
|
|
@@ -863,31 +1263,40 @@ var LmsService = class {
|
|
|
863
1263
|
await this.projectCompletion(result.enrollment);
|
|
864
1264
|
return {
|
|
865
1265
|
attempt: publicAttempt(result.attempt),
|
|
866
|
-
|
|
867
|
-
progress: progressSummary(course, result.enrollment)
|
|
1266
|
+
...await this.learningState(course, result.enrollment)
|
|
868
1267
|
};
|
|
869
1268
|
}
|
|
870
1269
|
async saveQuizDraft(actor, input) {
|
|
871
|
-
const current = user(actor);
|
|
872
|
-
const parsed = parse(
|
|
1270
|
+
const current = user$1(actor);
|
|
1271
|
+
const parsed = parse(quizDraftSchema, input);
|
|
873
1272
|
const course = await this.course(parsed.courseId);
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1273
|
+
return {
|
|
1274
|
+
saved: true,
|
|
1275
|
+
draftRevision: await change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
|
|
1276
|
+
await this.lessonAccess(course, parsed.lessonId, stored);
|
|
1277
|
+
const next = structuredClone(stored);
|
|
1278
|
+
const attempt = next.progress[parsed.lessonId]?.quizAttempts.find((item) => item.id === parsed.attemptId);
|
|
1279
|
+
invariant(attempt?.status === "in_progress" && attempt.quiz, "ATTEMPT_CLOSED", "This quiz attempt is closed.", 409);
|
|
1280
|
+
invariant(!attempt.expiresAt || this.now().getTime() < Date.parse(attempt.expiresAt), "QUIZ_EXPIRED", "This quiz attempt has expired.", 409);
|
|
1281
|
+
gradeAnswers(attempt.quiz.questions, parsed.answers);
|
|
1282
|
+
const revision = attempt.draftRevision ?? 0;
|
|
1283
|
+
const canonical = (answers) => JSON.stringify(Object.entries(answers).sort(([a], [b]) => a.localeCompare(b)));
|
|
1284
|
+
if (revision === parsed.expectedDraftRevision + 1 && canonical(attempt.answers) === canonical(parsed.answers)) return {
|
|
1285
|
+
value: next,
|
|
1286
|
+
result: revision
|
|
1287
|
+
};
|
|
1288
|
+
invariant(revision === parsed.expectedDraftRevision, "DRAFT_CONFLICT", "Saved answers changed in another request. Review them before saving.", 409);
|
|
1289
|
+
attempt.answers = parsed.answers;
|
|
1290
|
+
attempt.draftRevision = revision + 1;
|
|
1291
|
+
return {
|
|
1292
|
+
value: next,
|
|
1293
|
+
result: attempt.draftRevision
|
|
1294
|
+
};
|
|
1295
|
+
})
|
|
1296
|
+
};
|
|
888
1297
|
}
|
|
889
1298
|
async submitAssignment(actor, input) {
|
|
890
|
-
const current = user(actor);
|
|
1299
|
+
const current = user$1(actor);
|
|
891
1300
|
const parsed = parse(assignmentSubmitSchema, input);
|
|
892
1301
|
const course = await this.course(parsed.courseId);
|
|
893
1302
|
const enrollment = await change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
|
|
@@ -947,7 +1356,7 @@ var LmsService = class {
|
|
|
947
1356
|
"suspended",
|
|
948
1357
|
"revoked",
|
|
949
1358
|
"pending"
|
|
950
|
-
].includes(access)) this.finish(course, next, settings);
|
|
1359
|
+
].includes(access)) this.finish(await this.editions.learning(course, next), next, settings);
|
|
951
1360
|
return {
|
|
952
1361
|
value: next,
|
|
953
1362
|
result: next
|
|
@@ -959,10 +1368,10 @@ var LmsService = class {
|
|
|
959
1368
|
lessonId: parsed.lessonId,
|
|
960
1369
|
points: parsed.points
|
|
961
1370
|
});
|
|
962
|
-
return await this.
|
|
1371
|
+
return staffEnrollment(await this.accessEnrollment(enrollment), this.now());
|
|
963
1372
|
}
|
|
964
1373
|
async saveNote(actor, input) {
|
|
965
|
-
const current = user(actor);
|
|
1374
|
+
const current = user$1(actor);
|
|
966
1375
|
const parsed = parse(noteSchema, input);
|
|
967
1376
|
const course = await this.course(parsed.courseId);
|
|
968
1377
|
return { note: (await change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
|
|
@@ -978,14 +1387,14 @@ var LmsService = class {
|
|
|
978
1387
|
})).progress[parsed.lessonId].note };
|
|
979
1388
|
}
|
|
980
1389
|
async certificate(actor, courseId) {
|
|
981
|
-
const current = user(actor);
|
|
1390
|
+
const current = user$1(actor);
|
|
982
1391
|
const enrollment = await this.stores.enrollments.get(enrollmentKey(courseId, current.id));
|
|
983
1392
|
invariant(enrollment?.certificate, "NOT_FOUND", "Certificate not found.", 404);
|
|
984
1393
|
await this.projectCompletion(enrollment);
|
|
985
1394
|
return enrollment.certificate;
|
|
986
1395
|
}
|
|
987
1396
|
async ownedCertificate(actor, id) {
|
|
988
|
-
const current = user(actor);
|
|
1397
|
+
const current = user$1(actor);
|
|
989
1398
|
const index = await this.stores.certificates.get(id);
|
|
990
1399
|
invariant(index?.userId === current.id, "NOT_FOUND", "Certificate not found.", 404);
|
|
991
1400
|
const enrollment = await this.stores.enrollments.get(index.enrollmentId);
|
|
@@ -1008,7 +1417,7 @@ var LmsService = class {
|
|
|
1008
1417
|
};
|
|
1009
1418
|
}
|
|
1010
1419
|
async shareCertificate(actor, courseId, shared) {
|
|
1011
|
-
const current = user(actor);
|
|
1420
|
+
const current = user$1(actor);
|
|
1012
1421
|
return await change(this.stores.enrollments, enrollmentKey(courseId, current.id), async (stored) => {
|
|
1013
1422
|
invariant(stored?.certificate, "NOT_FOUND", "Certificate not found.", 404);
|
|
1014
1423
|
const next = structuredClone(stored);
|
|
@@ -1036,7 +1445,7 @@ var LmsService = class {
|
|
|
1036
1445
|
return certificate;
|
|
1037
1446
|
}
|
|
1038
1447
|
async cancelEnrollment(actor, courseId) {
|
|
1039
|
-
const current = user(actor);
|
|
1448
|
+
const current = user$1(actor);
|
|
1040
1449
|
const enrollment = await change(this.stores.enrollments, enrollmentKey(courseId, current.id), async (stored) => {
|
|
1041
1450
|
invariant(stored, "NOT_FOUND", "Enrollment not found.", 404);
|
|
1042
1451
|
const next = {
|
|
@@ -1052,7 +1461,7 @@ var LmsService = class {
|
|
|
1052
1461
|
return await this.enrollmentView(enrollment);
|
|
1053
1462
|
}
|
|
1054
1463
|
async discussions(actor, courseId, lessonId, cursor) {
|
|
1055
|
-
const current = user(actor);
|
|
1464
|
+
const current = user$1(actor);
|
|
1056
1465
|
const course = await this.course(courseId);
|
|
1057
1466
|
const enrollment = await this.stores.enrollments.get(enrollmentKey(courseId, current.id));
|
|
1058
1467
|
await this.lessonAccess(course, lessonId, enrollment);
|
|
@@ -1081,7 +1490,7 @@ var LmsService = class {
|
|
|
1081
1490
|
};
|
|
1082
1491
|
}
|
|
1083
1492
|
async postDiscussion(actor, input) {
|
|
1084
|
-
const current = user(actor);
|
|
1493
|
+
const current = user$1(actor);
|
|
1085
1494
|
const parsed = parse(discussionSchema, input);
|
|
1086
1495
|
const course = await this.course(parsed.courseId);
|
|
1087
1496
|
const enrollment = await this.stores.enrollments.get(enrollmentKey(course.id, current.id));
|
|
@@ -1117,7 +1526,7 @@ var LmsService = class {
|
|
|
1117
1526
|
});
|
|
1118
1527
|
}
|
|
1119
1528
|
async postReview(actor, input) {
|
|
1120
|
-
const current = user(actor);
|
|
1529
|
+
const current = user$1(actor);
|
|
1121
1530
|
const parsed = parse(reviewSchema, input);
|
|
1122
1531
|
const course = await this.course(parsed.courseId);
|
|
1123
1532
|
const enrollment = await this.accessEnrollment(await this.stores.enrollments.get(enrollmentKey(course.id, current.id)));
|
|
@@ -1159,7 +1568,7 @@ var LmsService = class {
|
|
|
1159
1568
|
return next;
|
|
1160
1569
|
}
|
|
1161
1570
|
async communityQueue(actor, collection, options = {}) {
|
|
1162
|
-
const current = user(actor);
|
|
1571
|
+
const current = user$1(actor);
|
|
1163
1572
|
if (options.courseId) this.instructor(await this.course(options.courseId), current);
|
|
1164
1573
|
else invariant(current.role >= 50, "FORBIDDEN", "Select a course you teach.", 403);
|
|
1165
1574
|
const store = this.stores[collection];
|
|
@@ -1182,7 +1591,7 @@ var LmsService = class {
|
|
|
1182
1591
|
};
|
|
1183
1592
|
}
|
|
1184
1593
|
async enrollments(actor, options = {}) {
|
|
1185
|
-
const current = user(actor);
|
|
1594
|
+
const current = user$1(actor);
|
|
1186
1595
|
if (options.courseId) this.instructor(await this.course(options.courseId), current);
|
|
1187
1596
|
else invariant(current.role >= 50, "FORBIDDEN", "Select a course you teach.", 403);
|
|
1188
1597
|
const page = await queryPage(this.stores.enrollments, {
|
|
@@ -1197,17 +1606,23 @@ var LmsService = class {
|
|
|
1197
1606
|
const courses = await this.stores.courses.getMany(page.items.map((item) => item.courseId));
|
|
1198
1607
|
return {
|
|
1199
1608
|
...page,
|
|
1200
|
-
items: await Promise.all(page.items.map(async (enrollment) =>
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1609
|
+
items: await Promise.all(page.items.map(async (enrollment) => {
|
|
1610
|
+
const resolved = await this.accessEnrollment(enrollment);
|
|
1611
|
+
const root = courses.get(enrollment.courseId);
|
|
1612
|
+
const course = root ? await this.editions.learning(root, enrollment) : null;
|
|
1613
|
+
const now = this.now();
|
|
1614
|
+
return {
|
|
1615
|
+
...staffEnrollment(resolved, now),
|
|
1616
|
+
learnerEmail: enrollment.learnerEmail,
|
|
1617
|
+
courseTitle: course?.title ?? "Archived course",
|
|
1618
|
+
lessonTitles: Object.fromEntries((course ? lessons(course) : []).map((lesson) => [lesson.id, lesson.title])),
|
|
1619
|
+
progressSummary: course ? progressSummary(course, resolved, now) : null
|
|
1620
|
+
};
|
|
1621
|
+
}))
|
|
1207
1622
|
};
|
|
1208
1623
|
}
|
|
1209
1624
|
async report(actor, courseId) {
|
|
1210
|
-
const current = user(actor);
|
|
1625
|
+
const current = user$1(actor);
|
|
1211
1626
|
if (courseId) this.instructor(await this.course(courseId), current);
|
|
1212
1627
|
else invariant(current.role >= 50, "FORBIDDEN", "Select a course you teach.", 403);
|
|
1213
1628
|
const filter = courseId ? { courseId } : {};
|
|
@@ -1279,7 +1694,7 @@ var LmsService = class {
|
|
|
1279
1694
|
return "" + lines.join("\r\n");
|
|
1280
1695
|
}
|
|
1281
1696
|
async cohortList(actor, cursor) {
|
|
1282
|
-
invariant(user(actor).role >= 50, "FORBIDDEN", "Only administrators can manage cohorts.", 403);
|
|
1697
|
+
invariant(user$1(actor).role >= 50, "FORBIDDEN", "Only administrators can manage cohorts.", 403);
|
|
1283
1698
|
const page = await this.stores.cohorts.query({
|
|
1284
1699
|
orderBy: { createdAt: "desc" },
|
|
1285
1700
|
limit: 24,
|
|
@@ -1307,7 +1722,7 @@ var LmsService = class {
|
|
|
1307
1722
|
};
|
|
1308
1723
|
}
|
|
1309
1724
|
async requestCohort(actor, cohortId) {
|
|
1310
|
-
const current = user(actor);
|
|
1725
|
+
const current = user$1(actor);
|
|
1311
1726
|
const cohort = await this.stores.cohorts.get(cohortId);
|
|
1312
1727
|
invariant(cohort, "NOT_FOUND", "Cohort not found.", 404);
|
|
1313
1728
|
invariant(!cohort.endsAt || this.now().getTime() < Date.parse(cohort.endsAt), "COHORT_CLOSED", "This cohort has ended.", 403);
|
|
@@ -1337,7 +1752,7 @@ var LmsService = class {
|
|
|
1337
1752
|
});
|
|
1338
1753
|
}
|
|
1339
1754
|
async cohortMemberships(actor, options = {}) {
|
|
1340
|
-
invariant(user(actor).role >= 50, "FORBIDDEN", "Only administrators can review cohort membership.", 403);
|
|
1755
|
+
invariant(user$1(actor).role >= 50, "FORBIDDEN", "Only administrators can review cohort membership.", 403);
|
|
1341
1756
|
return queryPage(this.stores.cohort_enrollments, {
|
|
1342
1757
|
where: {
|
|
1343
1758
|
...options.cohortId ? { cohortId: options.cohortId } : {},
|
|
@@ -1349,7 +1764,7 @@ var LmsService = class {
|
|
|
1349
1764
|
});
|
|
1350
1765
|
}
|
|
1351
1766
|
async decideCohort(actor, cohortId, userId, status, reason = "") {
|
|
1352
|
-
const current = user(actor);
|
|
1767
|
+
const current = user$1(actor);
|
|
1353
1768
|
invariant(current.role >= 50, "FORBIDDEN", "Only administrators can make cohort decisions.", 403);
|
|
1354
1769
|
const cohort = await this.stores.cohorts.get(cohortId);
|
|
1355
1770
|
invariant(cohort && this.ctx.users, "NOT_FOUND", "Cohort not found.", 404);
|
|
@@ -1421,7 +1836,7 @@ var LmsService = class {
|
|
|
1421
1836
|
};
|
|
1422
1837
|
}
|
|
1423
1838
|
async saveCohort(actor, input) {
|
|
1424
|
-
const current = user(actor);
|
|
1839
|
+
const current = user$1(actor);
|
|
1425
1840
|
invariant(current.role >= 50, "FORBIDDEN", "Only administrators can manage cohorts.", 403);
|
|
1426
1841
|
const parsed = parse(cohortSaveSchema, input);
|
|
1427
1842
|
for (const id of parsed.data.courseIds) await this.course(id);
|
|
@@ -1445,7 +1860,7 @@ var LmsService = class {
|
|
|
1445
1860
|
};
|
|
1446
1861
|
}
|
|
1447
1862
|
async applyCohort(actor, id, expectedRevision, offset) {
|
|
1448
|
-
const current = user(actor);
|
|
1863
|
+
const current = user$1(actor);
|
|
1449
1864
|
invariant(current.role >= 50, "FORBIDDEN", "Only administrators can enroll cohorts.", 403);
|
|
1450
1865
|
const stored = await this.stores.cohorts.getVersioned(id);
|
|
1451
1866
|
invariant(stored, "NOT_FOUND", "Cohort not found.", 404);
|
|
@@ -1476,6 +1891,7 @@ var LmsService = class {
|
|
|
1476
1891
|
}
|
|
1477
1892
|
async notify(enrollment, key, title, message) {
|
|
1478
1893
|
const settings = await this.settings();
|
|
1894
|
+
const preferences = await this.stores.preferences.get(enrollment.userId);
|
|
1479
1895
|
const id = `${enrollment.id}:${key}`;
|
|
1480
1896
|
const notification = {
|
|
1481
1897
|
id,
|
|
@@ -1486,14 +1902,14 @@ var LmsService = class {
|
|
|
1486
1902
|
courseId: enrollment.courseId,
|
|
1487
1903
|
read: false,
|
|
1488
1904
|
createdAt: this.time(),
|
|
1489
|
-
emailStatus: settings.emailNotifications ? "pending" : "disabled",
|
|
1905
|
+
emailStatus: settings.emailNotifications && preferences?.emailNotifications !== false ? "pending" : "disabled",
|
|
1490
1906
|
emailAttempts: 0,
|
|
1491
1907
|
claimedAt: null
|
|
1492
1908
|
};
|
|
1493
1909
|
await putOnce(this.stores.notifications, id, notification);
|
|
1494
1910
|
}
|
|
1495
1911
|
async notifications(actor, cursor) {
|
|
1496
|
-
const current = user(actor);
|
|
1912
|
+
const current = user$1(actor);
|
|
1497
1913
|
const page = await queryPage(this.stores.notifications, {
|
|
1498
1914
|
where: { userId: current.id },
|
|
1499
1915
|
orderBy: { createdAt: "desc" },
|
|
@@ -1506,7 +1922,7 @@ var LmsService = class {
|
|
|
1506
1922
|
};
|
|
1507
1923
|
}
|
|
1508
1924
|
async readNotification(actor, id) {
|
|
1509
|
-
const current = user(actor);
|
|
1925
|
+
const current = user$1(actor);
|
|
1510
1926
|
await change(this.stores.notifications, id, async (stored) => {
|
|
1511
1927
|
invariant(stored && stored.userId === current.id, "NOT_FOUND", "Notification not found.", 404);
|
|
1512
1928
|
return {
|
|
@@ -1561,6 +1977,17 @@ var LmsService = class {
|
|
|
1561
1977
|
delta: { emailAttempts: { inc: 1 } }
|
|
1562
1978
|
})).applied) continue;
|
|
1563
1979
|
try {
|
|
1980
|
+
if ((await this.stores.preferences.get(item.data.userId))?.emailNotifications === false) {
|
|
1981
|
+
await this.stores.notifications.updateIf(item.id, {
|
|
1982
|
+
where: {
|
|
1983
|
+
emailStatus: "sending",
|
|
1984
|
+
emailAttempts,
|
|
1985
|
+
claimedAt
|
|
1986
|
+
},
|
|
1987
|
+
set: { emailStatus: "disabled" }
|
|
1988
|
+
});
|
|
1989
|
+
continue;
|
|
1990
|
+
}
|
|
1564
1991
|
await this.ctx.email.send({
|
|
1565
1992
|
to: item.data.email,
|
|
1566
1993
|
subject: item.data.title,
|
|
@@ -1611,11 +2038,13 @@ var LmsService = class {
|
|
|
1611
2038
|
}
|
|
1612
2039
|
async exportCourse(actor, id) {
|
|
1613
2040
|
const { course } = await this.editableCourse(actor, id);
|
|
1614
|
-
const { id: _id, searchTitle: _search, createdBy: _creator, createdAt: _created, updatedAt: _updated, schemaVersion: _version, ...data } = course;
|
|
1615
2041
|
return {
|
|
1616
2042
|
format: "anantagnanam-lms-course",
|
|
1617
2043
|
version: 1,
|
|
1618
|
-
course:
|
|
2044
|
+
course: courseDataSchema.parse({
|
|
2045
|
+
...course,
|
|
2046
|
+
status: "draft"
|
|
2047
|
+
})
|
|
1619
2048
|
};
|
|
1620
2049
|
}
|
|
1621
2050
|
async importCourse(actor, input) {
|
|
@@ -1642,7 +2071,7 @@ var LmsService = class {
|
|
|
1642
2071
|
return { deleted: true };
|
|
1643
2072
|
}
|
|
1644
2073
|
async retryNotification(actor, id) {
|
|
1645
|
-
invariant(user(actor).role >= 50, "FORBIDDEN", "Only administrators can retry email delivery.", 403);
|
|
2074
|
+
invariant(user$1(actor).role >= 50, "FORBIDDEN", "Only administrators can retry email delivery.", 403);
|
|
1646
2075
|
invariant((await this.stores.notifications.updateIf(id, {
|
|
1647
2076
|
where: {
|
|
1648
2077
|
emailStatus: "failed",
|
|
@@ -1657,7 +2086,7 @@ var LmsService = class {
|
|
|
1657
2086
|
return { queued: true };
|
|
1658
2087
|
}
|
|
1659
2088
|
async notificationQueue(actor, options = {}) {
|
|
1660
|
-
invariant(user(actor).role >= 50, "FORBIDDEN", "Administrator access required.", 403);
|
|
2089
|
+
invariant(user$1(actor).role >= 50, "FORBIDDEN", "Administrator access required.", 403);
|
|
1661
2090
|
return queryPage(this.stores.notifications, {
|
|
1662
2091
|
where: options.status ? { emailStatus: options.status } : {},
|
|
1663
2092
|
orderBy: { createdAt: "desc" },
|
|
@@ -1666,7 +2095,7 @@ var LmsService = class {
|
|
|
1666
2095
|
});
|
|
1667
2096
|
}
|
|
1668
2097
|
async auditList(actor, options = {}) {
|
|
1669
|
-
invariant(user(actor).role >= 50, "FORBIDDEN", "Administrator access required.", 403);
|
|
2098
|
+
invariant(user$1(actor).role >= 50, "FORBIDDEN", "Administrator access required.", 403);
|
|
1670
2099
|
return queryPage(this.stores.audit, {
|
|
1671
2100
|
orderBy: { createdAt: "desc" },
|
|
1672
2101
|
limit: options.limit ?? 30,
|
|
@@ -1696,6 +2125,292 @@ var LmsService = class {
|
|
|
1696
2125
|
}
|
|
1697
2126
|
};
|
|
1698
2127
|
|
|
2128
|
+
//#endregion
|
|
2129
|
+
//#region src/learning.ts
|
|
2130
|
+
const exportSchema = pageSchema.extend({ section: z.enum([
|
|
2131
|
+
"enrollments",
|
|
2132
|
+
"discussions",
|
|
2133
|
+
"reviews",
|
|
2134
|
+
"notifications",
|
|
2135
|
+
"saved_courses",
|
|
2136
|
+
"path_enrollments",
|
|
2137
|
+
"cohort_enrollments"
|
|
2138
|
+
]) });
|
|
2139
|
+
function user(actor) {
|
|
2140
|
+
invariant(actor, "UNAUTHORIZED", "Sign in to continue.", 401);
|
|
2141
|
+
return actor;
|
|
2142
|
+
}
|
|
2143
|
+
function admin(actor) {
|
|
2144
|
+
const current = user(actor);
|
|
2145
|
+
invariant(current.role >= 50, "FORBIDDEN", "Administrator access required.", 403);
|
|
2146
|
+
return current;
|
|
2147
|
+
}
|
|
2148
|
+
function pathPage(input) {
|
|
2149
|
+
const options = parse(pageSchema, input);
|
|
2150
|
+
return {
|
|
2151
|
+
...options,
|
|
2152
|
+
limit: Math.min(options.limit, 8)
|
|
2153
|
+
};
|
|
2154
|
+
}
|
|
2155
|
+
/** Optional learning features share the existing access and immutable-edition service. */
|
|
2156
|
+
var LearningFeatures = class {
|
|
2157
|
+
constructor(service) {
|
|
2158
|
+
this.service = service;
|
|
2159
|
+
}
|
|
2160
|
+
get stores() {
|
|
2161
|
+
return this.service.stores;
|
|
2162
|
+
}
|
|
2163
|
+
async preferences(actor) {
|
|
2164
|
+
const stored = await this.stores.preferences.getVersioned(user(actor).id);
|
|
2165
|
+
return {
|
|
2166
|
+
value: preferencesSchema.parse(stored?.value ?? {}),
|
|
2167
|
+
revision: stored?.revision ?? null
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
async savePreferences(actor, input) {
|
|
2171
|
+
const current = user(actor);
|
|
2172
|
+
const parsed = parse(z.object({
|
|
2173
|
+
data: preferencesSchema,
|
|
2174
|
+
expectedRevision: z.string().max(128).nullable()
|
|
2175
|
+
}), input);
|
|
2176
|
+
const saved = await this.stores.preferences.compareAndSet(current.id, parsed.expectedRevision, parsed.data);
|
|
2177
|
+
invariant(saved.applied, "CONFLICT", "Preferences changed in another tab. Reload before saving.", 409);
|
|
2178
|
+
return {
|
|
2179
|
+
value: parsed.data,
|
|
2180
|
+
revision: saved.revision
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
2183
|
+
async saveCourse(actor, input) {
|
|
2184
|
+
const current = user(actor);
|
|
2185
|
+
const parsed = parse(courseIdSchema.extend({ saved: z.boolean() }), input);
|
|
2186
|
+
if (parsed.saved) invariant((await this.service.course(parsed.courseId)).status === "published", "NOT_FOUND", "Course not found.", 404);
|
|
2187
|
+
await change(this.stores.saved_courses, enrollmentKey(parsed.courseId, current.id), () => ({
|
|
2188
|
+
value: {
|
|
2189
|
+
userId: current.id,
|
|
2190
|
+
courseId: parsed.courseId,
|
|
2191
|
+
saved: parsed.saved,
|
|
2192
|
+
updatedAt: this.service.time()
|
|
2193
|
+
},
|
|
2194
|
+
result: true
|
|
2195
|
+
}));
|
|
2196
|
+
return { saved: parsed.saved };
|
|
2197
|
+
}
|
|
2198
|
+
async savedCourses(actor, input) {
|
|
2199
|
+
const current = user(actor);
|
|
2200
|
+
const options = parse(pageSchema, input);
|
|
2201
|
+
const page = await queryPage(this.stores.saved_courses, {
|
|
2202
|
+
...options,
|
|
2203
|
+
where: {
|
|
2204
|
+
userId: current.id,
|
|
2205
|
+
saved: true
|
|
2206
|
+
},
|
|
2207
|
+
orderBy: { updatedAt: "desc" }
|
|
2208
|
+
});
|
|
2209
|
+
return {
|
|
2210
|
+
...page,
|
|
2211
|
+
items: await Promise.all(page.items.map(async (item) => ({
|
|
2212
|
+
courseId: item.courseId,
|
|
2213
|
+
course: await this.card(item.courseId)
|
|
2214
|
+
})))
|
|
2215
|
+
};
|
|
2216
|
+
}
|
|
2217
|
+
async card(courseId) {
|
|
2218
|
+
const course = await this.stores.courses.get(courseId);
|
|
2219
|
+
return course?.status === "published" ? courseCard(course) : null;
|
|
2220
|
+
}
|
|
2221
|
+
async savePath(actor, input) {
|
|
2222
|
+
const current = admin(actor);
|
|
2223
|
+
const parsed = parse(pathSaveSchema, input);
|
|
2224
|
+
const id = parsed.id ?? crypto.randomUUID();
|
|
2225
|
+
const existing = await this.stores.learning_paths.getVersioned(id);
|
|
2226
|
+
invariant(!parsed.id || existing, "NOT_FOUND", "Learning path not found.", 404);
|
|
2227
|
+
invariant((existing?.revision ?? null) === parsed.expectedRevision, "CONFLICT", "This path changed. Reload before saving.", 409);
|
|
2228
|
+
for (const courseId of parsed.data.courseIds) {
|
|
2229
|
+
const course = await this.service.course(courseId);
|
|
2230
|
+
invariant(parsed.data.status !== "published" || course.status === "published", "VALIDATION_ERROR", "Publish all courses before publishing the path.");
|
|
2231
|
+
}
|
|
2232
|
+
const path = {
|
|
2233
|
+
...parsed.data,
|
|
2234
|
+
id,
|
|
2235
|
+
createdAt: existing?.value.createdAt ?? this.service.time(),
|
|
2236
|
+
updatedAt: this.service.time()
|
|
2237
|
+
};
|
|
2238
|
+
assertSize(path);
|
|
2239
|
+
const saved = await this.stores.learning_paths.compareAndSet(id, parsed.expectedRevision, path);
|
|
2240
|
+
invariant(saved.applied, "CONFLICT", "This path changed. Reload before saving.", 409);
|
|
2241
|
+
await this.service.audit(current, "path.save", id);
|
|
2242
|
+
return {
|
|
2243
|
+
path,
|
|
2244
|
+
revision: saved.revision
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
2247
|
+
async adminPaths(actor, input) {
|
|
2248
|
+
admin(actor);
|
|
2249
|
+
const page = await this.stores.learning_paths.query({
|
|
2250
|
+
...parse(pageSchema, input),
|
|
2251
|
+
orderBy: { updatedAt: "desc" }
|
|
2252
|
+
});
|
|
2253
|
+
const versions = await Promise.all(page.items.map((item) => this.stores.learning_paths.getVersioned(item.id)));
|
|
2254
|
+
return {
|
|
2255
|
+
...page,
|
|
2256
|
+
items: versions.flatMap((item) => item ? [{
|
|
2257
|
+
...item.value,
|
|
2258
|
+
revision: item.revision
|
|
2259
|
+
}] : [])
|
|
2260
|
+
};
|
|
2261
|
+
}
|
|
2262
|
+
async paths(input) {
|
|
2263
|
+
const page = await queryPage(this.stores.learning_paths, {
|
|
2264
|
+
...pathPage(input),
|
|
2265
|
+
where: { status: "published" },
|
|
2266
|
+
orderBy: { updatedAt: "desc" }
|
|
2267
|
+
});
|
|
2268
|
+
const items = await Promise.all(page.items.map(async (path) => ({
|
|
2269
|
+
id: path.id,
|
|
2270
|
+
title: path.title,
|
|
2271
|
+
description: path.description,
|
|
2272
|
+
courses: await Promise.all(path.courseIds.map((id) => this.card(id)))
|
|
2273
|
+
})));
|
|
2274
|
+
return {
|
|
2275
|
+
...page,
|
|
2276
|
+
items
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2279
|
+
async joinPath(actor, input) {
|
|
2280
|
+
const current = user(actor);
|
|
2281
|
+
const { pathId } = parse(z.object({ pathId: idSchema }), input);
|
|
2282
|
+
const id = enrollmentKey(pathId, current.id);
|
|
2283
|
+
if (await this.stores.path_enrollments.get(id)) return { joined: true };
|
|
2284
|
+
const path = await this.stores.learning_paths.get(pathId);
|
|
2285
|
+
invariant(path?.status === "published", "NOT_FOUND", "Learning path not found.", 404);
|
|
2286
|
+
await putOnce(this.stores.path_enrollments, id, {
|
|
2287
|
+
pathId,
|
|
2288
|
+
userId: current.id,
|
|
2289
|
+
title: path.title,
|
|
2290
|
+
courseIds: path.courseIds,
|
|
2291
|
+
joinedAt: this.service.time()
|
|
2292
|
+
});
|
|
2293
|
+
return { joined: true };
|
|
2294
|
+
}
|
|
2295
|
+
async learnerPaths(actor, input) {
|
|
2296
|
+
const current = user(actor);
|
|
2297
|
+
const page = await queryPage(this.stores.path_enrollments, {
|
|
2298
|
+
...pathPage(input),
|
|
2299
|
+
where: { userId: current.id },
|
|
2300
|
+
orderBy: { joinedAt: "desc" }
|
|
2301
|
+
});
|
|
2302
|
+
const items = await Promise.all(page.items.map(async (path) => {
|
|
2303
|
+
const courses = await Promise.all(path.courseIds.map(async (courseId) => {
|
|
2304
|
+
const enrollment = await this.stores.enrollments.get(enrollmentKey(courseId, current.id));
|
|
2305
|
+
const course = await this.stores.courses.get(courseId);
|
|
2306
|
+
return {
|
|
2307
|
+
course: course && enrollment ? courseCard(await this.service.editions.learning(course, enrollment)) : await this.card(courseId),
|
|
2308
|
+
completed: Boolean(enrollment?.completedAt)
|
|
2309
|
+
};
|
|
2310
|
+
}));
|
|
2311
|
+
return {
|
|
2312
|
+
pathId: path.pathId,
|
|
2313
|
+
title: path.title,
|
|
2314
|
+
joinedAt: path.joinedAt,
|
|
2315
|
+
total: courses.length,
|
|
2316
|
+
completed: courses.filter((c) => c.completed).length,
|
|
2317
|
+
courses
|
|
2318
|
+
};
|
|
2319
|
+
}));
|
|
2320
|
+
return {
|
|
2321
|
+
...page,
|
|
2322
|
+
items
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
2325
|
+
async playback(actor, input) {
|
|
2326
|
+
const current = user(actor);
|
|
2327
|
+
const parsed = parse(playbackSchema, input);
|
|
2328
|
+
const course = await this.service.course(parsed.courseId);
|
|
2329
|
+
return change(this.stores.enrollments, enrollmentKey(course.id, current.id), async (stored) => {
|
|
2330
|
+
const lesson = await this.service.lessonAccess(course, parsed.lessonId, stored);
|
|
2331
|
+
invariant(["audio", "video"].includes(lesson.kind), "VALIDATION_ERROR", "Only audio and video lessons store playback positions.");
|
|
2332
|
+
const next = structuredClone(stored);
|
|
2333
|
+
const progress = next.progress[lesson.id] ?? emptyProgress();
|
|
2334
|
+
invariant((progress.playback?.revision ?? 0) === parsed.expectedRevision, "CONFLICT", "Playback changed in another tab. Reload to resume saving.", 409);
|
|
2335
|
+
const playback = {
|
|
2336
|
+
positionSeconds: parsed.positionSeconds,
|
|
2337
|
+
playbackRate: parsed.playbackRate,
|
|
2338
|
+
revision: parsed.expectedRevision + 1,
|
|
2339
|
+
updatedAt: this.service.time()
|
|
2340
|
+
};
|
|
2341
|
+
progress.playback = playback;
|
|
2342
|
+
next.progress[lesson.id] = progress;
|
|
2343
|
+
next.lastActivityAt = this.service.time();
|
|
2344
|
+
return {
|
|
2345
|
+
value: next,
|
|
2346
|
+
result: playback
|
|
2347
|
+
};
|
|
2348
|
+
});
|
|
2349
|
+
}
|
|
2350
|
+
async exportData(actor, input) {
|
|
2351
|
+
const current = user(actor);
|
|
2352
|
+
const parsed = parse(exportSchema, input);
|
|
2353
|
+
const { section, cursor } = parsed;
|
|
2354
|
+
const options = {
|
|
2355
|
+
where: section === "discussions" ? { authorId: current.id } : { userId: current.id },
|
|
2356
|
+
cursor,
|
|
2357
|
+
limit: Math.min(parsed.limit, 5)
|
|
2358
|
+
};
|
|
2359
|
+
let page;
|
|
2360
|
+
if (section === "enrollments") {
|
|
2361
|
+
const raw = await queryPage(this.stores.enrollments, options);
|
|
2362
|
+
page = {
|
|
2363
|
+
...raw,
|
|
2364
|
+
items: await Promise.all(raw.items.map((enrollment) => this.service.enrollmentView(enrollment)))
|
|
2365
|
+
};
|
|
2366
|
+
} else {
|
|
2367
|
+
const raw = await this.stores[section].query(options);
|
|
2368
|
+
page = {
|
|
2369
|
+
...raw,
|
|
2370
|
+
items: raw.items.map((item) => item.data)
|
|
2371
|
+
};
|
|
2372
|
+
}
|
|
2373
|
+
return {
|
|
2374
|
+
format: "anantagnanam-lms-learner",
|
|
2375
|
+
version: 1,
|
|
2376
|
+
exportedAt: this.service.time(),
|
|
2377
|
+
section,
|
|
2378
|
+
profile: {
|
|
2379
|
+
id: current.id,
|
|
2380
|
+
name: current.name,
|
|
2381
|
+
email: current.email
|
|
2382
|
+
},
|
|
2383
|
+
preferences: (await this.preferences(current)).value,
|
|
2384
|
+
...page
|
|
2385
|
+
};
|
|
2386
|
+
}
|
|
2387
|
+
async health(actor) {
|
|
2388
|
+
admin(actor);
|
|
2389
|
+
const [maintenance, pendingEmails, failedEmails, pending, settings, migration] = await Promise.all([
|
|
2390
|
+
this.service.ctx.kv.get("maintenanceHealth"),
|
|
2391
|
+
this.stores.notifications.count({ emailStatus: "pending" }),
|
|
2392
|
+
this.stores.notifications.count({ emailStatus: "failed" }),
|
|
2393
|
+
this.stores.notifications.query({
|
|
2394
|
+
where: { emailStatus: "pending" },
|
|
2395
|
+
orderBy: { createdAt: "asc" },
|
|
2396
|
+
limit: 1
|
|
2397
|
+
}),
|
|
2398
|
+
this.service.settings(),
|
|
2399
|
+
this.service.migrationStatus(actor)
|
|
2400
|
+
]);
|
|
2401
|
+
return {
|
|
2402
|
+
version: PLUGIN_VERSION,
|
|
2403
|
+
maintenance,
|
|
2404
|
+
maintenanceStale: !maintenance?.completedAt || this.service.now().getTime() - Date.parse(maintenance.completedAt) > 45 * 6e4,
|
|
2405
|
+
pendingEmails,
|
|
2406
|
+
failedEmails,
|
|
2407
|
+
oldestPendingAt: pending.items[0]?.data.createdAt ?? null,
|
|
2408
|
+
emailEnabled: settings.emailNotifications,
|
|
2409
|
+
migration
|
|
2410
|
+
};
|
|
2411
|
+
}
|
|
2412
|
+
};
|
|
2413
|
+
|
|
1699
2414
|
//#endregion
|
|
1700
2415
|
//#region src/runtime.ts
|
|
1701
2416
|
const listSchema = pageSchema.extend({
|
|
@@ -1719,6 +2434,10 @@ const communityListSchema = pageSchema.extend({
|
|
|
1719
2434
|
});
|
|
1720
2435
|
const slugSchema = z.object({ slug: z.string().min(1).max(100) });
|
|
1721
2436
|
const keyInput = z.object({ id: keySchema });
|
|
2437
|
+
const courseRevisionInput = z.object({
|
|
2438
|
+
id: idSchema,
|
|
2439
|
+
expectedRevision: z.string().min(1).max(128)
|
|
2440
|
+
});
|
|
1722
2441
|
function get(handler, publicRoute = false, permission = "content:read") {
|
|
1723
2442
|
return definePluginRoute({
|
|
1724
2443
|
methods: ["GET"],
|
|
@@ -1762,7 +2481,7 @@ function createPlugin(_options = {}) {
|
|
|
1762
2481
|
},
|
|
1763
2482
|
hooks: {
|
|
1764
2483
|
"plugin:install": async (_event, ctx) => {
|
|
1765
|
-
await ctx.kv.
|
|
2484
|
+
await ctx.kv.compareAndSet("schemaVersion", null, 1);
|
|
1766
2485
|
},
|
|
1767
2486
|
"plugin:activate": async (_event, ctx) => {
|
|
1768
2487
|
await ctx.cron?.schedule("maintenance", { schedule: "*/15 * * * *" });
|
|
@@ -1773,12 +2492,39 @@ function createPlugin(_options = {}) {
|
|
|
1773
2492
|
cron: async (event, ctx) => {
|
|
1774
2493
|
if (event.name === "maintenance") {
|
|
1775
2494
|
const service = new LmsService(ctx);
|
|
1776
|
-
|
|
1777
|
-
|
|
2495
|
+
const startedAt = service.time();
|
|
2496
|
+
try {
|
|
2497
|
+
await service.reminders();
|
|
2498
|
+
await service.deliverNotifications();
|
|
2499
|
+
await ctx.kv.set("maintenanceHealth", {
|
|
2500
|
+
startedAt,
|
|
2501
|
+
completedAt: service.time(),
|
|
2502
|
+
failed: false
|
|
2503
|
+
});
|
|
2504
|
+
} catch (error) {
|
|
2505
|
+
await ctx.kv.set("maintenanceHealth", {
|
|
2506
|
+
startedAt,
|
|
2507
|
+
completedAt: null,
|
|
2508
|
+
failed: true
|
|
2509
|
+
});
|
|
2510
|
+
throw error;
|
|
2511
|
+
}
|
|
1778
2512
|
}
|
|
1779
2513
|
}
|
|
1780
2514
|
},
|
|
1781
2515
|
routes: {
|
|
2516
|
+
paths: get(async (ctx, service) => new LearningFeatures(service).paths(ctx.input), true),
|
|
2517
|
+
"learner/paths": get(async (ctx, service) => new LearningFeatures(service).learnerPaths(ctx.user, ctx.input)),
|
|
2518
|
+
"learner/path/join": post(async (ctx, service) => new LearningFeatures(service).joinPath(ctx.user, ctx.input)),
|
|
2519
|
+
"learner/saved": get(async (ctx, service) => new LearningFeatures(service).savedCourses(ctx.user, ctx.input)),
|
|
2520
|
+
"learner/saved/save": post(async (ctx, service) => new LearningFeatures(service).saveCourse(ctx.user, ctx.input)),
|
|
2521
|
+
"learner/preferences": get(async (ctx, service) => new LearningFeatures(service).preferences(ctx.user)),
|
|
2522
|
+
"learner/preferences/save": post(async (ctx, service) => new LearningFeatures(service).savePreferences(ctx.user, ctx.input)),
|
|
2523
|
+
"learner/playback": post(async (ctx, service) => new LearningFeatures(service).playback(ctx.user, ctx.input)),
|
|
2524
|
+
"learner/export": get(async (ctx, service) => new LearningFeatures(service).exportData(ctx.user, ctx.input)),
|
|
2525
|
+
"admin/paths": get(async (ctx, service) => new LearningFeatures(service).adminPaths(ctx.user, ctx.input), false, "plugins:manage"),
|
|
2526
|
+
"admin/path/save": post(async (ctx, service) => new LearningFeatures(service).savePath(ctx.user, ctx.input), "plugins:manage"),
|
|
2527
|
+
"admin/health": get(async (ctx, service) => new LearningFeatures(service).health(ctx.user), false, "plugins:manage"),
|
|
1782
2528
|
academy: get(async (_ctx, service) => {
|
|
1783
2529
|
const settings = await service.settings();
|
|
1784
2530
|
return {
|
|
@@ -1854,6 +2600,34 @@ function createPlugin(_options = {}) {
|
|
|
1854
2600
|
]).optional() }), ctx.input))),
|
|
1855
2601
|
"admin/course": get(async (ctx, service) => service.editableCourse(ctx.user, parse(z.object({ id: idSchema }), ctx.input).id)),
|
|
1856
2602
|
"admin/course/save": post(async (ctx, service) => service.saveCourse(ctx.user, ctx.input)),
|
|
2603
|
+
"admin/course/publish": post(async (ctx, service) => {
|
|
2604
|
+
const input = parse(courseRevisionInput, ctx.input);
|
|
2605
|
+
return service.publishCourse(ctx.user, input.id, input.expectedRevision);
|
|
2606
|
+
}),
|
|
2607
|
+
"admin/course/archive": post(async (ctx, service) => {
|
|
2608
|
+
const input = parse(courseRevisionInput, ctx.input);
|
|
2609
|
+
return service.archiveCourse(ctx.user, input.id, input.expectedRevision);
|
|
2610
|
+
}),
|
|
2611
|
+
"admin/course/history": get(async (ctx, service) => {
|
|
2612
|
+
const input = parse(z.object({
|
|
2613
|
+
id: idSchema,
|
|
2614
|
+
cursor: idSchema.optional()
|
|
2615
|
+
}), ctx.input);
|
|
2616
|
+
return service.courseHistory(ctx.user, input.id, input.cursor);
|
|
2617
|
+
}),
|
|
2618
|
+
"admin/course/restore": post(async (ctx, service) => {
|
|
2619
|
+
const input = parse(courseRevisionInput.extend({ revisionId: idSchema }), ctx.input);
|
|
2620
|
+
return service.restoreCourse(ctx.user, input.id, input.expectedRevision, input.revisionId);
|
|
2621
|
+
}),
|
|
2622
|
+
"admin/migrations": get(async (ctx, service) => service.migrationStatus(ctx.user), false, "plugins:manage"),
|
|
2623
|
+
"admin/migrations/run": post(async (ctx, service) => {
|
|
2624
|
+
const input = parse(z.object({ limit: z.number().int().min(1).max(20).default(20) }), ctx.input);
|
|
2625
|
+
return service.migrateBatch(ctx.user, input.limit);
|
|
2626
|
+
}, "plugins:manage"),
|
|
2627
|
+
"admin/lesson/preview": get(async (ctx, service) => {
|
|
2628
|
+
const input = parse(lessonIdSchema, ctx.input);
|
|
2629
|
+
return service.authorLessonPreview(ctx.user, input.courseId, input.lessonId);
|
|
2630
|
+
}),
|
|
1857
2631
|
"admin/course/delete": post(async (ctx, service) => {
|
|
1858
2632
|
const input = parse(z.object({
|
|
1859
2633
|
id: idSchema,
|
|
@@ -2035,10 +2809,16 @@ function createPlugin(_options = {}) {
|
|
|
2035
2809
|
},
|
|
2036
2810
|
save_course: {
|
|
2037
2811
|
route: "admin/course/save",
|
|
2038
|
-
description: "Create or
|
|
2812
|
+
description: "Create a course or save a working draft using its current storage revision. Existing courses require publish_course to publish edits; initial creation with published status publishes immediately.",
|
|
2039
2813
|
input: courseSaveSchema,
|
|
2040
2814
|
destructive: true
|
|
2041
2815
|
},
|
|
2816
|
+
publish_course: {
|
|
2817
|
+
route: "admin/course/publish",
|
|
2818
|
+
description: "Publish the saved draft as a new course edition using its current storage revision. Existing learners retain their enrolled edition.",
|
|
2819
|
+
input: courseRevisionInput,
|
|
2820
|
+
destructive: true
|
|
2821
|
+
},
|
|
2042
2822
|
enroll_learner: {
|
|
2043
2823
|
route: "admin/enrollment/save",
|
|
2044
2824
|
description: "Approve, reject, suspend, or manually enroll a learner. Requires an administrator account.",
|