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