@happyvertical/smrt-education 0.57.47 → 0.57.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/AGENTS.md +9 -0
  2. package/README.md +23 -2
  3. package/agents/course-authoring-service.md +44 -1
  4. package/agents/course-bundle-import.md +148 -0
  5. package/agents/group-sharing.md +20 -0
  6. package/agents/learner-service.md +35 -0
  7. package/agents/question-extraction.md +4 -1
  8. package/agents/tutor-context.md +62 -0
  9. package/dist/catalog.d.ts +12 -0
  10. package/dist/catalog.d.ts.map +1 -1
  11. package/dist/chunks/{workbook-photo-service-B7Tujv__.js → workbook-photo-service-WwDDC5GF.js} +692 -18
  12. package/dist/chunks/workbook-photo-service-WwDDC5GF.js.map +1 -0
  13. package/dist/course-authoring-contracts.d.ts +26 -3
  14. package/dist/course-authoring-contracts.d.ts.map +1 -1
  15. package/dist/course-authoring-models.d.ts +44 -1
  16. package/dist/course-authoring-models.d.ts.map +1 -1
  17. package/dist/course-authoring-service.d.ts +39 -0
  18. package/dist/course-authoring-service.d.ts.map +1 -1
  19. package/dist/course-bundle-contracts.d.ts +93 -0
  20. package/dist/course-bundle-contracts.d.ts.map +1 -0
  21. package/dist/course-bundle-models.d.ts +66 -0
  22. package/dist/course-bundle-models.d.ts.map +1 -0
  23. package/dist/course-bundle-service.d.ts +126 -0
  24. package/dist/course-bundle-service.d.ts.map +1 -0
  25. package/dist/course-bundle-zip.d.ts +48 -0
  26. package/dist/course-bundle-zip.d.ts.map +1 -0
  27. package/dist/course-bundle.d.ts +135 -0
  28. package/dist/course-bundle.d.ts.map +1 -0
  29. package/dist/course-visibility.d.ts +22 -0
  30. package/dist/course-visibility.d.ts.map +1 -0
  31. package/dist/group-sharing-reader.d.ts.map +1 -1
  32. package/dist/index.d.ts +17 -5
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2563 -510
  35. package/dist/index.js.map +1 -1
  36. package/dist/ingestion.js +1 -1
  37. package/dist/learner-history.d.ts +50 -1
  38. package/dist/learner-history.d.ts.map +1 -1
  39. package/dist/learner-plan-service.d.ts.map +1 -1
  40. package/dist/learner-service.d.ts +91 -2
  41. package/dist/learner-service.d.ts.map +1 -1
  42. package/dist/learners.d.ts +2 -0
  43. package/dist/learners.d.ts.map +1 -1
  44. package/dist/manifest.json +11774 -7626
  45. package/dist/question-response-service.d.ts +21 -3
  46. package/dist/question-response-service.d.ts.map +1 -1
  47. package/dist/smrt-knowledge.json +3507 -2008
  48. package/dist/tutor-context-service.d.ts +34 -0
  49. package/dist/tutor-context-service.d.ts.map +1 -0
  50. package/dist/tutor-contracts.d.ts +123 -0
  51. package/dist/tutor-contracts.d.ts.map +1 -0
  52. package/dist/tutor-models.d.ts +77 -0
  53. package/dist/tutor-models.d.ts.map +1 -0
  54. package/dist/tutor-session-service.d.ts +115 -0
  55. package/dist/tutor-session-service.d.ts.map +1 -0
  56. package/package.json +8 -8
  57. package/dist/chunks/workbook-photo-service-B7Tujv__.js.map +0 -1
@@ -113,7 +113,28 @@ ExternalCourse = __decorateClass$11([smrt({
113
113
  tableName: "courses",
114
114
  ...closed$9
115
115
  })], ExternalCourse);
116
- var AuthoredCourse = class extends Course {};
116
+ var COURSE_VISIBILITIES = [
117
+ "family",
118
+ "groups",
119
+ "public"
120
+ ];
121
+ var AuthoredCourse = class extends Course {
122
+ /**
123
+ * Authored-course visibility; '' means never set (reads as 'family'). Only the
124
+ * course-authoring service writes it, with an append-only change row.
125
+ */
126
+ visibility = "";
127
+ /** Education-owned image asset shown as the course cover; '' means none. */
128
+ coverAssetId = "";
129
+ constructor(options = {}) {
130
+ super(options);
131
+ applyModelOptions(this, options);
132
+ }
133
+ async save(options) {
134
+ if (this.licenseKind === "personal-use-copy" && (this.visibility === "groups" || this.visibility === "public")) throw new Error("personal-use-copy courses can only be family-visible");
135
+ return await super.save(options);
136
+ }
137
+ };
117
138
  __publicField$11(AuthoredCourse, "__smrtPackage__", "@happyvertical/smrt-education");
