@things-factory/learning 0.0.0-stage → 10.1.261

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 (167) hide show
  1. package/dist-client/bootstrap.d.ts +2 -0
  2. package/dist-client/bootstrap.js +4 -0
  3. package/dist-client/bootstrap.js.map +1 -0
  4. package/dist-client/components/learning-assign-form.d.ts +24 -0
  5. package/dist-client/components/learning-assign-form.js +77 -0
  6. package/dist-client/components/learning-assign-form.js.map +1 -0
  7. package/dist-client/components/learning-course-form.d.ts +29 -0
  8. package/dist-client/components/learning-course-form.js +104 -0
  9. package/dist-client/components/learning-course-form.js.map +1 -0
  10. package/dist-client/components/learning-material-form.d.ts +41 -0
  11. package/dist-client/components/learning-material-form.js +202 -0
  12. package/dist-client/components/learning-material-form.js.map +1 -0
  13. package/dist-client/components/learning-question-form.d.ts +33 -0
  14. package/dist-client/components/learning-question-form.js +142 -0
  15. package/dist-client/components/learning-question-form.js.map +1 -0
  16. package/dist-client/components/learning-todo.d.ts +12 -0
  17. package/dist-client/components/learning-todo.js +43 -0
  18. package/dist-client/components/learning-todo.js.map +1 -0
  19. package/dist-client/components/learning-unit-form.d.ts +44 -0
  20. package/dist-client/components/learning-unit-form.js +151 -0
  21. package/dist-client/components/learning-unit-form.js.map +1 -0
  22. package/dist-client/index.d.ts +2 -0
  23. package/dist-client/index.js +3 -0
  24. package/dist-client/index.js.map +1 -0
  25. package/dist-client/pages/learning-course-editor.d.ts +63 -0
  26. package/dist-client/pages/learning-course-editor.js +476 -0
  27. package/dist-client/pages/learning-course-editor.js.map +1 -0
  28. package/dist-client/pages/learning-course-list.d.ts +37 -0
  29. package/dist-client/pages/learning-course-list.js +92 -0
  30. package/dist-client/pages/learning-course-list.js.map +1 -0
  31. package/dist-client/pages/learning-enrollment-status.d.ts +65 -0
  32. package/dist-client/pages/learning-enrollment-status.js +211 -0
  33. package/dist-client/pages/learning-enrollment-status.js.map +1 -0
  34. package/dist-client/pages/learning-view.d.ts +63 -0
  35. package/dist-client/pages/learning-view.js +446 -0
  36. package/dist-client/pages/learning-view.js.map +1 -0
  37. package/dist-client/pages/my-learning.d.ts +22 -0
  38. package/dist-client/pages/my-learning.js +110 -0
  39. package/dist-client/pages/my-learning.js.map +1 -0
  40. package/dist-client/route.d.ts +1 -0
  41. package/dist-client/route.js +21 -0
  42. package/dist-client/route.js.map +1 -0
  43. package/dist-client/tsconfig.tsbuildinfo +1 -0
  44. package/dist-client/utils/learning-api.d.ts +61 -0
  45. package/dist-client/utils/learning-api.js +85 -0
  46. package/dist-client/utils/learning-api.js.map +1 -0
  47. package/dist-client/utils/learning-words.d.ts +20 -0
  48. package/dist-client/utils/learning-words.js +22 -0
  49. package/dist-client/utils/learning-words.js.map +1 -0
  50. package/dist-client/utils/material-source.d.ts +12 -0
  51. package/dist-client/utils/material-source.js +77 -0
  52. package/dist-client/utils/material-source.js.map +1 -0
  53. package/dist-server/activities.d.ts +38 -0
  54. package/dist-server/activities.js +42 -0
  55. package/dist-server/activities.js.map +1 -0
  56. package/dist-server/ai-reads.d.ts +9 -0
  57. package/dist-server/ai-reads.js +70 -0
  58. package/dist-server/ai-reads.js.map +1 -0
  59. package/dist-server/attachment-reader.d.ts +1 -0
  60. package/dist-server/attachment-reader.js +23 -0
  61. package/dist-server/attachment-reader.js.map +1 -0
  62. package/dist-server/index.d.ts +4 -0
  63. package/dist-server/index.js +33 -0
  64. package/dist-server/index.js.map +1 -0
  65. package/dist-server/learning-subjects.d.ts +10 -0
  66. package/dist-server/learning-subjects.js +31 -0
  67. package/dist-server/learning-subjects.js.map +1 -0
  68. package/dist-server/role-tasks.d.ts +2 -0
  69. package/dist-server/role-tasks.js +17 -0
  70. package/dist-server/role-tasks.js.map +1 -0
  71. package/dist-server/service/course/course-mutation.d.ts +19 -0
  72. package/dist-server/service/course/course-mutation.js +177 -0
  73. package/dist-server/service/course/course-mutation.js.map +1 -0
  74. package/dist-server/service/course/course-query.d.ts +27 -0
  75. package/dist-server/service/course/course-query.js +183 -0
  76. package/dist-server/service/course/course-query.js.map +1 -0
  77. package/dist-server/service/course/course-question.d.ts +41 -0
  78. package/dist-server/service/course/course-question.js +116 -0
  79. package/dist-server/service/course/course-question.js.map +1 -0
  80. package/dist-server/service/course/course-rules.d.ts +51 -0
  81. package/dist-server/service/course/course-rules.js +93 -0
  82. package/dist-server/service/course/course-rules.js.map +1 -0
  83. package/dist-server/service/course/course-service.d.ts +72 -0
  84. package/dist-server/service/course/course-service.js +398 -0
  85. package/dist-server/service/course/course-service.js.map +1 -0
  86. package/dist-server/service/course/course-type.d.ts +56 -0
  87. package/dist-server/service/course/course-type.js +202 -0
  88. package/dist-server/service/course/course-type.js.map +1 -0
  89. package/dist-server/service/course/course-unit.d.ts +37 -0
  90. package/dist-server/service/course/course-unit.js +128 -0
  91. package/dist-server/service/course/course-unit.js.map +1 -0
  92. package/dist-server/service/course/course-version.d.ts +45 -0
  93. package/dist-server/service/course/course-version.js +151 -0
  94. package/dist-server/service/course/course-version.js.map +1 -0
  95. package/dist-server/service/course/course.d.ts +23 -0
  96. package/dist-server/service/course/course.js +92 -0
  97. package/dist-server/service/course/course.js.map +1 -0
  98. package/dist-server/service/course/index.d.ts +15 -0
  99. package/dist-server/service/course/index.js +25 -0
  100. package/dist-server/service/course/index.js.map +1 -0
  101. package/dist-server/service/course/material-audio.d.ts +1 -0
  102. package/dist-server/service/course/material-audio.js +43 -0
  103. package/dist-server/service/course/material-audio.js.map +1 -0
  104. package/dist-server/service/course/material-draft-mutation.d.ts +13 -0
  105. package/dist-server/service/course/material-draft-mutation.js +55 -0
  106. package/dist-server/service/course/material-draft-mutation.js.map +1 -0
  107. package/dist-server/service/course/material-draft-rules.d.ts +38 -0
  108. package/dist-server/service/course/material-draft-rules.js +83 -0
  109. package/dist-server/service/course/material-draft-rules.js.map +1 -0
  110. package/dist-server/service/course/material-draft-service.d.ts +15 -0
  111. package/dist-server/service/course/material-draft-service.js +162 -0
  112. package/dist-server/service/course/material-draft-service.js.map +1 -0
  113. package/dist-server/service/enrollment/enrollment-mutation.d.ts +11 -0
  114. package/dist-server/service/enrollment/enrollment-mutation.js +124 -0
  115. package/dist-server/service/enrollment/enrollment-mutation.js.map +1 -0
  116. package/dist-server/service/enrollment/enrollment-query.d.ts +24 -0
  117. package/dist-server/service/enrollment/enrollment-query.js +236 -0
  118. package/dist-server/service/enrollment/enrollment-query.js.map +1 -0
  119. package/dist-server/service/enrollment/enrollment-remind.d.ts +1 -0
  120. package/dist-server/service/enrollment/enrollment-remind.js +45 -0
  121. package/dist-server/service/enrollment/enrollment-remind.js.map +1 -0
  122. package/dist-server/service/enrollment/enrollment-service.d.ts +110 -0
  123. package/dist-server/service/enrollment/enrollment-service.js +427 -0
  124. package/dist-server/service/enrollment/enrollment-service.js.map +1 -0
  125. package/dist-server/service/enrollment/enrollment-status.d.ts +53 -0
  126. package/dist-server/service/enrollment/enrollment-status.js +194 -0
  127. package/dist-server/service/enrollment/enrollment-status.js.map +1 -0
  128. package/dist-server/service/enrollment/enrollment-type.d.ts +82 -0
  129. package/dist-server/service/enrollment/enrollment-type.js +293 -0
  130. package/dist-server/service/enrollment/enrollment-type.js.map +1 -0
  131. package/dist-server/service/enrollment/enrollment.d.ts +53 -0
  132. package/dist-server/service/enrollment/enrollment.js +191 -0
  133. package/dist-server/service/enrollment/enrollment.js.map +1 -0
  134. package/dist-server/service/enrollment/index.d.ts +15 -0
  135. package/dist-server/service/enrollment/index.js +26 -0
  136. package/dist-server/service/enrollment/index.js.map +1 -0
  137. package/dist-server/service/enrollment/learning-ai.d.ts +45 -0
  138. package/dist-server/service/enrollment/learning-ai.js +93 -0
  139. package/dist-server/service/enrollment/learning-ai.js.map +1 -0
  140. package/dist-server/service/enrollment/learning-documents.d.ts +7 -0
  141. package/dist-server/service/enrollment/learning-documents.js +159 -0
  142. package/dist-server/service/enrollment/learning-documents.js.map +1 -0
  143. package/dist-server/service/enrollment/learning-outcome.d.ts +10 -0
  144. package/dist-server/service/enrollment/learning-outcome.js +16 -0
  145. package/dist-server/service/enrollment/learning-outcome.js.map +1 -0
  146. package/dist-server/service/enrollment/progress-rules.d.ts +119 -0
  147. package/dist-server/service/enrollment/progress-rules.js +132 -0
  148. package/dist-server/service/enrollment/progress-rules.js.map +1 -0
  149. package/dist-server/service/enrollment/quiz-attempt.d.ts +34 -0
  150. package/dist-server/service/enrollment/quiz-attempt.js +105 -0
  151. package/dist-server/service/enrollment/quiz-attempt.js.map +1 -0
  152. package/dist-server/service/enrollment/unit-progress.d.ts +34 -0
  153. package/dist-server/service/enrollment/unit-progress.js +134 -0
  154. package/dist-server/service/enrollment/unit-progress.js.map +1 -0
  155. package/dist-server/service/index.d.ts +14 -0
  156. package/dist-server/service/index.js +43 -0
  157. package/dist-server/service/index.js.map +1 -0
  158. package/dist-server/tsconfig.tsbuildinfo +1 -0
  159. package/package.json +57 -4
  160. package/things-factory.config.js +14 -0
  161. package/translations/en.json +278 -0
  162. package/translations/es.json +278 -0
  163. package/translations/ja.json +278 -0
  164. package/translations/ko.json +278 -0
  165. package/translations/ms.json +278 -0
  166. package/translations/zh.json +278 -0
  167. package/README.md +0 -3