118
139
  AuthoredCourse = __decorateClass$11([smrt({
119
140
  tableName: "courses",
@@ -374,6 +395,8 @@ var Enrollment = class extends SmrtObject {
374
395
  startedAt = null;
375
396
  completedAt = null;
376
397
  targetMinutesPerWeek = 0;
398
+ /** Planned lessons per week (1-14); `0` means no pace is set. */
399
+ paceLessonsPerWeek = 0;
377
400
  sortOrder = 0;
378
401
  constructor(options = {}) {
379
402
  super(options);
@@ -398,6 +421,7 @@ var Enrollment = class extends SmrtObject {
398
421
  tenant_id: this.tenantId
399
422
  })) throw new Error("Enrollment learner must match its tenant curriculum");
400
423
  if (!Number.isSafeInteger(this.targetMinutesPerWeek) || this.targetMinutesPerWeek < 0) throw new Error("Target minutes must be a nonnegative integer");
424
+ if (!Number.isSafeInteger(this.paceLessonsPerWeek) || this.paceLessonsPerWeek < 0 || this.paceLessonsPerWeek > 14) throw new Error("Pace must be zero or 1 to 14 lessons per week");
401
425
  return super.save(options);
402
426
  }
403
427
  };
@@ -1169,7 +1193,9 @@ var COURSE_AUTHORING_ACTIONS = [
1169
1193
  "education.course.review",
1170
1194
  "education.course.release",
1171
1195
  "education.course.assign",
1172
- "education.course.archive"
1196
+ "education.course.archive",
1197
+ "education.course.set-visibility",
1198
+ "education.course.set-cover"
1173
1199
  ];
1174
1200
  var COURSE_PROVENANCES = [
1175
1201
  "supplied",
@@ -1265,6 +1291,20 @@ var CourseMaterialCopyError = class extends CourseAuthoringError {
1265
1291
  super("material-copy-failed", message);
1266
1292
  }
1267
1293
  };
1294
+ var CourseVisibilityRefusedError = class extends CourseAuthoringError {
1295
+ constructor(visibility) {
1296
+ super("visibility-refused", "A personal-use course can only be family-visible.");
1297
+ this.visibility = visibility;
1298
+ }
1299
+ visibility;
1300
+ };
1301
+ var CourseCoverInvalidError = class extends CourseAuthoringError {
1302
+ constructor(reason) {
1303
+ super("cover-invalid", "The cover must be a readable education-owned image.");
1304
+ this.reason = reason;
1305
+ }
1306
+ reason;
1307
+ };
1268
1308
  //#endregion
1269
1309
  //#region src/course-authoring-licence.ts
1270
1310
  var AUTHORED_RELEASE_SOURCE_KEY = "authored-release";
@@ -1690,6 +1730,96 @@ CourseArchiveDecision = __decorateClass$7([TenantScoped({ mode: "required" }), s
1690
1730
  var CourseArchiveDecisionCollection = class extends SmrtCollection {
1691
1731
  static _itemClass = CourseArchiveDecision;
1692
1732
  };
1733
+ var CourseVisibilityChange = class extends SmrtObject {
1734
+ tenantId = "";
1735
+ courseId = "";
1736
+ sequence = 1;
1737
+ previousVisibility = "family";
1738
+ visibility = "family";
1739
+ reason = "request";
1740
+ actorUserId = "";
1741
+ delegatingGuardianUserId = "";
1742
+ delegationId = "";
1743
+ requestKey = "";
1744
+ recordedAt = /* @__PURE__ */ new Date();
1745
+ constructor(options = {}) {
1746
+ super(options);
1747
+ adopt$3(this, options);
1748
+ }
1749
+ async delete() {
1750
+ noDelete$4();
1751
+ }
1752
+ async save(options) {
1753
+ await requireInsert$3(this);
1754
+ await requireOwned$2(this, "courses", this.courseId);
1755
+ requireText$3(this.actorUserId, "Visibility change requires an actor");
1756
+ requireText$3(this.requestKey, "Visibility change requires a request key", 300);
1757
+ const visibilities = COURSE_VISIBILITIES;
1758
+ if (!visibilities.includes(this.previousVisibility) || !visibilities.includes(this.visibility) || !["request", "licence"].includes(this.reason) || !Number.isSafeInteger(this.sequence) || this.sequence < 1) throw new Error("Invalid visibility change");
1759
+ validDate$3(this.recordedAt);
1760
+ this.requireInsertOnSave();
1761
+ return super.save(options);
1762
+ }
1763
+ };
1764
+ __publicField$7(CourseVisibilityChange, "__smrtPackage__", "@happyvertical/smrt-education");
1765
+ __decorateClass$7([tenantId()], CourseVisibilityChange.prototype, "tenantId", 2);
1766
+ __decorateClass$7([foreignKey("Course", { required: true })], CourseVisibilityChange.prototype, "courseId", 2);
1767
+ CourseVisibilityChange = __decorateClass$7([TenantScoped({ mode: "required" }), smrt({
1768
+ tableName: "course_visibility_changes",
1769
+ conflictColumns: [
1770
+ "tenant_id",
1771
+ "course_id",
1772
+ "request_key"
1773
+ ],
1774
+ ...closed$6
1775
+ })], CourseVisibilityChange);
1776
+ var CourseVisibilityChangeCollection = class extends SmrtCollection {
1777
+ static _itemClass = CourseVisibilityChange;
1778
+ };
1779
+ var CourseCoverChange = class extends SmrtObject {
1780
+ tenantId = "";
1781
+ courseId = "";
1782
+ sequence = 1;
1783
+ previousAssetId = "";
1784
+ assetId = "";
1785
+ actorUserId = "";
1786
+ delegatingGuardianUserId = "";
1787
+ delegationId = "";
1788
+ requestKey = "";
1789
+ recordedAt = /* @__PURE__ */ new Date();
1790
+ constructor(options = {}) {
1791
+ super(options);
1792
+ adopt$3(this, options);
1793
+ }
1794
+ async delete() {
1795
+ noDelete$4();
1796
+ }
1797
+ async save(options) {
1798
+ await requireInsert$3(this);
1799
+ await requireOwned$2(this, "courses", this.courseId);
1800
+ requireText$3(this.actorUserId, "Cover change requires an actor");
1801
+ requireText$3(this.requestKey, "Cover change requires a request key", 300);
1802
+ if (this.previousAssetId === this.assetId || !Number.isSafeInteger(this.sequence) || this.sequence < 1) throw new Error("Invalid cover change");
1803
+ validDate$3(this.recordedAt);
1804
+ this.requireInsertOnSave();
1805
+ return super.save(options);
1806
+ }
1807
+ };
1808
+ __publicField$7(CourseCoverChange, "__smrtPackage__", "@happyvertical/smrt-education");
1809
+ __decorateClass$7([tenantId()], CourseCoverChange.prototype, "tenantId", 2);
1810
+ __decorateClass$7([foreignKey("Course", { required: true })], CourseCoverChange.prototype, "courseId", 2);
1811
+ CourseCoverChange = __decorateClass$7([TenantScoped({ mode: "required" }), smrt({
1812
+ tableName: "course_cover_changes",
1813
+ conflictColumns: [
1814
+ "tenant_id",
1815
+ "course_id",
1816
+ "request_key"
1817
+ ],
1818
+ ...closed$6
1819
+ })], CourseCoverChange);
1820
+ var CourseCoverChangeCollection = class extends SmrtCollection {
1821
+ static _itemClass = CourseCoverChange;
1822
+ };
1693
1823
  var CourseDiscardTombstone = class extends SmrtObject {
1694
1824
  tenantId = "";
1695
1825
  courseId = "";
@@ -2093,6 +2223,26 @@ function orderEffectiveLessons(selection, orderOf) {
2093
2223
  });
2094
2224
  }
2095
2225
  //#endregion
2226
+ //#region src/course-visibility.ts
2227
+ function effectiveCourseVisibility(stored) {
2228
+ return COURSE_VISIBILITIES.includes(stored) ? stored : "family";
2229
+ }
2230
+ async function authoredVisibilityPermitsGroups(db, tenantId, courseId) {
2231
+ if (!await db.tableExists("course_releases")) return true;
2232
+ const release = await db.get("course_releases", {
2233
+ tenant_id: tenantId,
2234
+ release_course_id: courseId
2235
+ });
2236
+ if (!release) return true;
2237
+ const identity = await db.get("courses", {
2238
+ id: release.course_id,
2239
+ tenant_id: tenantId
2240
+ });
2241
+ if (!identity) return false;
2242
+ const stored = identity.visibility;
2243
+ return stored === void 0 || stored === null || stored === "" || stored === "groups" || stored === "public";
2244
+ }
2245
+ //#endregion
2096
2246
  //#region src/group-sharing-hash.ts
2097
2247
  function groupSharingDigest(value) {
2098
2248
  const crypto = typeof process !== "undefined" && process.getBuiltinModule?.("node:crypto");
@@ -2243,6 +2393,12 @@ var bool = (value, what) => {
2243
2393
  };
2244
2394
  var iso$1 = (date) => new Date(date).toISOString();
2245
2395
  var optional = (value) => value || null;
2396
+ var COVER_MIME_TYPES = [
2397
+ "image/png",
2398
+ "image/jpeg",
2399
+ "image/webp",
2400
+ "image/gif"
2401
+ ];
2246
2402
  var SKIP_REASON = "not-authorized";
2247
2403
  var CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/g;
2248
2404
  var SKIP_CODE_PATTERN = /^[a-z0-9._-]{1,64}$/;
@@ -2669,7 +2825,7 @@ var CourseAuthoringService = class {
2669
2825
  objectives: draft.objectives,
2670
2826
  sections: input.sections
2671
2827
  }));
2672
- return (await CourseRevisionCollection.create({ db: tx })).create({
2828
+ const revision = await (await CourseRevisionCollection.create({ db: tx })).create({
2673
2829
  _insertOnly: true,
2674
2830
  tenantId: actor.tenantId,
2675
2831
  courseId: input.courseId,
@@ -2693,6 +2849,8 @@ var CourseAuthoringService = class {
2693
2849
  contentDigest,
2694
2850
  recordedAt: /* @__PURE__ */ new Date()
2695
2851
  });
2852
+ if (input.licenseKind === "personal-use-copy") await this.narrowForLicence(tx, actor, input.courseId, revision);
2853
+ return revision;
2696
2854
  }
2697
2855
  /**
2698
2856
  * Appends a revision and moves the draft head by expected-head compare and
@@ -3052,6 +3210,8 @@ var CourseAuthoringService = class {
3052
3210
  state,
3053
3211
  licenseKind,
3054
3212
  groupShareable: isGroupShareableLicense(licenseKind),
3213
+ visibility: effectiveCourseVisibility(course.visibility),
3214
+ coverAssetId: optional(course.cover_asset_id),
3055
3215
  headRevisionId: head ? String(head.head_revision_id) : null,
3056
3216
  releases
3057
3217
  };
@@ -3064,6 +3224,7 @@ var CourseAuthoringService = class {
3064
3224
  async listCourses(request) {
3065
3225
  pageLimit(request.limit);
3066
3226
  decodeCursor(request.cursor);
3227
+ if (request.visibility !== void 0 && !COURSE_VISIBILITIES.includes(request.visibility)) throw new CourseAuthoringValidationError("Invalid visibility");
3067
3228
  const includeDiscarded = request.includeDiscarded === void 0 ? false : bool(request.includeDiscarded, "includeDiscarded");
3068
3229
  return this.run(async (tx) => {
3069
3230
  await this.authorize(tx, request, "education.course.read-draft", "list-courses");
@@ -3071,7 +3232,7 @@ var CourseAuthoringService = class {
3071
3232
  const page = paginate((await tx.list("courses", {
3072
3233
  tenant_id: request.tenantId,
3073
3234
  source_key: AUTHORED_IDENTITY_SOURCE_KEY
3074
- })).filter((row) => includeDiscarded || !discarded.has(String(row.id))), (row) => [millis(row.created_at), String(row.id)], request);
3235
+ })).filter((row) => includeDiscarded || !discarded.has(String(row.id))).filter((row) => request.visibility === void 0 || effectiveCourseVisibility(row.visibility) === request.visibility), (row) => [millis(row.created_at), String(row.id)], request);
3075
3236
  const pageIds = page.rows.map((row) => String(row.id));
3076
3237
  const byCourse = (rows, key) => {
3077
3238
  const grouped = /* @__PURE__ */ new Map();
@@ -3131,6 +3292,8 @@ var CourseAuthoringService = class {
3131
3292
  sourceKey: "authored-release",
3132
3293
  licenseKind
3133
3294
  }),
3295
+ visibility: effectiveCourseVisibility(row.visibility),
3296
+ coverAssetId: optional(row.cover_asset_id),
3134
3297
  releaseCount: releases.length,
3135
3298
  head: head && revision ? {
3136
3299
  revisionId: String(head.head_revision_id),
@@ -3142,6 +3305,7 @@ var CourseAuthoringService = class {
3142
3305
  revisionId: String(latest.revision_id),
3143
3306
  releaseNumber: Number(latest.release_number),
3144
3307
  releaseCourseId: String(latest.release_course_id),
3308
+ coverAssetId: optional(lookup.releaseCourses.get(String(latest.release_course_id))?.cover_asset_id),
3145
3309
  recordedAt: iso$1(latest.recorded_at)
3146
3310
  } : null,
3147
3311
  archive: {
@@ -3319,8 +3483,12 @@ var CourseAuthoringService = class {
3319
3483
  const lessons = view.sections.flatMap((s) => s.lessons);
3320
3484
  const personal = view.licenseKind === "personal-use-copy";
3321
3485
  const modalities = new Set(lessons.map((l) => l.modality));
3486
+ const identity = await this.identityRow(tx, actor.tenantId, view.courseId);
3487
+ const cover = String(identity.cover_asset_id ?? "");
3488
+ const coverAssetId = cover && !await this.coverIneligibility(tx, actor.tenantId, view.courseId, cover) ? cover : "";
3322
3489
  await (await AuthoredCourseCollection.create({ db: tx })).create({
3323
3490
  id: input.releaseCourseId,
3491
+ coverAssetId,
3324
3492
  tenantId: actor.tenantId,
3325
3493
  title: view.title,
3326
3494
  summary: view.summary,
@@ -3444,6 +3612,10 @@ var CourseAuthoringService = class {
3444
3612
  revisionId: release.revisionId,
3445
3613
  releaseNumber: release.releaseNumber,
3446
3614
  releaseCourseId: release.releaseCourseId,
3615
+ coverAssetId: optional((await tx.get("courses", {
3616
+ id: release.releaseCourseId,
3617
+ tenant_id: tenantId
3618
+ }))?.cover_asset_id),
3447
3619
  reviewId: optional(release.reviewId),
3448
3620
  actorUserId: release.actorUserId,
3449
3621
  delegatingGuardianUserId: optional(release.delegatingGuardianUserId),
@@ -3452,6 +3624,217 @@ var CourseAuthoringService = class {
3452
3624
  lessons: lessons.sort((a, b) => a.lessonKey.localeCompare(b.lessonKey))
3453
3625
  };
3454
3626
  }
3627
+ /** Most restrictive of the head revision and every release's materialized course. */
3628
+ async effectiveLicence(tx, tenantId, courseId) {
3629
+ const head = await tx.get("course_draft_heads", {
3630
+ tenant_id: tenantId,
3631
+ course_id: courseId
3632
+ });
3633
+ const kinds = [(head ? await tx.get("course_revisions", {
3634
+ id: head.head_revision_id,
3635
+ tenant_id: tenantId
3636
+ }) : null)?.license_kind ?? "own-work"];
3637
+ for (const release of await tx.list("course_releases", {
3638
+ tenant_id: tenantId,
3639
+ course_id: courseId
3640
+ })) {
3641
+ const course = await tx.get("courses", {
3642
+ id: release.release_course_id,
3643
+ tenant_id: tenantId
3644
+ });
3645
+ kinds.push(course?.license_kind ?? "own-work");
3646
+ }
3647
+ return mostRestrictiveLicense(kinds);
3648
+ }
3649
+ async requireChangeTable(tx, table) {
3650
+ if (!await tx.tableExists(table)) throw new CourseAuthoringError("schema-unavailable", "Course visibility and cover require the latest education schema.");
3651
+ }
3652
+ /**
3653
+ * Sets who the course is meant for. `groups` is the precondition for sharing a
3654
+ * released course with the family's groups; `public` is a listing label only
3655
+ * and grants no read. A personal-use-copy course (any part, ever) can only be
3656
+ * `family`. Appends a change row (actor, previous, new, time); a request that
3657
+ * leaves the stored value unchanged writes nothing.
3658
+ */
3659
+ async setVisibility(request) {
3660
+ const requestKey = requireKey$1(request.requestKey);
3661
+ if (!COURSE_VISIBILITIES.includes(request.visibility)) throw new CourseAuthoringValidationError("Invalid visibility");
3662
+ return this.run(async (tx) => {
3663
+ await this.authorize(tx, request, "education.course.set-visibility", "set-visibility", { courseId: request.courseId });
3664
+ await this.writableDraft(tx, request.tenantId, request.courseId);
3665
+ await this.requireChangeTable(tx, "course_visibility_changes");
3666
+ await this.lockHead(tx, request.tenantId, request.courseId);
3667
+ const changes = (await (await CourseVisibilityChangeCollection.create({ db: tx })).list({
3668
+ where: {
3669
+ tenantId: request.tenantId,
3670
+ courseId: request.courseId
3671
+ },
3672
+ limit: 1e5
3673
+ })).sort((a, b) => a.sequence - b.sequence);
3674
+ const replay = changes.find((c) => c.requestKey === requestKey);
3675
+ if (replay) {
3676
+ if (replay.visibility !== request.visibility || replay.reason !== "request" || replay.delegationId !== (request.delegationId ?? "")) throw new CourseRequestKeyConflictError();
3677
+ return this.courseState(tx, request.tenantId, request.courseId);
3678
+ }
3679
+ const identity = await this.identityRow(tx, request.tenantId, request.courseId);
3680
+ const stored = String(identity.visibility ?? "");
3681
+ if (stored === request.visibility) return this.courseState(tx, request.tenantId, request.courseId);
3682
+ if (request.visibility !== "family" && await this.effectiveLicence(tx, request.tenantId, request.courseId) === "personal-use-copy") throw new CourseVisibilityRefusedError(request.visibility);
3683
+ await (await CourseVisibilityChangeCollection.create({ db: tx })).create({
3684
+ _insertOnly: true,
3685
+ tenantId: request.tenantId,
3686
+ courseId: request.courseId,
3687
+ sequence: changes.length + 1,
3688
+ previousVisibility: effectiveCourseVisibility(stored),
3689
+ visibility: request.visibility,
3690
+ reason: "request",
3691
+ actorUserId: request.actorUserId,
3692
+ ...delegationRecordFields(request),
3693
+ requestKey,
3694
+ recordedAt: /* @__PURE__ */ new Date()
3695
+ });
3696
+ await this.writeIdentity(tx, request.tenantId, request.courseId, { visibility: request.visibility });
3697
+ return this.courseState(tx, request.tenantId, request.courseId);
3698
+ });
3699
+ }
3700
+ /** Narrows to family (with a change row) when a revision made the course personal-use. */
3701
+ async narrowForLicence(tx, actor, courseId, revision) {
3702
+ const identity = await this.identityRow(tx, actor.tenantId, courseId);
3703
+ const stored = String(identity.visibility ?? "");
3704
+ if (stored !== "groups" && stored !== "public") return;
3705
+ await this.requireChangeTable(tx, "course_visibility_changes");
3706
+ const changes = await tx.list("course_visibility_changes", {
3707
+ tenant_id: actor.tenantId,
3708
+ course_id: courseId
3709
+ });
3710
+ await (await CourseVisibilityChangeCollection.create({ db: tx })).create({
3711
+ _insertOnly: true,
3712
+ tenantId: actor.tenantId,
3713
+ courseId,
3714
+ sequence: changes.length + 1,
3715
+ previousVisibility: stored,
3716
+ visibility: "family",
3717
+ reason: "licence",
3718
+ actorUserId: actor.actorUserId,
3719
+ ...delegationRecordFields(actor),
3720
+ requestKey: `licence:${String(revision.id)}`,
3721
+ recordedAt: /* @__PURE__ */ new Date()
3722
+ });
3723
+ await this.writeIdentity(tx, actor.tenantId, courseId, { visibility: "family" });
3724
+ }
3725
+ async writeIdentity(tx, tenantId, courseId, values) {
3726
+ const course = await (await AuthoredCourseCollection.create({ db: tx })).get({
3727
+ id: courseId,
3728
+ tenantId
3729
+ }, { cache: false });
3730
+ if (!course) throw new CourseNotFoundError();
3731
+ if (values.visibility !== void 0) course.visibility = values.visibility;
3732
+ if (values.coverAssetId !== void 0) course.coverAssetId = values.coverAssetId;
3733
+ await course.save();
3734
+ }
3735
+ /**
3736
+ * Why an asset cannot be this course's cover, or null. Education-owned means a
3737
+ * material this course copied into the tenant (`materialCopy`); it must be an
3738
+ * image and never an answer key anywhere in the tenant.
3739
+ */
3740
+ async coverIneligibility(tx, tenantId, courseId, assetId) {
3741
+ const asset = await tx.get("assets", {
3742
+ id: assetId,
3743
+ tenant_id: tenantId
3744
+ });
3745
+ if (!asset) return "missing";
3746
+ if (!COVER_MIME_TYPES.includes(String(asset.mime_type ?? "").toLowerCase())) return "not-image";
3747
+ let known = false;
3748
+ for (const row of await tx.list("lesson_revisions", {
3749
+ tenant_id: tenantId,
3750
+ course_id: courseId
3751
+ })) for (const m of JSON.parse(String(row.materials_json))) if (m.assetId === assetId) {
3752
+ known = true;
3753
+ if (m.relationship === "answer-key") return "answer-key";
3754
+ }
3755
+ if (!known) return "not-course-material";
3756
+ if (await tx.get("lesson_assets", {
3757
+ tenant_id: tenantId,
3758
+ asset_id: assetId,
3759
+ relationship: "answer-key"
3760
+ })) return "answer-key";
3761
+ return null;
3762
+ }
3763
+ /** Fails closed: the asset must be readable by this very actor through the asset guard. */
3764
+ async coverReadable(tx, actor, assetId) {
3765
+ try {
3766
+ const raw = await tx.get("assets", {
3767
+ id: assetId,
3768
+ tenant_id: actor.tenantId
3769
+ });
3770
+ if (!raw) return false;
3771
+ const read = async () => {
3772
+ const asset = await (await AssetCollection.create({ db: tx })).get(assetId, { cache: false });
3773
+ return Boolean(asset) && asset?.tenantId === actor.tenantId;
3774
+ };
3775
+ if (raw.access_mode !== "private") return await read();
3776
+ const user = await tx.get("users", { id: actor.actorUserId });
3777
+ const owner = raw.owner_profile_id ? String(raw.owner_profile_id) : "";
3778
+ if (!owner || !user || String(user.profile_id ?? "") !== owner) return false;
3779
+ return await withPrivateAssetAccess({
3780
+ tenantId: actor.tenantId,
3781
+ actorUserId: actor.actorUserId,
3782
+ ownerProfileId: owner,
3783
+ assetIds: [assetId]
3784
+ }, read);
3785
+ } catch {
3786
+ return false;
3787
+ }
3788
+ }
3789
+ /**
3790
+ * Sets (or, with `assetId: null`, clears) the course cover: an education-owned
3791
+ * image the actor can read, never an answer key. The next release snapshots it
3792
+ * onto the release row. Appends a change row; an unchanged cover writes nothing.
3793
+ */
3794
+ async setCover(request) {
3795
+ const requestKey = requireKey$1(request.requestKey);
3796
+ if (request.assetId !== null && (typeof request.assetId !== "string" || !request.assetId || request.assetId.length > 200)) throw new CourseAuthoringValidationError("Invalid cover asset");
3797
+ const assetId = request.assetId ?? "";
3798
+ return this.run(async (tx) => {
3799
+ await this.authorize(tx, request, "education.course.set-cover", "set-cover", { courseId: request.courseId });
3800
+ await this.writableDraft(tx, request.tenantId, request.courseId);
3801
+ await this.requireChangeTable(tx, "course_cover_changes");
3802
+ await this.lockHead(tx, request.tenantId, request.courseId);
3803
+ const changes = (await (await CourseCoverChangeCollection.create({ db: tx })).list({
3804
+ where: {
3805
+ tenantId: request.tenantId,
3806
+ courseId: request.courseId
3807
+ },
3808
+ limit: 1e5
3809
+ })).sort((a, b) => a.sequence - b.sequence);
3810
+ const replay = changes.find((c) => c.requestKey === requestKey);
3811
+ if (replay) {
3812
+ if (replay.assetId !== assetId || replay.delegationId !== (request.delegationId ?? "")) throw new CourseRequestKeyConflictError();
3813
+ return this.courseState(tx, request.tenantId, request.courseId);
3814
+ }
3815
+ const identity = await this.identityRow(tx, request.tenantId, request.courseId);
3816
+ const current = String(identity.cover_asset_id ?? "");
3817
+ if (current === assetId) return this.courseState(tx, request.tenantId, request.courseId);
3818
+ if (assetId) {
3819
+ const reason = await this.coverIneligibility(tx, request.tenantId, request.courseId, assetId) ?? (await this.coverReadable(tx, request, assetId) ? null : "unreadable");
3820
+ if (reason) throw new CourseCoverInvalidError(reason);
3821
+ }
3822
+ await (await CourseCoverChangeCollection.create({ db: tx })).create({
3823
+ _insertOnly: true,
3824
+ tenantId: request.tenantId,
3825
+ courseId: request.courseId,
3826
+ sequence: changes.length + 1,
3827
+ previousAssetId: current,
3828
+ assetId,
3829
+ actorUserId: request.actorUserId,
3830
+ ...delegationRecordFields(request),
3831
+ requestKey,
3832
+ recordedAt: /* @__PURE__ */ new Date()
3833
+ });
3834
+ await this.writeIdentity(tx, request.tenantId, request.courseId, { coverAssetId: assetId });
3835
+ return this.courseState(tx, request.tenantId, request.courseId);
3836
+ });
3837
+ }
3455
3838
  /** Reversible; never touches enrollments, progress, submissions, grades or notes. */
3456
3839
  async archive(request) {
3457
3840
  return this.setArchived(request, true);
@@ -29299,7 +29682,11 @@ var LessonProgressTransition = class extends SmrtObject {
29299
29682
  learner_id: this.learnerId,
29300
29683
  lesson_id: this.lessonId
29301
29684
  })) throw new Error("Progress history requires its tenant progress");
29302
- if (!PROGRESS_STATUSES.includes(this.fromStatus) || !PROGRESS_STATUSES.includes(this.toStatus) || !["put-back", "start"].includes(this.kind)) throw new Error("Invalid progress transition");
29685
+ if (!PROGRESS_STATUSES.includes(this.fromStatus) || !PROGRESS_STATUSES.includes(this.toStatus) || ![
29686
+ "put-back",
29687
+ "start",
29688
+ "complete"
29689
+ ].includes(this.kind)) throw new Error("Invalid progress transition");
29303
29690
  requireText$1(this.actorUserId, "Progress history requires an actor");
29304
29691
  validDate$1(this.recordedAt);
29305
29692
  this.requireInsertOnSave();
@@ -29369,6 +29756,111 @@ EnrollmentStatusChange = __decorateClass$3([TenantScoped({ mode: "required" }),
29369
29756
  var EnrollmentStatusChangeCollection = class extends SmrtCollection {
29370
29757
  static _itemClass = EnrollmentStatusChange;
29371
29758
  };
29759
+ var LessonCompletionRecord = class extends SmrtObject {
29760
+ tenantId = "";
29761
+ progressId = "";
29762
+ enrollmentId = "";
29763
+ learnerId = "";
29764
+ lessonId = "";
29765
+ /** Progress status before completion. */
29766
+ fromStatus = "not_started";
29767
+ actorUserId = "";
29768
+ /** Host setting value that let this actor complete the lesson. */
29769
+ policyBasis = "";
29770
+ submissionId = null;
29771
+ recordedAt = /* @__PURE__ */ new Date();
29772
+ constructor(options = {}) {
29773
+ super(options);
29774
+ adopt$1(this, options);
29775
+ }
29776
+ async delete() {
29777
+ noDelete$1();
29778
+ }
29779
+ async save(options) {
29780
+ await requireInsert$1(this);
29781
+ if (!await this.db.get("lesson_progress", {
29782
+ id: this.progressId,
29783
+ tenant_id: this.tenantId,
29784
+ enrollment_id: this.enrollmentId,
29785
+ learner_id: this.learnerId,
29786
+ lesson_id: this.lessonId
29787
+ })) throw new Error("Completion history requires its tenant progress");
29788
+ if (this.submissionId !== null && !await this.db.get("submissions", {
29789
+ id: this.submissionId,
29790
+ tenant_id: this.tenantId,
29791
+ learner_id: this.learnerId,
29792
+ lesson_id: this.lessonId,
29793
+ progress_id: this.progressId
29794
+ })) throw new Error("Completion evidence must belong to the same progress");
29795
+ if (!PROGRESS_STATUSES.includes(this.fromStatus)) throw new Error("Invalid completion record");
29796
+ requireText$1(this.actorUserId, "Completion history requires an actor");
29797
+ requireText$1(this.policyBasis, "Completion history requires a policy basis");
29798
+ validDate$1(this.recordedAt);
29799
+ this.requireInsertOnSave();
29800
+ return super.save(options);
29801
+ }
29802
+ };
29803
+ __publicField$3(LessonCompletionRecord, "__smrtPackage__", "@happyvertical/smrt-education");
29804
+ __decorateClass$3([tenantId()], LessonCompletionRecord.prototype, "tenantId", 2);
29805
+ __decorateClass$3([foreignKey("LessonProgress", { required: true })], LessonCompletionRecord.prototype, "progressId", 2);
29806
+ __decorateClass$3([foreignKey("Enrollment", { required: true })], LessonCompletionRecord.prototype, "enrollmentId", 2);
29807
+ __decorateClass$3([foreignKey("Learner", { required: true })], LessonCompletionRecord.prototype, "learnerId", 2);
29808
+ __decorateClass$3([foreignKey("Lesson", { required: true })], LessonCompletionRecord.prototype, "lessonId", 2);
29809
+ __decorateClass$3([field({
29810
+ type: "text",
29811
+ nullable: true
29812
+ })], LessonCompletionRecord.prototype, "submissionId", 2);
29813
+ LessonCompletionRecord = __decorateClass$3([TenantScoped({ mode: "required" }), smrt({
29814
+ tableName: "lesson_completion_records",
29815
+ conflictColumns: ["tenant_id", "progress_id"],
29816
+ ...closed$2
29817
+ })], LessonCompletionRecord);
29818
+ var LessonCompletionRecordCollection = class extends SmrtCollection {
29819
+ static _itemClass = LessonCompletionRecord;
29820
+ };
29821
+ var EnrollmentPaceChange = class extends SmrtObject {
29822
+ tenantId = "";
29823
+ enrollmentId = "";
29824
+ learnerId = "";
29825
+ previousLessonsPerWeek = 0;
29826
+ newLessonsPerWeek = 0;
29827
+ actorUserId = "";
29828
+ policyBasis = "";
29829
+ recordedAt = /* @__PURE__ */ new Date();
29830
+ constructor(options = {}) {
29831
+ super(options);
29832
+ adopt$1(this, options);
29833
+ }
29834
+ async delete() {
29835
+ noDelete$1();
29836
+ }
29837
+ async save(options) {
29838
+ await requireInsert$1(this);
29839
+ if (!await this.db.get("enrollments", {
29840
+ id: this.enrollmentId,
29841
+ tenant_id: this.tenantId,
29842
+ learner_id: this.learnerId
29843
+ })) throw new Error("Pace history requires its tenant enrollment");
29844
+ for (const value of [this.previousLessonsPerWeek, this.newLessonsPerWeek]) if (!Number.isSafeInteger(value) || value < 0 || value > 14) throw new Error("Invalid enrollment pace change");
29845
+ if (this.previousLessonsPerWeek === this.newLessonsPerWeek) throw new Error("Invalid enrollment pace change");
29846
+ requireText$1(this.actorUserId, "Pace history requires an actor");
29847
+ requireText$1(this.policyBasis, "Pace history requires a policy basis");
29848
+ validDate$1(this.recordedAt);
29849
+ this.requireInsertOnSave();
29850
+ return super.save(options);
29851
+ }
29852
+ };
29853
+ __publicField$3(EnrollmentPaceChange, "__smrtPackage__", "@happyvertical/smrt-education");
29854
+ __decorateClass$3([tenantId()], EnrollmentPaceChange.prototype, "tenantId", 2);
29855
+ __decorateClass$3([foreignKey("Enrollment", { required: true })], EnrollmentPaceChange.prototype, "enrollmentId", 2);
29856
+ __decorateClass$3([foreignKey("Learner", { required: true })], EnrollmentPaceChange.prototype, "learnerId", 2);
29857
+ EnrollmentPaceChange = __decorateClass$3([TenantScoped({ mode: "required" }), smrt({
29858
+ tableName: "enrollment_pace_changes",
29859
+ ...closed$2
29860
+ })], EnrollmentPaceChange);
29861
+ var EnrollmentPaceChangeCollection = class extends SmrtCollection {
29862
+ static _itemClass = EnrollmentPaceChange;
29863
+ };
29372
29864
  //#endregion
29373
29865
  //#region src/workbook-completion.ts
29374
29866
  var __defProp$2 = Object.defineProperty;
@@ -29578,6 +30070,32 @@ var EnrollmentStatusTransitionError = class extends LearnerValidationError {
29578
30070
  from;
29579
30071
  to;
29580
30072
  };
30073
+ var EnrollmentNotActiveError = class extends LearnerValidationError {
30074
+ constructor(status) {
30075
+ super(`An enrollment that is ${status} cannot have lessons completed`);
30076
+ this.status = status;
30077
+ this.name = "EnrollmentNotActiveError";
30078
+ }
30079
+ status;
30080
+ };
30081
+ var LessonAlreadyCompleteError = class extends LearnerValidationError {
30082
+ constructor() {
30083
+ super("The lesson is already complete");
30084
+ this.name = "LessonAlreadyCompleteError";
30085
+ }
30086
+ };
30087
+ var WorkbookOwnedLessonError = class extends LearnerValidationError {
30088
+ constructor() {
30089
+ super("A workbook lesson is completed through the workbook workflow");
30090
+ this.name = "WorkbookOwnedLessonError";
30091
+ }
30092
+ };
30093
+ var LessonSkippedError = class extends LearnerValidationError {
30094
+ constructor() {
30095
+ super("A skipped lesson cannot be completed");
30096
+ this.name = "LessonSkippedError";
30097
+ }
30098
+ };
29581
30099
  var LearnerHistoryUnavailableError = class extends Error {
29582
30100
  constructor(table) {
29583
30101
  super(`Education history table ${table} is not migrated`);
@@ -29586,6 +30104,15 @@ var LearnerHistoryUnavailableError = class extends Error {
29586
30104
  }
29587
30105
  table;
29588
30106
  };
30107
+ var MIN_LESSONS_PER_WEEK = 1;
30108
+ var MAX_LESSONS_PER_WEEK = 14;
30109
+ var MAX_NEXT_LESSONS_COUNT = 14;
30110
+ var EnrollmentPaceError = class extends LearnerValidationError {
30111
+ constructor() {
30112
+ super(`Pace must be null or a whole number of lessons per week from 1 to 14`);
30113
+ this.name = "EnrollmentPaceError";
30114
+ }
30115
+ };
29589
30116
  var POLICY_BASIS_CONTROL = /[\u0000-\u001f\u007f]/;
29590
30117
  function requirePolicyBasis(value) {
29591
30118
  if (typeof value !== "string" || !value.trim() || value.length > 200 || POLICY_BASIS_CONTROL.test(value)) throw new LearnerValidationError("A valid policy basis is required");
@@ -29605,7 +30132,8 @@ function enrollmentView(row) {
29605
30132
  learnerId: row.learnerId,
29606
30133
  curriculumId: row.curriculumId,
29607
30134
  courseId: row.courseId,
29608
- status: row.status
30135
+ status: row.status,
30136
+ pace: row.paceLessonsPerWeek ? { lessonsPerWeek: row.paceLessonsPerWeek } : null
29609
30137
  };
29610
30138
  }
29611
30139
  function progressView(row) {
@@ -30218,10 +30746,13 @@ var LearnerService = class LearnerService {
30218
30746
  })).map(enrollmentView);
30219
30747
  }
30220
30748
  /**
30221
- * Projects the first unfinished lesson in each authorized enrollment. No
30749
+ * Projects the first unfinished lesson in each authorized enrollment, or up
30750
+ * to `count` (an integer from 1 to 14, default 1) in lesson order. No
30222
30751
  * catalog assets, answer material, model handles, or source locators escape.
30223
30752
  */
30224
30753
  async nextLessons(request) {
30754
+ const count = request.count === void 0 ? 1 : request.count;
30755
+ if (!Number.isInteger(count) || count < 1 || count > 14) throw new LearnerValidationError(`Count must be a whole number from 1 to 14`);
30225
30756
  const enrollments = request.enrollmentId ? [await this.requireEnrollment(request, "education.learner.read")] : await (await EnrollmentCollection.create({ db: this.db })).list({
30226
30757
  where: {
30227
30758
  tenantId: request.tenantId,
@@ -30239,16 +30770,13 @@ var LearnerService = class LearnerService {
30239
30770
  learnerId: request.learnerId
30240
30771
  } });
30241
30772
  const byLesson = new Map(await Promise.all(progresses.map(async (row) => [row.lessonId, await this.projectProgress(request, row)])));
30242
- const next = scope.entries.find(({ lesson: candidate }) => {
30243
- const progress2 = byLesson.get(String(candidate.id));
30244
- return !progress2 || !["complete", "skipped"].includes(progress2.status);
30245
- });
30246
- if (!next) continue;
30247
- const { lesson, course } = next;
30248
- const progress = byLesson.get(String(lesson.id));
30249
- result.push({
30773
+ const unfinished = scope.entries.filter(({ lesson: candidate }) => {
30774
+ const progress = byLesson.get(String(candidate.id));
30775
+ return !progress || !["complete", "skipped"].includes(progress.status);
30776
+ }).slice(0, count);
30777
+ for (const { lesson, course } of unfinished) result.push({
30250
30778
  enrollment: enrollmentView(enrollment),
30251
- progress: progress ?? null,
30779
+ progress: byLesson.get(String(lesson.id)) ?? null,
30252
30780
  lesson: {
30253
30781
  id: String(lesson.id),
30254
30782
  title: lesson.title,
@@ -30425,6 +30953,152 @@ var LearnerService = class LearnerService {
30425
30953
  };
30426
30954
  });
30427
30955
  }
30956
+ /**
30957
+ * The learner completes their own lesson of any kind under
30958
+ * `education.progress.complete-own`; the host decides per setting, for the
30959
+ * learner and for a guardian it also allows. Moves progress to `complete`,
30960
+ * sets `completedAt` and appends a `LessonCompletionRecord` with the actor,
30961
+ * `policyBasis` and optional `evidence.submissionId` (which must be a
30962
+ * submission of this tenant, learner and lesson). Counters and score are
30963
+ * untouched and no score is invented. Guardian `attestLesson` is unchanged.
30964
+ *
30965
+ * Repeat rule: when the lesson already completed through this path
30966
+ * (a completion record exists) the call returns `{ changed: false }`. A
30967
+ * lesson complete any other way (guardian attestation or grade, workbook
30968
+ * completion) throws `LessonAlreadyCompleteError`; `skipped` throws
30969
+ * `LessonSkippedError`; an enrollment that is not `active` throws
30970
+ * `EnrollmentNotActiveError`.
30971
+ */
30972
+ async completeOwnLesson(request) {
30973
+ const policyBasis = requirePolicyBasis(request.policyBasis);
30974
+ const evidence = request.evidence;
30975
+ if (evidence !== void 0 && (typeof evidence !== "object" || evidence === null || typeof evidence.submissionId !== "string" || !evidence.submissionId.trim())) throw new LearnerValidationError("Evidence needs a submission id");
30976
+ return this.mutate(async (service) => {
30977
+ const enrollment = await service.requireEnrollment(request, "education.progress.complete-own");
30978
+ await service.lock("enrollments", request.enrollmentId, request.tenantId);
30979
+ if (enrollment.status !== "active") throw new EnrollmentNotActiveError(enrollment.status);
30980
+ if (!await service.lessonInScope(request, enrollment, request.lessonId)) throw new LearnerAuthorizationError();
30981
+ let row = await (await LessonProgressCollection.create({ db: service.db })).get({
30982
+ enrollmentId: String(enrollment.id),
30983
+ lessonId: request.lessonId,
30984
+ tenantId: request.tenantId
30985
+ }, { cache: false });
30986
+ if (row && row.learnerId !== enrollment.learnerId) throw new LearnerAuthorizationError();
30987
+ const hasTables = await service.db.tableExists("lesson_completion_records") === true;
30988
+ const projected = row ? await service.projectProgress(request, row) : null;
30989
+ if (row && (row.status === "complete" || projected?.status === "complete")) {
30990
+ if ((hasTables ? await service.db.get("lesson_completion_records", {
30991
+ tenant_id: request.tenantId,
30992
+ progress_id: String(row.id)
30993
+ }) : null) && row.status === "complete") return {
30994
+ changed: false,
30995
+ progress: projected
30996
+ };
30997
+ throw new LessonAlreadyCompleteError();
30998
+ }
30999
+ if (row?.status === "skipped") throw new LessonSkippedError();
31000
+ if (await service.findWorkbookActivity(request, {
31001
+ enrollmentId: String(enrollment.id),
31002
+ lessonId: request.lessonId
31003
+ })) throw new WorkbookOwnedLessonError();
31004
+ if (!hasTables) throw new LearnerHistoryUnavailableError("lesson_completion_records");
31005
+ if (evidence) {
31006
+ const submission = row ? await service.db.get("submissions", {
31007
+ id: evidence.submissionId,
31008
+ tenant_id: request.tenantId,
31009
+ learner_id: enrollment.learnerId,
31010
+ lesson_id: request.lessonId,
31011
+ progress_id: String(row.id)
31012
+ }) : null;
31013
+ if (!submission || submission.status === "rejected") throw new LearnerAuthorizationError();
31014
+ }
31015
+ const from = row?.status ?? "not_started";
31016
+ const now = /* @__PURE__ */ new Date();
31017
+ if (!row) row = await (await LessonProgressCollection.create({ db: service.db })).create({
31018
+ tenantId: request.tenantId,
31019
+ enrollmentId: String(enrollment.id),
31020
+ learnerId: enrollment.learnerId,
31021
+ lessonId: request.lessonId,
31022
+ status: "complete",
31023
+ completedAt: now
31024
+ });
31025
+ else {
31026
+ row.status = "complete";
31027
+ row.completedAt = now;
31028
+ await row.save();
31029
+ }
31030
+ await (await LessonCompletionRecordCollection.create({ db: service.db })).create({
31031
+ _insertOnly: true,
31032
+ tenantId: request.tenantId,
31033
+ progressId: String(row.id),
31034
+ enrollmentId: row.enrollmentId,
31035
+ learnerId: row.learnerId,
31036
+ lessonId: row.lessonId,
31037
+ fromStatus: from,
31038
+ actorUserId: request.actorUserId,
31039
+ policyBasis,
31040
+ submissionId: evidence?.submissionId ?? null,
31041
+ recordedAt: now
31042
+ });
31043
+ if (await service.db.tableExists("lesson_progress_transitions")) await service.recordProgressTransition(request, row, from, "complete");
31044
+ return {
31045
+ changed: true,
31046
+ progress: await service.projectProgress(request, row)
31047
+ };
31048
+ });
31049
+ }
31050
+ /**
31051
+ * Sets or clears (`pace: null`) an enrollment's planned lessons per week
31052
+ * (a whole number from 1 to 14) under `education.enrollment.set-pace`; the
31053
+ * host decides whether a learner may act on their own enrollment. Appends an
31054
+ * immutable `EnrollmentPaceChange` with the actor, `policyBasis` and the
31055
+ * previous and new value (`0` means no pace). An unchanged pace is an
31056
+ * idempotent `{ changed: false }` that records nothing; an invalid pace
31057
+ * throws `EnrollmentPaceError`. Status, progress and history are untouched.
31058
+ */
31059
+ async setEnrollmentPace(request) {
31060
+ const policyBasis = requirePolicyBasis(request.policyBasis);
31061
+ const raw = request.pace;
31062
+ let lessonsPerWeek = 0;
31063
+ if (raw !== null) {
31064
+ const value = typeof raw === "object" && raw !== void 0 ? raw.lessonsPerWeek : void 0;
31065
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 14) throw new EnrollmentPaceError();
31066
+ lessonsPerWeek = value;
31067
+ }
31068
+ return this.mutate(async (service) => {
31069
+ await service.requireEnrollment(request, "education.enrollment.set-pace");
31070
+ await service.lock("enrollments", request.enrollmentId, request.tenantId);
31071
+ const enrollment = await (await EnrollmentCollection.create({ db: service.db })).get({
31072
+ id: request.enrollmentId,
31073
+ learnerId: request.learnerId,
31074
+ tenantId: request.tenantId
31075
+ }, { cache: false });
31076
+ if (!enrollment) throw new LearnerAuthorizationError();
31077
+ const previous = enrollment.paceLessonsPerWeek;
31078
+ if (previous === lessonsPerWeek) return {
31079
+ changed: false,
31080
+ enrollment: enrollmentView(enrollment)
31081
+ };
31082
+ if (!await service.db.tableExists("enrollment_pace_changes")) throw new LearnerHistoryUnavailableError("enrollment_pace_changes");
31083
+ enrollment.paceLessonsPerWeek = lessonsPerWeek;
31084
+ await enrollment.save();
31085
+ await (await EnrollmentPaceChangeCollection.create({ db: service.db })).create({
31086
+ _insertOnly: true,
31087
+ tenantId: request.tenantId,
31088
+ enrollmentId: String(enrollment.id),
31089
+ learnerId: enrollment.learnerId,
31090
+ previousLessonsPerWeek: previous,
31091
+ newLessonsPerWeek: lessonsPerWeek,
31092
+ actorUserId: request.actorUserId,
31093
+ policyBasis,
31094
+ recordedAt: /* @__PURE__ */ new Date()
31095
+ });
31096
+ return {
31097
+ changed: true,
31098
+ enrollment: enrollmentView(enrollment)
31099
+ };
31100
+ });
31101
+ }
30428
31102
  };
30429
31103
  //#endregion
30430
31104
  //#region src/question-extraction-contracts.ts
@@ -34513,6 +35187,6 @@ var WorkbookPhotoService = class WorkbookPhotoService extends WorkbookService {
34513
35187
  }
34514
35188
  };
34515
35189
  //#endregion
34516
- export { LearnerAuthorizationError as $, EnrollmentAutoUpgradeSkip as $n, VideoCollection as $r, GeneratedPrintableCollection as $t, normalizeQuestionExtraction as A, QuestionReferenceError as An, ReviewNotCurrentError as Ar, workbookAggregateFeedback as At, MAX_EXTRACTED_OMISSIONS as B, CourseDiscardTombstoneCollection as Bn, ExternalCompletionCollection as Br, FamilyIntakeAssetCollection as Bt, ExtractedQuestionLink as C, Video as Ci, PrintableStorageNotConfiguredError as Cn, CourseRequestKeyConflictError as Cr, WorkbookWorkFiling as Ct, QUESTION_EXTRACTION_ARGS_SCHEMA as D, QUESTION_PROVENANCES as Dn, EnrollmentPinConflictError as Dr, WorkbookCompletionConflictError as Dt, QuestionExtractionRecordCollection as E, QUESTION_PROMPT_SUMMARY_LENGTH as En, DEFAULT_PAGE_SIZE as Er, WorkbookAssessmentValidationError as Et, EXTRACTED_ORIGINS as F, delegationRecordFields as Fn, CourseCollection as Fr, GroupSharingPolicyValidationError as Ft, MAX_EXTRACTED_TEXT as G, CourseRevision as Gn, LessonCollection as Gr, QuestionAuthoringService as Gt, MAX_EXTRACTED_QUESTIONS as H, CourseDraftHeadCollection as Hn, GradeCollection as Hr, FamilyIntakeDecision as Ht, EXTRACTED_PAGE_ROLES as I, readDelegation as In, CourseSectionCollection as Ir, canonicalGroupSharingPolicy as It, QuestionExtractionNotFoundError as J, CourseUpgradeChoiceCollection as Jn, ReadingCollection as Jr, canonical$2 as Jt, QUESTION_EXTRACTION_SOURCE_KINDS as K, CourseRevisionCollection as Kn, LessonProgressCollection as Kr, buildGuardianWorksheet as Kt, EXTRACTED_QUESTION_STATUSES as L, CourseArchiveDecision as Ln, CurriculumCollection as Lr, validateGroupSharingPolicy as Lt, EXTRACTED_LEGIBILITY as M, groupSharingDigest as Mn, CatalogService as Mr, summarizeWorkbookEvidence as Mt, EXTRACTED_MEDIA_KINDS as N, resolveEnrollmentLessons as Nn, AttestationCollection as Nr, GroupSharingAuthorizationError as Nt, extractedQuestionContent as O, QUESTION_RESPONSE_TYPES as On, MAX_PAGE_SIZE as Or, aggregateWorkbookQuestions as Ot, EXTRACTED_OMISSION_REASONS as P, delegationActorFields as Pn, AuthoredCourseCollection as Pr, GroupSharingConflictError as Pt, EnrollmentStatusTransitionError as Q, EnrollmentAssignmentRecordCollection as Qn, SubmissionIntakeBindingCollection as Qr, GeneratedPrintable as Qt, EXTRACTED_READABILITY as R, CourseArchiveDecisionCollection as Rn, EnrollmentCollection as Rr, FamilyIntake as Rt, questionExtractionProposalDigest as S, Reading as Si, PrintableStorageCleanupError as Sn, CoursePolicyBasisMismatchError as Sr, WorkbookFeedbackReleaseCollection as St, QuestionExtractionRecord as T, QUESTION_FIELDS as Tn, CourseStaleHeadError as Tr, WORKBOOK_MATH_VOCABULARY as Tt, MAX_EXTRACTED_SECTIONS as U, CourseRelease as Un, LearnerCollection as Ur, FamilyIntakeDecisionCollection as Ut, MAX_EXTRACTED_PAGES as V, CourseDraftHead as Vn, ExternalCourseCollection as Vr, FamilyIntakeCollection as Vt, MAX_EXTRACTED_STUDENT_WORK as W, CourseReleaseCollection as Wn, LessonAssetCollection as Wr, familyIntakeScopeId as Wt, QuestionExtractionValidationError as X, CurriculumCreationRecordCollection as Xn, SubmissionAssetCollection as Xr, renderGuardianWorksheetPdf as Xt, QuestionExtractionPendingError as Y, CurriculumCreationRecord as Yn, ScannedSubmissionCollection as Yr, worksheetVersionId as Yt, ENROLLMENT_STATUS_TRANSITIONS as Z, EnrollmentAssignmentRecord as Zn, SubmissionCollection as Zr, renderLearnerWorksheetPdf as Zt, SELF_ATTESTABLE_LESSON_KINDS as _, Course as _i, MAX_PRINTABLE_PAGES as _n, CourseAuthoringValidationError as _r, WorkbookAttemptCollection as _t, WorkbookContextRevisionCollection as a, ScannedSubmission as ai, QuestionCollection as an, ReleasedLesson as ar, WorkbookCompletionDecision as at, WorkbookWorkflowRequiredError as b, ExternalCourse as bi, MAX_RESOLVE_QUESTION_REVISIONS as bn, CourseMaterialCopyError as br, WorkbookFeedbackDeliveryCollection as bt, WorkbookPhotoDecision as c, SubmissionIntakeBinding as ci, QuestionRevision as cn, AUTHORED_RELEASE_SOURCE_KEY as cr, EnrollmentStatusChangeCollection as ct, WorkbookPhotoIntakeCollection as d, LessonAsset as di, CourseAuthoringService as dn, mostRestrictiveLicense as dr, Workbook as dt, WorksheetCollection as ei, LearningObjective as en, EnrollmentAutoUpgradeSkipCollection as er, LearnerHistoryUnavailableError as et, validateWorkbookPhotoIdentification as f, Curriculum as fi, GENERATED_PRINTABLE_AUDIENCES as fn, COURSE_AUTHORING_ACTIONS as fr, WorkbookActivity as ft, OwnAttestationLessonKindError as g, AuthoredCourse as gi, MAX_PRINTABLE_ITEMS as gn, CourseAuthoringError as gr, WorkbookAttempt as gt, OwnAttestationConflictError as h, LessonProgress as hi, LearningObjectiveNotFoundError as hn, CourseAuthoringAuthorizationError as hr, WorkbookAssessmentRevisionCollection as ht, WorkbookContextRevision as i, LessonSelfAttestation as ii, Question as in, LessonRevisionCollection as ir, requirePolicyBasis as it, questionExtractionSourceIdentity as j, WorksheetVersionMismatchError as jn, CatalogAuthorizationError as jr, evaluateGroupSharingPolicy as jt, matchExtractedQuestions as k, QuestionNotFoundError as kn, ReviewGateRequiredError as kr, validateWorkbookAssessment as kt, WorkbookPhotoDecisionCollection as l, parseQuestionRubric as li, QuestionRevisionCollection as ln, isAuthoredReleaseGroupShareable as lr, LessonProgressTransition as lt, normalizeWorkbookAnswerRegions as m, Learner as mi, LearningObjectiveKeyConflictError as mn, CourseArchivedError as mr, WorkbookAssessmentRevision as mt, preservePhotoProposal as n, ExternalCompletion as ni, LearningObjectiveRevision as nn, EnrollmentReleasePinCollection as nr, LearnerValidationError as nt, WorkbookPhotoAsset as o, Submission as oi, QuestionResponse as on, ReleasedLessonCollection as or, WorkbookCompletionDecisionCollection as ot, normalizeWorkbookObservedLayout as p, Enrollment as pi, GENERATED_PRINTABLE_RELATIONSHIPS as pn, COURSE_PROVENANCES as pr, WorkbookActivityCollection as pt, QUESTION_EXTRACTION_VERSION as q, CourseUpgradeChoice as qn, LessonSelfAttestationCollection as qr, buildLearnerWorksheet as qt, WorkbookService as r, Grade as ri, LearningObjectiveRevisionCollection as rn, LessonRevision as rr, LessonPutBackError as rt, WorkbookPhotoAssetCollection as s, SubmissionAsset as si, QuestionResponseCollection as sn, AUTHORED_IDENTITY_SOURCE_KEY as sr, EnrollmentStatusChange as st, WorkbookPhotoService as t, Attestation as ti, LearningObjectiveCollection as tn, EnrollmentReleasePin as tr, LearnerService as tt, WorkbookPhotoIntake as u, validateRubricResults as ui, normalizeQuestionContent as un, isGroupShareableLicense as ur, LessonProgressTransitionCollection as ut, SubmissionService as v, CourseSection as vi, MAX_PRINTABLE_TEXT_LENGTH as vn, CourseDiscardRefusedError as vr, WorkbookCollection as vt, ExtractedQuestionLinkCollection as w, Worksheet as wi, QUESTION_COMPLETION_MODES as wn, CourseRevisionAlreadyReleasedError as wr, WorkbookWorkFilingCollection as wt, QuestionExtractionService as x, Lesson as xi, PrintableOwnerError as xn, CourseNotFoundError as xr, WorkbookFeedbackRelease as xt, SubmissionStorageCleanupError as y, Exercise as yi, MAX_PRINTABLE_TOTAL_TEXT as yn, CourseDiscardedError as yr, WorkbookFeedbackDelivery as yt, MAX_EXTRACTED_ANCHORS as z, CourseDiscardTombstone as zn, ExerciseCollection as zr, FamilyIntakeAsset as zt };
35190
+ export { EnrollmentPaceError as $, CourseArchiveDecisionCollection as $n, CatalogService as $r, FamilyIntakeAssetCollection as $t, normalizeQuestionExtraction as A, parseQuestionRubric as Ai, MAX_PRINTABLE_PAGES as An, mostRestrictiveLicense as Ar, WorkbookAttemptCollection as At, MAX_EXTRACTED_OMISSIONS as B, CourseSection as Bi, QUESTION_PROVENANCES as Bn, CourseMaterialCopyError as Br, WorkbookCompletionConflictError as Bt, ExtractedQuestionLink as C, ExternalCompletion as Ci, normalizeQuestionContent as Cn, LessonRevisionCollection as Cr, LessonProgressTransitionCollection as Ct, QUESTION_EXTRACTION_ARGS_SCHEMA as D, Submission as Di, LearningObjectiveKeyConflictError as Dn, AUTHORED_RELEASE_SOURCE_KEY as Dr, WorkbookAssessmentRevision as Dt, QuestionExtractionRecordCollection as E, ScannedSubmission as Ei, GENERATED_PRINTABLE_RELATIONSHIPS as En, AUTHORED_IDENTITY_SOURCE_KEY as Er, WorkbookActivityCollection as Et, EXTRACTED_ORIGINS as F, Learner as Fi, PrintableStorageCleanupError as Fn, CourseAuthoringError as Fr, WorkbookFeedbackReleaseCollection as Ft, MAX_EXTRACTED_TEXT as G, Video as Gi, groupSharingDigest as Gn, CourseStaleHeadError as Gr, summarizeWorkbookEvidence as Gt, MAX_EXTRACTED_QUESTIONS as H, ExternalCourse as Hi, QuestionNotFoundError as Hn, CoursePolicyBasisMismatchError as Hr, validateWorkbookAssessment as Ht, EXTRACTED_PAGE_ROLES as I, LessonProgress as Ii, PrintableStorageNotConfiguredError as In, CourseAuthoringValidationError as Ir, WorkbookWorkFiling as It, QuestionExtractionNotFoundError as J, resolveEnrollmentLessons as Jn, EnrollmentPinConflictError as Jr, GroupSharingPolicyValidationError as Jt, QUESTION_EXTRACTION_SOURCE_KINDS as K, Worksheet as Ki, authoredVisibilityPermitsGroups as Kn, CourseVisibilityRefusedError as Kr, GroupSharingAuthorizationError as Kt, EXTRACTED_QUESTION_STATUSES as L, AuthoredCourse as Li, QUESTION_COMPLETION_MODES as Ln, CourseCoverInvalidError as Lr, WorkbookWorkFilingCollection as Lt, EXTRACTED_LEGIBILITY as M, LessonAsset as Mi, MAX_PRINTABLE_TOTAL_TEXT as Mn, COURSE_PROVENANCES as Mr, WorkbookFeedbackDelivery as Mt, EXTRACTED_MEDIA_KINDS as N, Curriculum as Ni, MAX_RESOLVE_QUESTION_REVISIONS as Nn, CourseArchivedError as Nr, WorkbookFeedbackDeliveryCollection as Nt, extractedQuestionContent as O, SubmissionAsset as Oi, LearningObjectiveNotFoundError as On, isAuthoredReleaseGroupShareable as Or, WorkbookAssessmentRevisionCollection as Ot, EXTRACTED_OMISSION_REASONS as P, Enrollment as Pi, PrintableOwnerError as Pn, CourseAuthoringAuthorizationError as Pr, WorkbookFeedbackRelease as Pt, EnrollmentNotActiveError as Q, CourseArchiveDecision as Qn, CatalogAuthorizationError as Qr, FamilyIntakeAsset as Qt, EXTRACTED_READABILITY as R, COURSE_VISIBILITIES as Ri, QUESTION_FIELDS as Rn, CourseDiscardRefusedError as Rr, WORKBOOK_MATH_VOCABULARY as Rt, questionExtractionProposalDigest as S, Attestation as Si, QuestionRevisionCollection as Sn, LessonRevision as Sr, LessonProgressTransition as St, QuestionExtractionRecord as T, LessonSelfAttestation as Ti, GENERATED_PRINTABLE_AUDIENCES as Tn, ReleasedLessonCollection as Tr, WorkbookActivity as Tt, MAX_EXTRACTED_SECTIONS as U, Lesson as Ui, QuestionReferenceError as Un, CourseRequestKeyConflictError as Ur, workbookAggregateFeedback as Ut, MAX_EXTRACTED_PAGES as V, Exercise as Vi, QUESTION_RESPONSE_TYPES as Vn, CourseNotFoundError as Vr, aggregateWorkbookQuestions as Vt, MAX_EXTRACTED_STUDENT_WORK as W, Reading as Wi, WorksheetVersionMismatchError as Wn, CourseRevisionAlreadyReleasedError as Wr, evaluateGroupSharingPolicy as Wt, QuestionExtractionValidationError as X, delegationRecordFields as Xn, ReviewGateRequiredError as Xr, validateGroupSharingPolicy as Xt, QuestionExtractionPendingError as Y, delegationActorFields as Yn, MAX_PAGE_SIZE as Yr, canonicalGroupSharingPolicy as Yt, ENROLLMENT_STATUS_TRANSITIONS as Z, readDelegation as Zn, ReviewNotCurrentError as Zr, FamilyIntake as Zt, SELF_ATTESTABLE_LESSON_KINDS as _, SubmissionAssetCollection as _i, Question as _n, EnrollmentAssignmentRecordCollection as _r, EnrollmentPaceChangeCollection as _t, WorkbookContextRevisionCollection as a, EnrollmentCollection as ai, buildGuardianWorksheet as an, CourseDraftHeadCollection as ar, LessonAlreadyCompleteError as at, WorkbookWorkflowRequiredError as b, VideoCollection as bi, QuestionResponseCollection as bn, EnrollmentReleasePin as br, LessonCompletionRecord as bt, WorkbookPhotoDecision as c, ExternalCourseCollection as ci, worksheetVersionId as cn, CourseRevision as cr, MAX_LESSONS_PER_WEEK as ct, WorkbookPhotoIntakeCollection as d, LessonAssetCollection as di, GeneratedPrintable as dn, CourseUpgradeChoiceCollection as dr, WorkbookOwnedLessonError as dt, AttestationCollection as ei, FamilyIntakeCollection as en, CourseCoverChange as er, EnrollmentStatusTransitionError as et, validateWorkbookPhotoIdentification as f, LessonCollection as fi, GeneratedPrintableCollection as fn, CourseVisibilityChange as fr, enrollmentView as ft, OwnAttestationLessonKindError as g, ScannedSubmissionCollection as gi, LearningObjectiveRevisionCollection as gn, EnrollmentAssignmentRecord as gr, EnrollmentPaceChange as gt, OwnAttestationConflictError as h, ReadingCollection as hi, LearningObjectiveRevision as hn, CurriculumCreationRecordCollection as hr, WorkbookCompletionDecisionCollection as ht, WorkbookContextRevision as i, CurriculumCollection as ii, QuestionAuthoringService as in, CourseDraftHead as ir, LearnerValidationError as it, questionExtractionSourceIdentity as j, validateRubricResults as ji, MAX_PRINTABLE_TEXT_LENGTH as jn, COURSE_AUTHORING_ACTIONS as jr, WorkbookCollection as jt, matchExtractedQuestions as k, SubmissionIntakeBinding as ki, MAX_PRINTABLE_ITEMS as kn, isGroupShareableLicense as kr, WorkbookAttempt as kt, WorkbookPhotoDecisionCollection as l, GradeCollection as li, renderGuardianWorksheetPdf as ln, CourseRevisionCollection as lr, MAX_NEXT_LESSONS_COUNT as lt, normalizeWorkbookAnswerRegions as m, LessonSelfAttestationCollection as mi, LearningObjectiveCollection as mn, CurriculumCreationRecord as mr, WorkbookCompletionDecision as mt, preservePhotoProposal as n, CourseCollection as ni, FamilyIntakeDecisionCollection as nn, CourseDiscardTombstone as nr, LearnerHistoryUnavailableError as nt, WorkbookPhotoAsset as o, ExerciseCollection as oi, buildLearnerWorksheet as on, CourseRelease as or, LessonPutBackError as ot, normalizeWorkbookObservedLayout as p, LessonProgressCollection as pi, LearningObjective as pn, CourseVisibilityChangeCollection as pr, requirePolicyBasis as pt, QUESTION_EXTRACTION_VERSION as q, effectiveCourseVisibility as qn, DEFAULT_PAGE_SIZE as qr, GroupSharingConflictError as qt, WorkbookService as r, CourseSectionCollection as ri, familyIntakeScopeId as rn, CourseDiscardTombstoneCollection as rr, LearnerService as rt, WorkbookPhotoAssetCollection as s, ExternalCompletionCollection as si, canonical$2 as sn, CourseReleaseCollection as sr, LessonSkippedError as st, WorkbookPhotoService as t, AuthoredCourseCollection as ti, FamilyIntakeDecision as tn, CourseCoverChangeCollection as tr, LearnerAuthorizationError as tt, WorkbookPhotoIntake as u, LearnerCollection as ui, renderLearnerWorksheetPdf as un, CourseUpgradeChoice as ur, MIN_LESSONS_PER_WEEK as ut, SubmissionService as v, SubmissionCollection as vi, QuestionCollection as vn, EnrollmentAutoUpgradeSkip as vr, EnrollmentStatusChange as vt, ExtractedQuestionLinkCollection as w, Grade as wi, CourseAuthoringService as wn, ReleasedLesson as wr, Workbook as wt, QuestionExtractionService as x, WorksheetCollection as xi, QuestionRevision as xn, EnrollmentReleasePinCollection as xr, LessonCompletionRecordCollection as xt, SubmissionStorageCleanupError as y, SubmissionIntakeBindingCollection as yi, QuestionResponse as yn, EnrollmentAutoUpgradeSkipCollection as yr, EnrollmentStatusChangeCollection as yt, MAX_EXTRACTED_ANCHORS as z, Course as zi, QUESTION_PROMPT_SUMMARY_LENGTH as zn, CourseDiscardedError as zr, WorkbookAssessmentValidationError as zt };
34517
35191
 
34518
- //# sourceMappingURL=workbook-photo-service-B7Tujv__.js.map
35192
+ //# sourceMappingURL=workbook-photo-service-WwDDC5GF.js.map