@@ -0,0 +1,159 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.REGISTER_TEMPLATE = exports.CERTIFICATE_TEMPLATE = exports.LEARNING_REGISTER_KIND = exports.LEARNING_CERTIFICATE_KIND = void 0;
4
+ exports.registerLearningDocuments = registerLearningDocuments;
5
+ /*
6
+ * The documents this module issues through the pdf module (ADR-0194 decisions 9, 10 · ADR-0176): the certificate of a completed
7
+ * enrolment (`learning.certificate`) and the completion register of a course (`learning.completion-register`). The pdf module
8
+ * numbers and prints them with 「우리 회사」 - the company's name, representative and seal as the application keeps them.
9
+ */
10
+ const auth_base_1 = require("@things-factory/auth-base");
11
+ const pdf_1 = require("@things-factory/pdf");
12
+ const shell_1 = require("@things-factory/shell");
13
+ const course_1 = require("../course/course");
14
+ const course_version_1 = require("../course/course-version");
15
+ const enrollment_1 = require("./enrollment");
16
+ const enrollment_status_1 = require("./enrollment-status");
17
+ exports.LEARNING_CERTIFICATE_KIND = 'learning.certificate';
18
+ exports.LEARNING_REGISTER_KIND = 'learning.completion-register';
19
+ const STYLE = `@page { size: A4; margin: 0 }
20
+ body { margin: 0; font-family: "Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans KR", sans-serif; color: #222 }
21
+ .page { position: relative; box-sizing: border-box; width: 210mm; min-height: 297mm; padding: 30mm 24mm }
22
+ .number { position: absolute; top: 16mm; left: 24mm; font-size: 9.5pt; color: #666 }
23
+ h1 { margin: 6mm 0 16mm; text-align: center; font-size: 26pt; letter-spacing: 12pt }
24
+ table { width: 100%; border-collapse: collapse; font-size: 11.5pt }
25
+ th, td { border: 1px solid #444; padding: 3.2mm 4mm; text-align: left; vertical-align: middle }
26
+ th { background: #f2f2f2; font-weight: 600; text-align: center }
27
+ .statement { margin: 22mm 0 0; text-align: center; font-size: 13pt; line-height: 2 }
28
+ .date { margin-top: 18mm; text-align: center; font-size: 13pt; letter-spacing: 1pt }
29
+ .issuer { position: relative; margin: 20mm auto 0; width: max-content; font-size: 14pt; line-height: 2; text-align: left; padding-right: 22mm }
30
+ .issuer img { position: absolute; right: 0; top: 2mm; width: 22mm; opacity: .9 }`;
31
+ const ISSUER = `<div class="issuer">
32
+ <div>상호 : {{company.name}}</div>
33
+ {{#if company.address}}<div>주소 : {{company.address}}</div>{{/if}}
34
+ <div>대표이사 : {{company.representative}}</div>
35
+ {{#if company.seal}}<img src="{{company.seal}}" alt="">{{/if}}
36
+ </div>`;
37
+ /** The certificate's template in code - a domain's own `PDFTemplate` of the kind prints in its place. */
38
+ exports.CERTIFICATE_TEMPLATE = `<!doctype html><html lang="ko"><head><meta charset="utf-8"><style>
39
+ ${STYLE}
40
+ th { width: 32mm }
41
+ </style></head><body><div class="page">
42
+ <div class="number">발급번호 {{issue.number}}</div>
43
+ <h1>수료증</h1>
44
+ <table>
45
+ <tr><th>성명</th><td>{{learner.name}}</td></tr>
46
+ <tr><th>과정</th><td>{{course.name}} (개정본 {{course.version}})</td></tr>
47
+ <tr><th>이수일</th><td>{{completion.completedOn}}</td></tr>
48
+ <tr><th>학습 시간</th><td>{{completion.minutes}}분</td></tr>
49
+ {{#if completion.score}}<tr><th>평가 점수</th><td>{{completion.score}}점</td></tr>{{/if}}
50
+ <tr><th>수료 번호</th><td>{{completion.certificateNo}}</td></tr>
51
+ </table>
52
+ <p class="statement">위 사람은 위 과정을 이수하였음을 증명합니다.</p>
53
+ <div class="date">{{issue.issuedOn}}</div>
54
+ ${ISSUER}
55
+ </div></body></html>`;
56
+ /** The completion register's template - one row per completion. */
57
+ exports.REGISTER_TEMPLATE = `<!doctype html><html lang="ko"><head><meta charset="utf-8"><style>
58
+ ${STYLE}
59
+ .page { padding: 24mm 16mm }
60
+ h1 { font-size: 20pt; letter-spacing: 4pt; margin-bottom: 8mm }
61
+ .meta { margin-bottom: 6mm; font-size: 10.5pt }
62
+ table { font-size: 9.5pt }
63
+ th, td { padding: 2mm 2.4mm }
64
+ </style></head><body><div class="page">
65
+ <div class="number">발급번호 {{issue.number}}</div>
66
+ <h1>이수 대장</h1>
67
+ <div class="meta">과정 : {{course.name}} · 기준일 : {{issue.asOf}} · 이수자 {{count}}명</div>
68
+ <table>
69
+ <tr><th>번호</th><th>성명</th><th>부서</th><th>개정본</th><th>이수일</th><th>학습 시간(분)</th><th>점수</th><th>수료 번호</th></tr>
70
+ {{#each rows}}<tr><td>{{this.no}}</td><td>{{this.name}}</td><td>{{this.department}}</td><td>{{this.version}}</td><td>{{this.completedOn}}</td><td>{{this.minutes}}</td><td>{{this.score}}</td><td>{{this.certificateNo}}</td></tr>{{/each}}
71
+ </table>
72
+ <div class="date">{{issue.issuedOn}}</div>
73
+ ${ISSUER}
74
+ </div></body></html>`;
75
+ const dayOf = (at, timezone) => at ? new Intl.DateTimeFormat('en-CA', { timeZone: timezone || 'UTC', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(at)) : '';
76
+ async function enrollmentFor(context, id) {
77
+ return id ? await (0, shell_1.getRepository)(enrollment_1.Enrollment, context.state.tx).findOne({ where: { id, domain: { id: context.state.domain.id } } }) : null;
78
+ }
79
+ /** A certificate is issued and read by the person it is of, once completed, and by a manager. */
80
+ async function mayHaveCertificate(context, id) {
81
+ const enrollment = await enrollmentFor(context, id ?? '');
82
+ if (!enrollment || enrollment.status !== enrollment_1.EnrollmentStatus.COMPLETED)
83
+ return false;
84
+ return enrollment.userId === context.state.user?.id || (await (0, auth_base_1.requestHolds)(context, 'course', 'query'));
85
+ }
86
+ function registerLearningDocuments() {
87
+ (0, pdf_1.registerIssuedDocumentKind)({
88
+ kind: exports.LEARNING_CERTIFICATE_KIND,
89
+ prefix: '수료',
90
+ refType: 'Enrollment',
91
+ template: { content_template: exports.CERTIFICATE_TEMPLATE, page_size: 'A4' },
92
+ async dataOf(context, refId) {
93
+ const enrollment = await enrollmentFor(context, refId);
94
+ if (!enrollment || enrollment.status !== enrollment_1.EnrollmentStatus.COMPLETED)
95
+ throw new shell_1.Refusal('learning-not-completed', {}, 'a certificate is issued for a completed enrolment');
96
+ const tx = context.state.tx;
97
+ const course = await (0, shell_1.getRepository)(course_1.Course, tx).findOneBy({ id: enrollment.courseId });
98
+ const version = await (0, shell_1.getRepository)(course_version_1.CourseVersion, tx).findOne({ where: { id: enrollment.versionId }, withDeleted: true });
99
+ const learner = await (0, shell_1.getRepository)(auth_base_1.User, tx).findOneBy({ id: enrollment.userId });
100
+ return {
101
+ subjectName: `${learner?.name ?? ''} · ${course?.name ?? ''}`,
102
+ data: {
103
+ learner: { name: learner?.name ?? '' },
104
+ course: { name: course?.name ?? '', version: version?.number ?? '' },
105
+ completion: {
106
+ completedOn: dayOf(enrollment.completedAt, context.state.domain.timezone),
107
+ minutes: Math.floor((enrollment.learnedSeconds ?? 0) / 60),
108
+ score: enrollment.score ?? null,
109
+ certificateNo: enrollment.certificateNo ?? ''
110
+ }
111
+ }
112
+ };
113
+ },
114
+ async mayIssue(context, refId) {
115
+ return await mayHaveCertificate(context, refId);
116
+ },
117
+ async mayRead(context, row) {
118
+ return await mayHaveCertificate(context, row.refId);
119
+ }
120
+ });
121
+ (0, pdf_1.registerIssuedDocumentKind)({
122
+ kind: exports.LEARNING_REGISTER_KIND,
123
+ prefix: '이수대장',
124
+ refType: 'Course',
125
+ asOf: true,
126
+ template: { content_template: exports.REGISTER_TEMPLATE, page_size: 'A4' },
127
+ async dataOf(context, refId, input) {
128
+ const course = await (0, shell_1.getRepository)(course_1.Course, context.state.tx).findOne({ where: { id: refId, domain: { id: context.state.domain.id } } });
129
+ if (!course)
130
+ throw new shell_1.Refusal('learning-not-found', {}, 'the record was not found. reload and try again.');
131
+ const timezone = context.state.domain.timezone;
132
+ const rows = (await (0, enrollment_status_1.completionRegister)(course.id, context)).filter(row => !input.asOf || dayOf(row.enrollment.completedAt, timezone) <= input.asOf);
133
+ return {
134
+ subjectName: course.name,
135
+ data: {
136
+ course: { name: course.name },
137
+ count: rows.length,
138
+ rows: rows.map((row, index) => ({
139
+ no: index + 1,
140
+ name: row.user?.name ?? '',
141
+ department: row.departmentName ?? '',
142
+ version: row.versionNumber ?? '',
143
+ completedOn: dayOf(row.enrollment.completedAt, timezone),
144
+ minutes: Math.floor((row.enrollment.learnedSeconds ?? 0) / 60),
145
+ score: row.enrollment.score ?? '',
146
+ certificateNo: row.enrollment.certificateNo ?? ''
147
+ }))
148
+ }
149
+ };
150
+ },
151
+ async mayIssue(context) {
152
+ return await (0, auth_base_1.requestHolds)(context, 'course', 'query');
153
+ },
154
+ async mayRead(context) {
155
+ return await (0, auth_base_1.requestHolds)(context, 'course', 'query');
156
+ }
157
+ });
158
+ }
159
+ //# sourceMappingURL=learning-documents.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"learning-documents.js","sourceRoot":"","sources":["../../../server/service/enrollment/learning-documents.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * The documents this module issues through the pdf module (ADR-0194 decisions 9, 10 · ADR-0176): the certificate of a completed\n * enrolment (`learning.certificate`) and the completion register of a course (`learning.completion-register`). The pdf module\n * numbers and prints them with 「우리 회사」 - the company's name, representative and seal as the application keeps them.\n */\nimport { requestHolds, User } from '@things-factory/auth-base'\nimport { registerIssuedDocumentKind } from '@things-factory/pdf'\nimport { getRepository, Refusal } from '@things-factory/shell'\n\nimport { Course } from '../course/course'\nimport { CourseVersion } from '../course/course-version'\nimport { Enrollment, EnrollmentStatus } from './enrollment'\nimport { completionRegister } from './enrollment-status'\n\nexport const LEARNING_CERTIFICATE_KIND = 'learning.certificate'\nexport const LEARNING_REGISTER_KIND = 'learning.completion-register'\n\nconst STYLE = `@page { size: A4; margin: 0 }\nbody { margin: 0; font-family: \"Apple SD Gothic Neo\", \"Malgun Gothic\", \"Noto Sans KR\", sans-serif; color: #222 }\n.page { position: relative; box-sizing: border-box; width: 210mm; min-height: 297mm; padding: 30mm 24mm }\n.number { position: absolute; top: 16mm; left: 24mm; font-size: 9.5pt; color: #666 }\nh1 { margin: 6mm 0 16mm; text-align: center; font-size: 26pt; letter-spacing: 12pt }\ntable { width: 100%; border-collapse: collapse; font-size: 11.5pt }\nth, td { border: 1px solid #444; padding: 3.2mm 4mm; text-align: left; vertical-align: middle }\nth { background: #f2f2f2; font-weight: 600; text-align: center }\n.statement { margin: 22mm 0 0; text-align: center; font-size: 13pt; line-height: 2 }\n.date { margin-top: 18mm; text-align: center; font-size: 13pt; letter-spacing: 1pt }\n.issuer { position: relative; margin: 20mm auto 0; width: max-content; font-size: 14pt; line-height: 2; text-align: left; padding-right: 22mm }\n.issuer img { position: absolute; right: 0; top: 2mm; width: 22mm; opacity: .9 }`\n\nconst ISSUER = `<div class=\"issuer\">\n<div>상호 : {{company.name}}</div>\n{{#if company.address}}<div>주소 : {{company.address}}</div>{{/if}}\n<div>대표이사 : {{company.representative}}</div>\n{{#if company.seal}}<img src=\"{{company.seal}}\" alt=\"\">{{/if}}\n</div>`\n\n/** The certificate's template in code - a domain's own `PDFTemplate` of the kind prints in its place. */\nexport const CERTIFICATE_TEMPLATE = `<!doctype html><html lang=\"ko\"><head><meta charset=\"utf-8\"><style>\n${STYLE}\nth { width: 32mm }\n</style></head><body><div class=\"page\">\n<div class=\"number\">발급번호 {{issue.number}}</div>\n<h1>수료증</h1>\n<table>\n<tr><th>성명</th><td>{{learner.name}}</td></tr>\n<tr><th>과정</th><td>{{course.name}} (개정본 {{course.version}})</td></tr>\n<tr><th>이수일</th><td>{{completion.completedOn}}</td></tr>\n<tr><th>학습 시간</th><td>{{completion.minutes}}분</td></tr>\n{{#if completion.score}}<tr><th>평가 점수</th><td>{{completion.score}}점</td></tr>{{/if}}\n<tr><th>수료 번호</th><td>{{completion.certificateNo}}</td></tr>\n</table>\n<p class=\"statement\">위 사람은 위 과정을 이수하였음을 증명합니다.</p>\n<div class=\"date\">{{issue.issuedOn}}</div>\n${ISSUER}\n</div></body></html>`\n\n/** The completion register's template - one row per completion. */\nexport const REGISTER_TEMPLATE = `<!doctype html><html lang=\"ko\"><head><meta charset=\"utf-8\"><style>\n${STYLE}\n.page { padding: 24mm 16mm }\nh1 { font-size: 20pt; letter-spacing: 4pt; margin-bottom: 8mm }\n.meta { margin-bottom: 6mm; font-size: 10.5pt }\ntable { font-size: 9.5pt }\nth, td { padding: 2mm 2.4mm }\n</style></head><body><div class=\"page\">\n<div class=\"number\">발급번호 {{issue.number}}</div>\n<h1>이수 대장</h1>\n<div class=\"meta\">과정 : {{course.name}} · 기준일 : {{issue.asOf}} · 이수자 {{count}}명</div>\n<table>\n<tr><th>번호</th><th>성명</th><th>부서</th><th>개정본</th><th>이수일</th><th>학습 시간(분)</th><th>점수</th><th>수료 번호</th></tr>\n{{#each rows}}<tr><td>{{this.no}}</td><td>{{this.name}}</td><td>{{this.department}}</td><td>{{this.version}}</td><td>{{this.completedOn}}</td><td>{{this.minutes}}</td><td>{{this.score}}</td><td>{{this.certificateNo}}</td></tr>{{/each}}\n</table>\n<div class=\"date\">{{issue.issuedOn}}</div>\n${ISSUER}\n</div></body></html>`\n\nconst dayOf = (at: Date | string | null | undefined, timezone?: string | null) =>\n at ? new Intl.DateTimeFormat('en-CA', { timeZone: timezone || 'UTC', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(at)) : ''\n\nasync function enrollmentFor(context: any, id: string): Promise<Enrollment | null> {\n return id ? await getRepository(Enrollment, context.state.tx).findOne({ where: { id, domain: { id: context.state.domain.id } } as any }) : null\n}\n\n/** A certificate is issued and read by the person it is of, once completed, and by a manager. */\nasync function mayHaveCertificate(context: any, id: string | null | undefined): Promise<boolean> {\n const enrollment = await enrollmentFor(context, id ?? '')\n if (!enrollment || enrollment.status !== EnrollmentStatus.COMPLETED) return false\n return enrollment.userId === context.state.user?.id || (await requestHolds(context, 'course', 'query'))\n}\n\nexport function registerLearningDocuments(): void {\n registerIssuedDocumentKind({\n kind: LEARNING_CERTIFICATE_KIND,\n prefix: '수료',\n refType: 'Enrollment',\n template: { content_template: CERTIFICATE_TEMPLATE, page_size: 'A4' },\n async dataOf(context, refId) {\n const enrollment = await enrollmentFor(context, refId)\n if (!enrollment || enrollment.status !== EnrollmentStatus.COMPLETED) throw new Refusal('learning-not-completed', {}, 'a certificate is issued for a completed enrolment')\n const tx = context.state.tx\n const course = await getRepository(Course, tx).findOneBy({ id: enrollment.courseId! })\n const version = await getRepository(CourseVersion, tx).findOne({ where: { id: enrollment.versionId! } as any, withDeleted: true })\n const learner = await getRepository(User, tx).findOneBy({ id: enrollment.userId! })\n return {\n subjectName: `${learner?.name ?? ''} · ${course?.name ?? ''}`,\n data: {\n learner: { name: learner?.name ?? '' },\n course: { name: course?.name ?? '', version: version?.number ?? '' },\n completion: {\n completedOn: dayOf(enrollment.completedAt, context.state.domain.timezone),\n minutes: Math.floor((enrollment.learnedSeconds ?? 0) / 60),\n score: enrollment.score ?? null,\n certificateNo: enrollment.certificateNo ?? ''\n }\n }\n }\n },\n async mayIssue(context, refId) {\n return await mayHaveCertificate(context, refId)\n },\n async mayRead(context, row) {\n return await mayHaveCertificate(context, row.refId)\n }\n })\n\n registerIssuedDocumentKind({\n kind: LEARNING_REGISTER_KIND,\n prefix: '이수대장',\n refType: 'Course',\n asOf: true,\n template: { content_template: REGISTER_TEMPLATE, page_size: 'A4' },\n async dataOf(context, refId, input) {\n const course = await getRepository(Course, context.state.tx).findOne({ where: { id: refId, domain: { id: context.state.domain.id } } as any })\n if (!course) throw new Refusal('learning-not-found', {}, 'the record was not found. reload and try again.')\n const timezone = context.state.domain.timezone\n const rows = (await completionRegister(course.id, context)).filter(row => !input.asOf || dayOf(row.enrollment.completedAt, timezone) <= input.asOf)\n return {\n subjectName: course.name,\n data: {\n course: { name: course.name },\n count: rows.length,\n rows: rows.map((row, index) => ({\n no: index + 1,\n name: row.user?.name ?? '',\n department: row.departmentName ?? '',\n version: row.versionNumber ?? '',\n completedOn: dayOf(row.enrollment.completedAt, timezone),\n minutes: Math.floor((row.enrollment.learnedSeconds ?? 0) / 60),\n score: row.enrollment.score ?? '',\n certificateNo: row.enrollment.certificateNo ?? ''\n }))\n }\n }\n },\n async mayIssue(context) {\n return await requestHolds(context, 'course', 'query')\n },\n async mayRead(context) {\n return await requestHolds(context, 'course', 'query')\n }\n })\n}\n"]}
@@ -0,0 +1,10 @@
1
+ import type { Enrollment } from './enrollment';
2
+ export interface LearningOutcomeHandler {
3
+ /** The enrolment was completed - its evidence is on it. Throwing takes the completion back. */
4
+ completed(enrollment: Enrollment, context: any): Promise<void>;
5
+ /** A completed enrolment was cancelled by a manager, with the reason. Throwing takes the cancellation back. */
6
+ cancelled?(enrollment: Enrollment, reason: string, context: any): Promise<void>;
7
+ }
8
+ /** Registers the handler of a source kind (`compliance.training-session`) - a second registration replaces the first. */
9
+ export declare function registerLearningOutcome(sourceKind: string, handler: LearningOutcomeHandler | null): void;
10
+ export declare function learningOutcomeOf(sourceKind: string): LearningOutcomeHandler | undefined;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerLearningOutcome = registerLearningOutcome;
4
+ exports.learningOutcomeOf = learningOutcomeOf;
5
+ const handlers = new Map();
6
+ /** Registers the handler of a source kind (`compliance.training-session`) - a second registration replaces the first. */
7
+ function registerLearningOutcome(sourceKind, handler) {
8
+ if (handler)
9
+ handlers.set(sourceKind, handler);
10
+ else
11
+ handlers.delete(sourceKind);
12
+ }
13
+ function learningOutcomeOf(sourceKind) {
14
+ return handlers.get(sourceKind);
15
+ }
16
+ //# sourceMappingURL=learning-outcome.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"learning-outcome.js","sourceRoot":"","sources":["../../../server/service/enrollment/learning-outcome.ts"],"names":[],"mappings":";;AAiBA,0DAGC;AAED,8CAEC;AAVD,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAkC,CAAA;AAE1D,yHAAyH;AACzH,SAAgB,uBAAuB,CAAC,UAAkB,EAAE,OAAsC;IAChG,IAAI,OAAO;QAAE,QAAQ,CAAC,GAAG,CAAC,UAAU,EAAE,OAAO,CAAC,CAAA;;QACzC,QAAQ,CAAC,MAAM,CAAC,UAAU,CAAC,CAAA;AAClC,CAAC;AAED,SAAgB,iBAAiB,CAAC,UAAkB;IAClD,OAAO,QAAQ,CAAC,GAAG,CAAC,UAAU,CAAC,CAAA;AACjC,CAAC","sourcesContent":["/*\n * Telling whoever asked for an enrolment that it was completed or cancelled (ADR-0194 decisions 7, 8) - the same shape\n * as accounting's `registerPaymentTarget`. The module that asks for enrolments registers one handler per source kind;\n * this module calls it in the completion's own transaction, so a handler that fails takes the completion back with it.\n */\nimport type { Enrollment } from './enrollment'\n\nexport interface LearningOutcomeHandler {\n /** The enrolment was completed - its evidence is on it. Throwing takes the completion back. */\n completed(enrollment: Enrollment, context: any): Promise<void>\n /** A completed enrolment was cancelled by a manager, with the reason. Throwing takes the cancellation back. */\n cancelled?(enrollment: Enrollment, reason: string, context: any): Promise<void>\n}\n\nconst handlers = new Map<string, LearningOutcomeHandler>()\n\n/** Registers the handler of a source kind (`compliance.training-session`) - a second registration replaces the first. */\nexport function registerLearningOutcome(sourceKind: string, handler: LearningOutcomeHandler | null): void {\n if (handler) handlers.set(sourceKind, handler)\n else handlers.delete(sourceKind)\n}\n\nexport function learningOutcomeOf(sourceKind: string): LearningOutcomeHandler | undefined {\n return handlers.get(sourceKind)\n}\n"]}
@@ -0,0 +1,119 @@
1
+ /** The longest a tick is counted for, in seconds - the screen sends one every 15 seconds looked at. */
2
+ export declare const TICK_SECONDS = 15;
3
+ /** How far ahead of what was watched a position still reads as watching - the video's own step between two updates. */
4
+ export declare const POSITION_SLACK_SECONDS = 2;
5
+ /** How long a PDF or a text is looked at to be done when its unit says nothing. */
6
+ export declare const DEFAULT_UNIT_SECONDS = 60;
7
+ export interface ProgressNow {
8
+ learnedSeconds: number;
9
+ watchedSeconds: number;
10
+ /** The furthest page of a PDF arrived at. */
11
+ pagesSeen?: number | null;
12
+ /** The furthest page of a PDF read through - looked at for its seconds. */
13
+ pagesRead?: number | null;
14
+ /** The seconds looked at on the furthest page arrived at, so far. */
15
+ pageSeconds?: number | null;
16
+ /** The page it was on until this tick. */
17
+ pageAt?: number | null;
18
+ lastTickAt?: Date | null;
19
+ }
20
+ export interface Tick {
21
+ /** What the screen says it looked at since its last tick - it may lower what is counted, never raise it. */
22
+ seconds: number;
23
+ /** Where it stands - a video's second, a PDF's page (from 1); 0 for a text. */
24
+ position: number;
25
+ }
26
+ export interface UnitShape {
27
+ kind: string;
28
+ durationSeconds?: number | null;
29
+ pageCount?: number | null;
30
+ minSeconds?: number | null;
31
+ }
32
+ export type TickVerdict = {
33
+ refusal: 'position-ahead';
34
+ allowed: number;
35
+ } | {
36
+ refusal: 'page-ahead';
37
+ allowed: number;
38
+ } | {
39
+ add: number;
40
+ watchedSeconds: number;
41
+ positionSeconds: number;
42
+ pagesSeen: number;
43
+ pagesRead: number;
44
+ pageSeconds: number;
45
+ pageAt: number;
46
+ };
47
+ /** The fewest seconds a PDF page is looked at to count as read - its unit's time shared by its pages, 3 at least. */
48
+ export declare function secondsPerPage(unit: UnitShape): number;
49
+ /**
50
+ * What one tick does (decision 6). The time added is the gap since the last tick, at most 15 seconds and at most what
51
+ * the screen says - a tick with no tick before it starts the clock and adds nothing.
52
+ *
53
+ * - A video's position may not be ahead of what was watched by more than the time that passed: a move ahead is refused,
54
+ * a move back is free.
55
+ * - A PDF is read page by page (2026-11-08, the user). The seconds since the last tick were spent on the page it was on;
56
+ * the furthest page arrived at is read once its own seconds reach `secondsPerPage`. A tick may turn only to a page
57
+ * read or the one after the furthest read - two turns at the same moment do not read the page between them; going
58
+ * back is free.
59
+ */
60
+ export declare function tickVerdict(progress: ProgressNow, tick: Tick, now: Date, unit: UnitShape): TickVerdict;
61
+ /** Whether a unit counts toward completion - a link does not: the server cannot know what was watched elsewhere. */
62
+ export declare const counts: (unit: UnitShape) => boolean;
63
+ /**
64
+ * Whether a unit is done (decision 3): a video watched to its end (its length known) **and** its running time counted
65
+ * by the server - a skip or a faster playback reaches neither; a PDF with every page read through - the last one too (its
66
+ * page count known) - and its time; a text looked at for its time. A link is never done - it does not count.
67
+ */
68
+ export declare function unitDone(unit: UnitShape, progress: {
69
+ learnedSeconds: number;
70
+ watchedSeconds: number;
71
+ pagesRead?: number | null;
72
+ }): boolean;
73
+ export interface CompletionFacts {
74
+ /** The units that count, and the ones of them done. */
75
+ units: number;
76
+ unitsDone: number;
77
+ learnedSeconds: number;
78
+ minSeconds: number;
79
+ /** The version has a quiz - questions and a pass score. */
80
+ quiz: boolean;
81
+ passed: boolean;
82
+ /** The version asks for an acknowledgement. */
83
+ acknowledgement: boolean;
84
+ acknowledged: boolean;
85
+ }
86
+ export type Missing = 'units' | 'time' | 'quiz' | 'acknowledgement';
87
+ /**
88
+ * What still stands between an enrolment and its completion (decision 8) - every unit done, the least time, the quiz
89
+ * passed when there is one, the acknowledgement confirmed when one is asked. Complete when nothing is missing.
90
+ */
91
+ export declare function missingOf(facts: CompletionFacts): Missing[];
92
+ export interface QuestionKey {
93
+ id: string;
94
+ kind: string;
95
+ choices: {
96
+ id: string;
97
+ correct: boolean;
98
+ }[];
99
+ }
100
+ /**
101
+ * A quiz's score, out of 100 (decision 4): each question is right when the choices picked are exactly the right ones -
102
+ * one for SINGLE and TRUE_FALSE, every right one and no other for MULTIPLE.
103
+ */
104
+ export declare function scoreOf(questions: QuestionKey[], answers: Record<string, string[]>): number;
105
+ /** The questions an attempt draws - `count` of them (all when empty), in a shuffled order, each with its choices shuffled. */
106
+ export declare function drawQuestions<Q extends {
107
+ id: string;
108
+ choices: {
109
+ id: string;
110
+ }[];
111
+ }>(questions: Q[], count: number | null | undefined, random?: () => number): {
112
+ questionId: string;
113
+ choiceIds: string[];
114
+ }[];
115
+ /** The day an enrolment reads as overdue: open, and its due day before today. */
116
+ export declare const overdue: (enrollment: {
117
+ status: string;
118
+ dueOn?: string | null;
119
+ }, today: string) => boolean;
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ /*
3
+ * What the server decides of learning by itself (ADR-0194 decisions 4, 6, 8) - data in, verdict out, no database: the
4
+ * time a tick adds, where a video may stand, when a unit is done, whether an enrolment is complete, and a quiz's score.
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.overdue = exports.counts = exports.DEFAULT_UNIT_SECONDS = exports.POSITION_SLACK_SECONDS = exports.TICK_SECONDS = void 0;
8
+ exports.secondsPerPage = secondsPerPage;
9
+ exports.tickVerdict = tickVerdict;
10
+ exports.unitDone = unitDone;
11
+ exports.missingOf = missingOf;
12
+ exports.scoreOf = scoreOf;
13
+ exports.drawQuestions = drawQuestions;
14
+ /** The longest a tick is counted for, in seconds - the screen sends one every 15 seconds looked at. */
15
+ exports.TICK_SECONDS = 15;
16
+ /** How far ahead of what was watched a position still reads as watching - the video's own step between two updates. */
17
+ exports.POSITION_SLACK_SECONDS = 2;
18
+ /** How long a PDF or a text is looked at to be done when its unit says nothing. */
19
+ exports.DEFAULT_UNIT_SECONDS = 60;
20
+ /** The fewest seconds a PDF page is looked at to count as read - its unit's time shared by its pages, 3 at least. */
21
+ function secondsPerPage(unit) {
22
+ return Math.max(3, Math.ceil((unit.minSeconds ?? exports.DEFAULT_UNIT_SECONDS) / Math.max(1, unit.pageCount ?? 1)));
23
+ }
24
+ /**
25
+ * What one tick does (decision 6). The time added is the gap since the last tick, at most 15 seconds and at most what
26
+ * the screen says - a tick with no tick before it starts the clock and adds nothing.
27
+ *
28
+ * - A video's position may not be ahead of what was watched by more than the time that passed: a move ahead is refused,
29
+ * a move back is free.
30
+ * - A PDF is read page by page (2026-11-08, the user). The seconds since the last tick were spent on the page it was on;
31
+ * the furthest page arrived at is read once its own seconds reach `secondsPerPage`. A tick may turn only to a page
32
+ * read or the one after the furthest read - two turns at the same moment do not read the page between them; going
33
+ * back is free.
34
+ */
35
+ function tickVerdict(progress, tick, now, unit) {
36
+ const gap = progress.lastTickAt ? Math.max(0, (now.getTime() - new Date(progress.lastTickAt).getTime()) / 1000) : 0;
37
+ const said = Math.max(0, Number(tick.seconds) || 0);
38
+ const add = progress.lastTickAt ? Math.floor(Math.min(exports.TICK_SECONDS, gap, said)) : 0;
39
+ const position = Math.max(0, Math.floor(Number(tick.position) || 0));
40
+ let seen = progress.pagesSeen ?? 0;
41
+ let read = progress.pagesRead ?? 0;
42
+ let onPage = progress.pageSeconds ?? 0;
43
+ const kept = { pagesSeen: seen, pagesRead: read, pageSeconds: onPage };
44
+ if (unit.kind === 'VIDEO') {
45
+ const allowed = progress.watchedSeconds + Math.min(exports.TICK_SECONDS, gap) + exports.POSITION_SLACK_SECONDS;
46
+ if (position > allowed)
47
+ return { refusal: 'position-ahead', allowed: Math.floor(progress.watchedSeconds) };
48
+ return { add, watchedSeconds: Math.max(progress.watchedSeconds, position), positionSeconds: position, ...kept, pageAt: 0 };
49
+ }
50
+ if (unit.kind === 'PDF') {
51
+ /* The time since the last tick goes to the page it was on - it reads the furthest page only when it was there. */
52
+ const was = progress.pageAt ?? 0;
53
+ if (was > 0 && was === seen) {
54
+ onPage += add;
55
+ if (onPage >= secondsPerPage(unit))
56
+ read = Math.max(read, seen);
57
+ }
58
+ const page = Math.max(1, position);
59
+ if (page > read + 1)
60
+ return { refusal: 'page-ahead', allowed: read };
61
+ if (page > seen) {
62
+ seen = page;
63
+ onPage = 0;
64
+ }
65
+ return { add, watchedSeconds: progress.watchedSeconds, positionSeconds: 0, pagesSeen: seen, pagesRead: read, pageSeconds: onPage, pageAt: page };
66
+ }
67
+ return { add, watchedSeconds: progress.watchedSeconds, positionSeconds: 0, ...kept, pageAt: 0 };
68
+ }
69
+ /** Whether a unit counts toward completion - a link does not: the server cannot know what was watched elsewhere. */
70
+ const counts = (unit) => unit.kind !== 'LINK';
71
+ exports.counts = counts;
72
+ /**
73
+ * Whether a unit is done (decision 3): a video watched to its end (its length known) **and** its running time counted
74
+ * by the server - a skip or a faster playback reaches neither; a PDF with every page read through - the last one too (its
75
+ * page count known) - and its time; a text looked at for its time. A link is never done - it does not count.
76
+ */
77
+ function unitDone(unit, progress) {
78
+ if (unit.kind === 'VIDEO')
79
+ return !!unit.durationSeconds && progress.watchedSeconds >= unit.durationSeconds - exports.POSITION_SLACK_SECONDS && progress.learnedSeconds >= unit.durationSeconds - exports.TICK_SECONDS;
80
+ if (unit.kind === 'PDF')
81
+ return !!unit.pageCount && (progress.pagesRead ?? 0) >= unit.pageCount && progress.learnedSeconds >= (unit.minSeconds ?? exports.DEFAULT_UNIT_SECONDS);
82
+ if (unit.kind === 'MARKDOWN')
83
+ return progress.learnedSeconds >= (unit.minSeconds ?? exports.DEFAULT_UNIT_SECONDS);
84
+ return false;
85
+ }
86
+ /**
87
+ * What still stands between an enrolment and its completion (decision 8) - every unit done, the least time, the quiz
88
+ * passed when there is one, the acknowledgement confirmed when one is asked. Complete when nothing is missing.
89
+ */
90
+ function missingOf(facts) {
91
+ const missing = [];
92
+ if (facts.unitsDone < facts.units)
93
+ missing.push('units');
94
+ if (facts.learnedSeconds < facts.minSeconds)
95
+ missing.push('time');
96
+ if (facts.quiz && !facts.passed)
97
+ missing.push('quiz');
98
+ if (facts.acknowledgement && !facts.acknowledged)
99
+ missing.push('acknowledgement');
100
+ return missing;
101
+ }
102
+ /**
103
+ * A quiz's score, out of 100 (decision 4): each question is right when the choices picked are exactly the right ones -
104
+ * one for SINGLE and TRUE_FALSE, every right one and no other for MULTIPLE.
105
+ */
106
+ function scoreOf(questions, answers) {
107
+ if (!questions.length)
108
+ return 0;
109
+ const right = questions.filter(question => {
110
+ const picked = new Set(answers?.[question.id] ?? []);
111
+ const correct = question.choices.filter(one => one.correct).map(one => one.id);
112
+ return picked.size === correct.length && correct.every(id => picked.has(id));
113
+ }).length;
114
+ return Math.round((right / questions.length) * 100);
115
+ }
116
+ /** The questions an attempt draws - `count` of them (all when empty), in a shuffled order, each with its choices shuffled. */
117
+ function drawQuestions(questions, count, random = Math.random) {
118
+ const shuffle = (items) => {
119
+ const out = [...items];
120
+ for (let i = out.length - 1; i > 0; i--) {
121
+ const j = Math.floor(random() * (i + 1));
122
+ [out[i], out[j]] = [out[j], out[i]];
123
+ }
124
+ return out;
125
+ };
126
+ const picked = shuffle(questions).slice(0, count && count > 0 ? count : questions.length);
127
+ return picked.map(question => ({ questionId: question.id, choiceIds: shuffle(question.choices.map(one => one.id)) }));
128
+ }
129
+ /** The day an enrolment reads as overdue: open, and its due day before today. */
130
+ const overdue = (enrollment, today) => (enrollment.status === 'ASSIGNED' || enrollment.status === 'IN_PROGRESS') && !!enrollment.dueOn && enrollment.dueOn < today;
131
+ exports.overdue = overdue;
132
+ //# sourceMappingURL=progress-rules.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"progress-rules.js","sourceRoot":"","sources":["../../../server/service/enrollment/progress-rules.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * What the server decides of learning by itself (ADR-0194 decisions 4, 6, 8) - data in, verdict out, no database: the\n * time a tick adds, where a video may stand, when a unit is done, whether an enrolment is complete, and a quiz's score.\n */\n\n/** The longest a tick is counted for, in seconds - the screen sends one every 15 seconds looked at. */\nexport const TICK_SECONDS = 15\n/** How far ahead of what was watched a position still reads as watching - the video's own step between two updates. */\nexport const POSITION_SLACK_SECONDS = 2\n/** How long a PDF or a text is looked at to be done when its unit says nothing. */\nexport const DEFAULT_UNIT_SECONDS = 60\n\nexport interface ProgressNow {\n learnedSeconds: number\n watchedSeconds: number\n /** The furthest page of a PDF arrived at. */\n pagesSeen?: number | null\n /** The furthest page of a PDF read through - looked at for its seconds. */\n pagesRead?: number | null\n /** The seconds looked at on the furthest page arrived at, so far. */\n pageSeconds?: number | null\n /** The page it was on until this tick. */\n pageAt?: number | null\n lastTickAt?: Date | null\n}\n\nexport interface Tick {\n /** What the screen says it looked at since its last tick - it may lower what is counted, never raise it. */\n seconds: number\n /** Where it stands - a video's second, a PDF's page (from 1); 0 for a text. */\n position: number\n}\n\nexport interface UnitShape {\n kind: string\n durationSeconds?: number | null\n pageCount?: number | null\n minSeconds?: number | null\n}\n\nexport type TickVerdict =\n | { refusal: 'position-ahead'; allowed: number }\n | { refusal: 'page-ahead'; allowed: number }\n | { add: number; watchedSeconds: number; positionSeconds: number; pagesSeen: number; pagesRead: number; pageSeconds: number; pageAt: number }\n\n/** The fewest seconds a PDF page is looked at to count as read - its unit's time shared by its pages, 3 at least. */\nexport function secondsPerPage(unit: UnitShape): number {\n return Math.max(3, Math.ceil((unit.minSeconds ?? DEFAULT_UNIT_SECONDS) / Math.max(1, unit.pageCount ?? 1)))\n}\n\n/**\n * What one tick does (decision 6). The time added is the gap since the last tick, at most 15 seconds and at most what\n * the screen says - a tick with no tick before it starts the clock and adds nothing.\n *\n * - A video's position may not be ahead of what was watched by more than the time that passed: a move ahead is refused,\n * a move back is free.\n * - A PDF is read page by page (2026-11-08, the user). The seconds since the last tick were spent on the page it was on;\n * the furthest page arrived at is read once its own seconds reach `secondsPerPage`. A tick may turn only to a page\n * read or the one after the furthest read - two turns at the same moment do not read the page between them; going\n * back is free.\n */\nexport function tickVerdict(progress: ProgressNow, tick: Tick, now: Date, unit: UnitShape): TickVerdict {\n const gap = progress.lastTickAt ? Math.max(0, (now.getTime() - new Date(progress.lastTickAt).getTime()) / 1000) : 0\n const said = Math.max(0, Number(tick.seconds) || 0)\n const add = progress.lastTickAt ? Math.floor(Math.min(TICK_SECONDS, gap, said)) : 0\n const position = Math.max(0, Math.floor(Number(tick.position) || 0))\n let seen = progress.pagesSeen ?? 0\n let read = progress.pagesRead ?? 0\n let onPage = progress.pageSeconds ?? 0\n const kept = { pagesSeen: seen, pagesRead: read, pageSeconds: onPage }\n if (unit.kind === 'VIDEO') {\n const allowed = progress.watchedSeconds + Math.min(TICK_SECONDS, gap) + POSITION_SLACK_SECONDS\n if (position > allowed) return { refusal: 'position-ahead', allowed: Math.floor(progress.watchedSeconds) }\n return { add, watchedSeconds: Math.max(progress.watchedSeconds, position), positionSeconds: position, ...kept, pageAt: 0 }\n }\n if (unit.kind === 'PDF') {\n /* The time since the last tick goes to the page it was on - it reads the furthest page only when it was there. */\n const was = progress.pageAt ?? 0\n if (was > 0 && was === seen) {\n onPage += add\n if (onPage >= secondsPerPage(unit)) read = Math.max(read, seen)\n }\n const page = Math.max(1, position)\n if (page > read + 1) return { refusal: 'page-ahead', allowed: read }\n if (page > seen) {\n seen = page\n onPage = 0\n }\n return { add, watchedSeconds: progress.watchedSeconds, positionSeconds: 0, pagesSeen: seen, pagesRead: read, pageSeconds: onPage, pageAt: page }\n }\n return { add, watchedSeconds: progress.watchedSeconds, positionSeconds: 0, ...kept, pageAt: 0 }\n}\n\n/** Whether a unit counts toward completion - a link does not: the server cannot know what was watched elsewhere. */\nexport const counts = (unit: UnitShape) => unit.kind !== 'LINK'\n\n/**\n * Whether a unit is done (decision 3): a video watched to its end (its length known) **and** its running time counted\n * by the server - a skip or a faster playback reaches neither; a PDF with every page read through - the last one too (its\n * page count known) - and its time; a text looked at for its time. A link is never done - it does not count.\n */\nexport function unitDone(unit: UnitShape, progress: { learnedSeconds: number; watchedSeconds: number; pagesRead?: number | null }): boolean {\n if (unit.kind === 'VIDEO')\n return !!unit.durationSeconds && progress.watchedSeconds >= unit.durationSeconds - POSITION_SLACK_SECONDS && progress.learnedSeconds >= unit.durationSeconds - TICK_SECONDS\n if (unit.kind === 'PDF') return !!unit.pageCount && (progress.pagesRead ?? 0) >= unit.pageCount && progress.learnedSeconds >= (unit.minSeconds ?? DEFAULT_UNIT_SECONDS)\n if (unit.kind === 'MARKDOWN') return progress.learnedSeconds >= (unit.minSeconds ?? DEFAULT_UNIT_SECONDS)\n return false\n}\n\nexport interface CompletionFacts {\n /** The units that count, and the ones of them done. */\n units: number\n unitsDone: number\n learnedSeconds: number\n minSeconds: number\n /** The version has a quiz - questions and a pass score. */\n quiz: boolean\n passed: boolean\n /** The version asks for an acknowledgement. */\n acknowledgement: boolean\n acknowledged: boolean\n}\n\nexport type Missing = 'units' | 'time' | 'quiz' | 'acknowledgement'\n\n/**\n * What still stands between an enrolment and its completion (decision 8) - every unit done, the least time, the quiz\n * passed when there is one, the acknowledgement confirmed when one is asked. Complete when nothing is missing.\n */\nexport function missingOf(facts: CompletionFacts): Missing[] {\n const missing: Missing[] = []\n if (facts.unitsDone < facts.units) missing.push('units')\n if (facts.learnedSeconds < facts.minSeconds) missing.push('time')\n if (facts.quiz && !facts.passed) missing.push('quiz')\n if (facts.acknowledgement && !facts.acknowledged) missing.push('acknowledgement')\n return missing\n}\n\nexport interface QuestionKey {\n id: string\n kind: string\n choices: { id: string; correct: boolean }[]\n}\n\n/**\n * A quiz's score, out of 100 (decision 4): each question is right when the choices picked are exactly the right ones -\n * one for SINGLE and TRUE_FALSE, every right one and no other for MULTIPLE.\n */\nexport function scoreOf(questions: QuestionKey[], answers: Record<string, string[]>): number {\n if (!questions.length) return 0\n const right = questions.filter(question => {\n const picked = new Set(answers?.[question.id] ?? [])\n const correct = question.choices.filter(one => one.correct).map(one => one.id)\n return picked.size === correct.length && correct.every(id => picked.has(id))\n }).length\n return Math.round((right / questions.length) * 100)\n}\n\n/** The questions an attempt draws - `count` of them (all when empty), in a shuffled order, each with its choices shuffled. */\nexport function drawQuestions<Q extends { id: string; choices: { id: string }[] }>(questions: Q[], count: number | null | undefined, random: () => number = Math.random) {\n const shuffle = <T>(items: T[]): T[] => {\n const out = [...items]\n for (let i = out.length - 1; i > 0; i--) {\n const j = Math.floor(random() * (i + 1))\n ;[out[i], out[j]] = [out[j], out[i]]\n }\n return out\n }\n const picked = shuffle(questions).slice(0, count && count > 0 ? count : questions.length)\n return picked.map(question => ({ questionId: question.id, choiceIds: shuffle(question.choices.map(one => one.id)) }))\n}\n\n/** The day an enrolment reads as overdue: open, and its due day before today. */\nexport const overdue = (enrollment: { status: string; dueOn?: string | null }, today: string) =>\n (enrollment.status === 'ASSIGNED' || enrollment.status === 'IN_PROGRESS') && !!enrollment.dueOn && enrollment.dueOn < today\n"]}
@@ -0,0 +1,34 @@
1
+ import { User } from '@things-factory/auth-base';
2
+ import { Domain } from '@things-factory/shell';
3
+ import { Enrollment } from './enrollment';
4
+ /** One question as an attempt drew it - its choices in the order shown. */
5
+ export interface DrawnQuestion {
6
+ questionId: string;
7
+ choiceIds: string[];
8
+ }
9
+ /**
10
+ * 응시 (ADR-0194 decision 4) - one attempt at a version's quiz: the questions it drew and their choices in shuffled
11
+ * order, and once sent, the answers and the score the server gave. The drawn questions are kept so the answers are
12
+ * scored against what was shown.
13
+ */
14
+ export declare class QuizAttempt {
15
+ readonly id: string;
16
+ domain?: Domain;
17
+ domainId?: string;
18
+ enrollment?: Enrollment;
19
+ enrollmentId?: string;
20
+ /** The questions drawn, in order, with their choices in the order shown - not a field. */
21
+ drawn: DrawnQuestion[];
22
+ /** The choices picked for each question - not a field. */
23
+ answers?: Record<string, string[]>;
24
+ submittedAt?: Date;
25
+ score?: number;
26
+ passed?: boolean;
27
+ createdAt?: Date;
28
+ updatedAt?: Date;
29
+ deletedAt?: Date;
30
+ creator?: User;
31
+ creatorId?: string;
32
+ updater?: User;
33
+ updaterId?: string;
34
+ }