@headless-lms/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/assets/events.d.ts +81 -0
- package/dist/assets/events.js +24 -0
- package/dist/assets/events.js.map +1 -0
- package/dist/assets/index.d.ts +7 -0
- package/dist/assets/index.js +3 -0
- package/dist/assets/model.d.ts +4 -0
- package/dist/assets/model.js +1 -0
- package/dist/assets/ports.d.ts +34 -0
- package/dist/assets/ports.js +1 -0
- package/dist/assets/service.d.ts +29 -0
- package/dist/assets/service.js +130 -0
- package/dist/assets/service.js.map +1 -0
- package/dist/automations/actions.d.ts +24 -0
- package/dist/automations/actions.js +59 -0
- package/dist/automations/actions.js.map +1 -0
- package/dist/automations/catalog.d.ts +12 -0
- package/dist/automations/catalog.js +224 -0
- package/dist/automations/catalog.js.map +1 -0
- package/dist/automations/events.d.ts +264 -0
- package/dist/automations/events.js +54 -0
- package/dist/automations/events.js.map +1 -0
- package/dist/automations/index.d.ts +9 -0
- package/dist/automations/index.js +4 -0
- package/dist/automations/model.d.ts +12 -0
- package/dist/automations/model.js +14 -0
- package/dist/automations/model.js.map +1 -0
- package/dist/automations/ports.d.ts +56 -0
- package/dist/automations/ports.js +1 -0
- package/dist/automations/service.d.ts +53 -0
- package/dist/automations/service.js +225 -0
- package/dist/automations/service.js.map +1 -0
- package/dist/automations/types.d.ts +3 -0
- package/dist/automations/types.js +1 -0
- package/dist/content/events.d.ts +410 -0
- package/dist/content/events.js +105 -0
- package/dist/content/events.js.map +1 -0
- package/dist/content/index.d.ts +8 -0
- package/dist/content/index.js +3 -0
- package/dist/content/model.d.ts +3 -0
- package/dist/content/model.js +1 -0
- package/dist/content/ports.d.ts +85 -0
- package/dist/content/ports.js +1 -0
- package/dist/content/service.d.ts +54 -0
- package/dist/content/service.js +479 -0
- package/dist/content/service.js.map +1 -0
- package/dist/content/types.d.ts +4 -0
- package/dist/content/types.js +1 -0
- package/dist/discussion/events.d.ts +101 -0
- package/dist/discussion/events.js +29 -0
- package/dist/discussion/events.js.map +1 -0
- package/dist/discussion/index.d.ts +8 -0
- package/dist/discussion/index.js +3 -0
- package/dist/discussion/model.d.ts +5 -0
- package/dist/discussion/model.js +1 -0
- package/dist/discussion/ports.d.ts +112 -0
- package/dist/discussion/ports.js +1 -0
- package/dist/discussion/service.d.ts +67 -0
- package/dist/discussion/service.js +343 -0
- package/dist/discussion/service.js.map +1 -0
- package/dist/discussion/types.d.ts +4 -0
- package/dist/discussion/types.js +1 -0
- package/dist/entitlements/events.d.ts +89 -0
- package/dist/entitlements/events.js +29 -0
- package/dist/entitlements/events.js.map +1 -0
- package/dist/entitlements/index.d.ts +7 -0
- package/dist/entitlements/index.js +3 -0
- package/dist/entitlements/model.d.ts +4 -0
- package/dist/entitlements/model.js +1 -0
- package/dist/entitlements/ports.d.ts +34 -0
- package/dist/entitlements/ports.js +1 -0
- package/dist/entitlements/service.d.ts +27 -0
- package/dist/entitlements/service.js +60 -0
- package/dist/entitlements/service.js.map +1 -0
- package/dist/identity/events.d.ts +63 -0
- package/dist/identity/events.js +24 -0
- package/dist/identity/events.js.map +1 -0
- package/dist/identity/index.d.ts +8 -0
- package/dist/identity/index.js +3 -0
- package/dist/identity/model.d.ts +3 -0
- package/dist/identity/model.js +1 -0
- package/dist/identity/ports.d.ts +58 -0
- package/dist/identity/ports.js +1 -0
- package/dist/identity/service.d.ts +38 -0
- package/dist/identity/service.js +79 -0
- package/dist/identity/service.js.map +1 -0
- package/dist/identity/types.d.ts +4 -0
- package/dist/identity/types.js +1 -0
- package/dist/integrations/events.d.ts +70 -0
- package/dist/integrations/events.js +24 -0
- package/dist/integrations/events.js.map +1 -0
- package/dist/integrations/index.d.ts +9 -0
- package/dist/integrations/index.js +6 -0
- package/dist/integrations/model.d.ts +28 -0
- package/dist/integrations/model.js +45 -0
- package/dist/integrations/model.js.map +1 -0
- package/dist/integrations/ports.d.ts +62 -0
- package/dist/integrations/ports.js +1 -0
- package/dist/integrations/registry.d.ts +7 -0
- package/dist/integrations/registry.js +16 -0
- package/dist/integrations/registry.js.map +1 -0
- package/dist/integrations/service.d.ts +47 -0
- package/dist/integrations/service.js +184 -0
- package/dist/integrations/service.js.map +1 -0
- package/dist/integrations/zod.d.ts +29 -0
- package/dist/integrations/zod.js +40 -0
- package/dist/integrations/zod.js.map +1 -0
- package/dist/organizations/events.d.ts +197 -0
- package/dist/organizations/events.js +59 -0
- package/dist/organizations/events.js.map +1 -0
- package/dist/organizations/index.d.ts +11 -0
- package/dist/organizations/index.js +5 -0
- package/dist/organizations/members.d.ts +32 -0
- package/dist/organizations/members.js +12 -0
- package/dist/organizations/members.js.map +1 -0
- package/dist/organizations/model.d.ts +3 -0
- package/dist/organizations/model.js +1 -0
- package/dist/organizations/ports.d.ts +127 -0
- package/dist/organizations/ports.js +1 -0
- package/dist/organizations/roles.d.ts +23 -0
- package/dist/organizations/roles.js +70 -0
- package/dist/organizations/roles.js.map +1 -0
- package/dist/organizations/service.d.ts +58 -0
- package/dist/organizations/service.js +359 -0
- package/dist/organizations/service.js.map +1 -0
- package/dist/organizations/types.d.ts +39 -0
- package/dist/organizations/types.js +1 -0
- package/dist/progress/events.d.ts +55 -0
- package/dist/progress/events.js +19 -0
- package/dist/progress/events.js.map +1 -0
- package/dist/progress/index.d.ts +8 -0
- package/dist/progress/index.js +3 -0
- package/dist/progress/model.d.ts +3 -0
- package/dist/progress/model.js +1 -0
- package/dist/progress/ports.d.ts +37 -0
- package/dist/progress/ports.js +1 -0
- package/dist/progress/service.d.ts +33 -0
- package/dist/progress/service.js +181 -0
- package/dist/progress/service.js.map +1 -0
- package/dist/progress/types.d.ts +4 -0
- package/dist/progress/types.js +1 -0
- package/dist/reporting/dashboard/index.d.ts +4 -0
- package/dist/reporting/dashboard/index.js +2 -0
- package/dist/reporting/dashboard/model.d.ts +14 -0
- package/dist/reporting/dashboard/model.js +1 -0
- package/dist/reporting/dashboard/ports.d.ts +13 -0
- package/dist/reporting/dashboard/ports.js +1 -0
- package/dist/reporting/dashboard/service.d.ts +19 -0
- package/dist/reporting/dashboard/service.js +20 -0
- package/dist/reporting/dashboard/service.js.map +1 -0
- package/dist/reporting/learn/index.d.ts +5 -0
- package/dist/reporting/learn/index.js +2 -0
- package/dist/reporting/learn/model.d.ts +23 -0
- package/dist/reporting/learn/model.js +1 -0
- package/dist/reporting/learn/ports.d.ts +42 -0
- package/dist/reporting/learn/ports.js +1 -0
- package/dist/reporting/learn/service.d.ts +48 -0
- package/dist/reporting/learn/service.js +111 -0
- package/dist/reporting/learn/service.js.map +1 -0
- package/dist/reporting/students/index.d.ts +4 -0
- package/dist/reporting/students/index.js +2 -0
- package/dist/reporting/students/model.d.ts +31 -0
- package/dist/reporting/students/model.js +1 -0
- package/dist/reporting/students/ports.d.ts +13 -0
- package/dist/reporting/students/ports.js +1 -0
- package/dist/reporting/students/service.d.ts +19 -0
- package/dist/reporting/students/service.js +20 -0
- package/dist/reporting/students/service.js.map +1 -0
- package/dist/shared/errors.d.ts +22 -0
- package/dist/shared/errors.js +34 -0
- package/dist/shared/errors.js.map +1 -0
- package/dist/shared/events.d.ts +31 -0
- package/dist/shared/events.js +75 -0
- package/dist/shared/events.js.map +1 -0
- package/dist/shared/id.d.ts +44 -0
- package/dist/shared/id.js +53 -0
- package/dist/shared/id.js.map +1 -0
- package/dist/shared/invite-token.d.ts +9 -0
- package/dist/shared/invite-token.js +16 -0
- package/dist/shared/invite-token.js.map +1 -0
- package/dist/shared/logger.d.ts +19 -0
- package/dist/shared/logger.js +44 -0
- package/dist/shared/logger.js.map +1 -0
- package/dist/shared/mailer.d.ts +23 -0
- package/dist/shared/mailer.js +27 -0
- package/dist/shared/mailer.js.map +1 -0
- package/dist/shared/ports.d.ts +62 -0
- package/dist/shared/ports.js +2 -0
- package/dist/shared/settings.d.ts +25 -0
- package/dist/shared/settings.js +21 -0
- package/dist/shared/settings.js.map +1 -0
- package/dist/types/assets.d.ts +2 -0
- package/dist/types/assets.js +1 -0
- package/dist/types/automations.d.ts +23 -0
- package/dist/types/automations.js +1 -0
- package/dist/types/content.d.ts +2 -0
- package/dist/types/content.js +1 -0
- package/dist/types/discussion.d.ts +36 -0
- package/dist/types/discussion.js +1 -0
- package/dist/types/email-templates.d.ts +57 -0
- package/dist/types/email-templates.js +1 -0
- package/dist/types/entitlements.d.ts +3 -0
- package/dist/types/entitlements.js +1 -0
- package/dist/types/events.d.ts +25 -0
- package/dist/types/events.js +1 -0
- package/dist/types/identity.d.ts +37 -0
- package/dist/types/identity.js +1 -0
- package/dist/types/index.d.ts +23 -0
- package/dist/types/index.js +1 -0
- package/dist/types/integrations.d.ts +45 -0
- package/dist/types/integrations.js +1 -0
- package/dist/types/organizations.d.ts +15 -0
- package/dist/types/organizations.js +1 -0
- package/dist/types/ports.d.ts +57 -0
- package/dist/types/ports.js +1 -0
- package/dist/types/progress.d.ts +6 -0
- package/dist/types/progress.js +1 -0
- package/dist/types/schemas/assets.d.ts +109 -0
- package/dist/types/schemas/assets.js +49 -0
- package/dist/types/schemas/assets.js.map +1 -0
- package/dist/types/schemas/automations.d.ts +118 -0
- package/dist/types/schemas/automations.js +73 -0
- package/dist/types/schemas/automations.js.map +1 -0
- package/dist/types/schemas/content.d.ts +216 -0
- package/dist/types/schemas/content.js +138 -0
- package/dist/types/schemas/content.js.map +1 -0
- package/dist/types/schemas/credentials.d.ts +15 -0
- package/dist/types/schemas/credentials.js +15 -0
- package/dist/types/schemas/credentials.js.map +1 -0
- package/dist/types/schemas/discussion.d.ts +192 -0
- package/dist/types/schemas/discussion.js +100 -0
- package/dist/types/schemas/discussion.js.map +1 -0
- package/dist/types/schemas/entitlements.d.ts +50 -0
- package/dist/types/schemas/entitlements.js +38 -0
- package/dist/types/schemas/entitlements.js.map +1 -0
- package/dist/types/schemas/identity.d.ts +37 -0
- package/dist/types/schemas/identity.js +34 -0
- package/dist/types/schemas/identity.js.map +1 -0
- package/dist/types/schemas/index.d.ts +14 -0
- package/dist/types/schemas/index.js +14 -0
- package/dist/types/schemas/integrations.d.ts +36 -0
- package/dist/types/schemas/integrations.js +30 -0
- package/dist/types/schemas/integrations.js.map +1 -0
- package/dist/types/schemas/organizations.d.ts +135 -0
- package/dist/types/schemas/organizations.js +90 -0
- package/dist/types/schemas/organizations.js.map +1 -0
- package/dist/types/schemas/outbox.d.ts +19 -0
- package/dist/types/schemas/outbox.js +18 -0
- package/dist/types/schemas/outbox.js.map +1 -0
- package/dist/types/schemas/progress.d.ts +53 -0
- package/dist/types/schemas/progress.js +37 -0
- package/dist/types/schemas/progress.js.map +1 -0
- package/dist/types/schemas/settings.d.ts +16 -0
- package/dist/types/schemas/settings.js +15 -0
- package/dist/types/schemas/settings.js.map +1 -0
- package/dist/types/schemas/shared.d.ts +13 -0
- package/dist/types/schemas/shared.js +28 -0
- package/dist/types/schemas/shared.js.map +1 -0
- package/dist/types/shared.d.ts +25 -0
- package/dist/types/shared.js +1 -0
- package/package.json +61 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { genId } from "../shared/id.js";
|
|
2
|
+
import { noopLogger } from "../shared/logger.js";
|
|
3
|
+
import { NotFoundError } from "../shared/errors.js";
|
|
4
|
+
import { progressEvents } from "./events.js";
|
|
5
|
+
//#region src/progress/service.ts
|
|
6
|
+
/** Mirrors reporting/learn: `settings.published === false` is the only draft signal. */
|
|
7
|
+
function isActivityPublished(settings) {
|
|
8
|
+
return settings?.published !== false;
|
|
9
|
+
}
|
|
10
|
+
/** Fold a report batch into the record's per-subject state map. Reserved keys
|
|
11
|
+
* (`asset` = subject, `completed` = claim) are stripped; the rest is the asset
|
|
12
|
+
* type's own vocabulary, stored opaquely — latest report per subject wins. */
|
|
13
|
+
function mergeReports(position, items) {
|
|
14
|
+
const map = { ...position ?? {} };
|
|
15
|
+
let changed = false;
|
|
16
|
+
let claimed = false;
|
|
17
|
+
for (const item of items) {
|
|
18
|
+
const { asset, completed, ...payload } = item;
|
|
19
|
+
if (completed === true) claimed = true;
|
|
20
|
+
if (Object.keys(payload).length > 0) {
|
|
21
|
+
map[asset ?? "self"] = payload;
|
|
22
|
+
changed = true;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
map,
|
|
27
|
+
changed,
|
|
28
|
+
claimed
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** The completion-rule seam. Only `manual` ships: no authored rule → the
|
|
32
|
+
* learner's claim decides. Authored rules are never satisfied until their
|
|
33
|
+
* evaluators exist. Future rules evaluate against the accumulated state map. */
|
|
34
|
+
function completionSatisfied(settings, claimed) {
|
|
35
|
+
const rule = settings?.completion?.rule;
|
|
36
|
+
if (!rule || rule === "manual") return claimed;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
var ProgressServiceImpl = class {
|
|
40
|
+
repo;
|
|
41
|
+
content;
|
|
42
|
+
uow;
|
|
43
|
+
logger;
|
|
44
|
+
constructor(params) {
|
|
45
|
+
this.repo = params.repo;
|
|
46
|
+
this.content = params.content;
|
|
47
|
+
this.uow = params.uow;
|
|
48
|
+
this.logger = params.logger ?? noopLogger;
|
|
49
|
+
}
|
|
50
|
+
async report(orgId, input) {
|
|
51
|
+
const activity = await this.content.getActivity(orgId, input.activityId);
|
|
52
|
+
if (!activity || !isActivityPublished(activity.settings)) throw new NotFoundError("Activity", input.activityId);
|
|
53
|
+
const courseId = activity.courseId;
|
|
54
|
+
const modules = await this.content.listCourseModules(orgId, courseId);
|
|
55
|
+
const courseActivities = await this.content.listCourseActivities(orgId, courseId);
|
|
56
|
+
return this.uow.run(async (scope) => {
|
|
57
|
+
const lockIds = [
|
|
58
|
+
...courseActivities.filter((a) => isActivityPublished(a.settings)).map((a) => a.id),
|
|
59
|
+
...modules.map((m) => m.id),
|
|
60
|
+
courseId
|
|
61
|
+
];
|
|
62
|
+
await scope.progress.findByTargets(orgId, input.orgUserId, lockIds, { forUpdate: true });
|
|
63
|
+
const events = [];
|
|
64
|
+
let record = await this.ensureActivityRecord(orgId, input, scope, events);
|
|
65
|
+
const state = mergeReports(record.position, input.reports);
|
|
66
|
+
if (state.changed && !record.completedAt) record = await scope.progress.update(orgId, record.id, { position: state.map }) ?? record;
|
|
67
|
+
if (!record.completedAt && completionSatisfied(activity.settings, state.claimed)) {
|
|
68
|
+
record = await scope.progress.update(orgId, record.id, { completedAt: /* @__PURE__ */ new Date() }) ?? record;
|
|
69
|
+
events.push(progressEvents.progressCompleted.make({
|
|
70
|
+
orgId,
|
|
71
|
+
data: record
|
|
72
|
+
}));
|
|
73
|
+
await this.completeContainers(orgId, input, courseId, modules, courseActivities, scope, events);
|
|
74
|
+
this.logger.info("progress completed", {
|
|
75
|
+
orgId,
|
|
76
|
+
recordId: record.id
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (events.length > 0) await scope.outbox.append(events);
|
|
80
|
+
return record;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
async ensureActivityRecord(orgId, input, scope, events) {
|
|
84
|
+
const target = {
|
|
85
|
+
orgUserId: input.orgUserId,
|
|
86
|
+
targetType: "activity",
|
|
87
|
+
targetId: input.activityId
|
|
88
|
+
};
|
|
89
|
+
const existing = await scope.progress.findByTarget(orgId, target);
|
|
90
|
+
if (existing) return existing;
|
|
91
|
+
const now = /* @__PURE__ */ new Date();
|
|
92
|
+
const record = await scope.progress.insert(orgId, {
|
|
93
|
+
id: genId("progress"),
|
|
94
|
+
orgId,
|
|
95
|
+
orgUserId: input.orgUserId,
|
|
96
|
+
targetType: "activity",
|
|
97
|
+
targetId: input.activityId,
|
|
98
|
+
startedAt: now,
|
|
99
|
+
position: null,
|
|
100
|
+
completedAt: null,
|
|
101
|
+
updatedAt: now
|
|
102
|
+
});
|
|
103
|
+
if (!record) {
|
|
104
|
+
const winner = await scope.progress.findByTarget(orgId, target);
|
|
105
|
+
if (!winner) throw new Error("progress record vanished after insert conflict");
|
|
106
|
+
return winner;
|
|
107
|
+
}
|
|
108
|
+
events.push(progressEvents.progressStarted.make({
|
|
109
|
+
orgId,
|
|
110
|
+
data: record
|
|
111
|
+
}));
|
|
112
|
+
this.logger.info("progress started", {
|
|
113
|
+
orgId,
|
|
114
|
+
recordId: record.id
|
|
115
|
+
});
|
|
116
|
+
return record;
|
|
117
|
+
}
|
|
118
|
+
/** After an activity completes: newly-complete containers get their records
|
|
119
|
+
* (created complete) and a progress.completed event — same transaction. */
|
|
120
|
+
async completeContainers(orgId, input, courseId, modules, courseActivities, scope, events) {
|
|
121
|
+
const published = courseActivities.filter((a) => isActivityPublished(a.settings));
|
|
122
|
+
const byModule = modules.map((m) => ({
|
|
123
|
+
id: m.id,
|
|
124
|
+
activityIds: published.filter((a) => a.moduleId === m.id).map((a) => a.id)
|
|
125
|
+
}));
|
|
126
|
+
const allIds = byModule.flatMap((m) => m.activityIds);
|
|
127
|
+
const records = await scope.progress.findByTargets(orgId, input.orgUserId, allIds);
|
|
128
|
+
const done = new Set(records.filter((r) => r.targetType === "activity" && r.completedAt).map((r) => r.targetId));
|
|
129
|
+
const containing = byModule.find((m) => m.activityIds.includes(input.activityId));
|
|
130
|
+
if (containing && containing.activityIds.every((id) => done.has(id))) await this.ensureContainerComplete(orgId, input, "module", containing.id, scope, events);
|
|
131
|
+
if (allIds.length > 0 && allIds.every((id) => done.has(id))) await this.ensureContainerComplete(orgId, input, "course", courseId, scope, events);
|
|
132
|
+
}
|
|
133
|
+
async ensureContainerComplete(orgId, input, targetType, targetId, scope, events) {
|
|
134
|
+
const target = {
|
|
135
|
+
orgUserId: input.orgUserId,
|
|
136
|
+
targetType,
|
|
137
|
+
targetId
|
|
138
|
+
};
|
|
139
|
+
let existing = await scope.progress.findByTarget(orgId, target);
|
|
140
|
+
if (existing?.completedAt) return;
|
|
141
|
+
if (!existing) {
|
|
142
|
+
const now = /* @__PURE__ */ new Date();
|
|
143
|
+
const inserted = await scope.progress.insert(orgId, {
|
|
144
|
+
id: genId("progress"),
|
|
145
|
+
orgId,
|
|
146
|
+
orgUserId: input.orgUserId,
|
|
147
|
+
targetType,
|
|
148
|
+
targetId,
|
|
149
|
+
startedAt: now,
|
|
150
|
+
position: null,
|
|
151
|
+
completedAt: now,
|
|
152
|
+
updatedAt: now
|
|
153
|
+
});
|
|
154
|
+
if (inserted) {
|
|
155
|
+
events.push(progressEvents.progressCompleted.make({
|
|
156
|
+
orgId,
|
|
157
|
+
data: inserted
|
|
158
|
+
}));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
existing = await scope.progress.findByTarget(orgId, target);
|
|
162
|
+
if (existing?.completedAt) return;
|
|
163
|
+
if (!existing) throw new Error("progress container record vanished after insert conflict");
|
|
164
|
+
}
|
|
165
|
+
const record = await scope.progress.update(orgId, existing.id, { completedAt: /* @__PURE__ */ new Date() }) ?? existing;
|
|
166
|
+
events.push(progressEvents.progressCompleted.make({
|
|
167
|
+
orgId,
|
|
168
|
+
data: record
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
get(orgId, target) {
|
|
172
|
+
return this.repo.findByTarget(orgId, target);
|
|
173
|
+
}
|
|
174
|
+
listByTargets(orgId, orgUserId, targetIds) {
|
|
175
|
+
return this.repo.findByTargets(orgId, orgUserId, targetIds);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
//#endregion
|
|
179
|
+
export { ProgressServiceImpl };
|
|
180
|
+
|
|
181
|
+
//# sourceMappingURL=service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.js","names":[],"sources":["../../src/progress/service.ts"],"sourcesContent":["// progress context — service implementation (inbound port).\n// The frontend only reports usage; this service makes every completion\n// decision. One report batch runs in one UoW transaction: ensure the record,\n// merge item payloads into the per-subject state map, evaluate the activity's\n// completion rule, then module/course against current published structure —\n// appending events with the writes. The container is resolved from the\n// hierarchy (activity → module → course), never supplied by the caller.\n// Percentage/resume are derived by readers, never stored here.\nimport { genId } from '../shared/id.js';\nimport { NotFoundError } from '../shared/errors.js';\nimport type { ProgressRecord } from './model.js';\nimport type {\n ProgressRepository,\n ProgressService,\n ProgressUnitOfWork,\n ProgressWriteScope,\n} from './ports.js';\nimport type { ProgressReportItem, ProgressTarget, ReportProgressInput } from './types.js';\nimport { progressEvents, type NewProgressEvent } from './events.js';\nimport type { Activity, ContentService, Module } from '../content/index.js';\nimport type { JsonValue } from '../types/index.js';\nimport type { Logger } from '../shared/ports.js';\nimport { noopLogger } from '../shared/logger.js';\n\n/** Mirrors reporting/learn: `settings.published === false` is the only draft signal. */\nfunction isActivityPublished(settings: unknown): boolean {\n return (settings as { published?: boolean } | null)?.published !== false;\n}\n\n/** Fold a report batch into the record's per-subject state map. Reserved keys\n * (`asset` = subject, `completed` = claim) are stripped; the rest is the asset\n * type's own vocabulary, stored opaquely — latest report per subject wins. */\nfunction mergeReports(\n position: unknown,\n items: ProgressReportItem[],\n): { map: Record<string, JsonValue>; changed: boolean; claimed: boolean } {\n const map = { ...((position as Record<string, JsonValue> | null) ?? {}) };\n let changed = false;\n let claimed = false;\n for (const item of items) {\n const { asset, completed, ...payload } = item;\n if (completed === true) {\n claimed = true;\n }\n if (Object.keys(payload).length > 0) {\n map[asset ?? 'self'] = payload;\n changed = true;\n }\n }\n return { map, changed, claimed };\n}\n\n/** The completion-rule seam. Only `manual` ships: no authored rule → the\n * learner's claim decides. Authored rules are never satisfied until their\n * evaluators exist. Future rules evaluate against the accumulated state map. */\nfunction completionSatisfied(settings: unknown, claimed: boolean): boolean {\n const rule = (settings as { completion?: { rule?: string } } | null)?.completion?.rule;\n if (!rule || rule === 'manual') {\n return claimed;\n }\n return false;\n}\n\nexport type ProgressServiceParams = {\n repo: ProgressRepository;\n content: ContentService;\n uow: ProgressUnitOfWork;\n logger?: Logger;\n};\n\nexport class ProgressServiceImpl implements ProgressService {\n private readonly repo: ProgressRepository;\n private readonly content: ContentService;\n private readonly uow: ProgressUnitOfWork;\n private readonly logger: Logger;\n\n constructor(params: ProgressServiceParams) {\n this.repo = params.repo;\n this.content = params.content;\n this.uow = params.uow;\n this.logger = params.logger ?? noopLogger;\n }\n\n async report(orgId: string, input: ReportProgressInput): Promise<ProgressRecord> {\n const activity = await this.content.getActivity(orgId, input.activityId);\n if (!activity || !isActivityPublished(activity.settings)) {\n throw new NotFoundError('Activity', input.activityId);\n }\n const courseId = activity.courseId;\n const modules = await this.content.listCourseModules(orgId, courseId);\n const courseActivities = await this.content.listCourseActivities(orgId, courseId);\n return this.uow.run(async (scope) => {\n // Serializes concurrent reports for this student+course (locks are tx-scoped;\n // both racers lock in the same order, the loser waits and then sees committed state).\n const lockIds = [\n ...courseActivities.filter((a) => isActivityPublished(a.settings)).map((a) => a.id),\n ...modules.map((m) => m.id),\n courseId,\n ];\n await scope.progress.findByTargets(orgId, input.orgUserId, lockIds, { forUpdate: true });\n const events: NewProgressEvent[] = [];\n let record = await this.ensureActivityRecord(orgId, input, scope, events);\n const state = mergeReports(record.position, input.reports);\n if (state.changed && !record.completedAt) {\n record = (await scope.progress.update(orgId, record.id, { position: state.map })) ?? record;\n }\n if (!record.completedAt && completionSatisfied(activity.settings, state.claimed)) {\n record =\n (await scope.progress.update(orgId, record.id, {\n completedAt: new Date(),\n })) ?? record;\n events.push(\n progressEvents.progressCompleted.make({ orgId, data: record }),\n );\n await this.completeContainers(orgId, input, courseId, modules, courseActivities, scope, events);\n this.logger.info('progress completed', { orgId, recordId: record.id });\n }\n if (events.length > 0) {\n await scope.outbox.append(events);\n }\n return record;\n });\n }\n\n private async ensureActivityRecord(\n orgId: string,\n input: ReportProgressInput,\n scope: ProgressWriteScope,\n events: NewProgressEvent[],\n ): Promise<ProgressRecord> {\n const target: ProgressTarget = {\n orgUserId: input.orgUserId,\n targetType: 'activity',\n targetId: input.activityId,\n };\n const existing = await scope.progress.findByTarget(orgId, target);\n if (existing) {\n return existing;\n }\n const now = new Date();\n const record = await scope.progress.insert(orgId, {\n id: genId('progress'),\n orgId,\n orgUserId: input.orgUserId,\n targetType: 'activity',\n targetId: input.activityId,\n startedAt: now,\n position: null,\n completedAt: null,\n updatedAt: now,\n });\n if (!record) {\n // Lost a concurrent first-touch insert: the winner owns the row and emitted\n // progress.started. Return its row without re-emitting.\n const winner = await scope.progress.findByTarget(orgId, target);\n if (!winner) {\n throw new Error('progress record vanished after insert conflict');\n }\n return winner;\n }\n events.push(progressEvents.progressStarted.make({ orgId, data: record }));\n this.logger.info('progress started', { orgId, recordId: record.id });\n return record;\n }\n\n /** After an activity completes: newly-complete containers get their records\n * (created complete) and a progress.completed event — same transaction. */\n private async completeContainers(\n orgId: string,\n input: ReportProgressInput,\n courseId: string,\n modules: Module[],\n courseActivities: Activity[],\n scope: ProgressWriteScope,\n events: NewProgressEvent[],\n ): Promise<void> {\n const published = courseActivities.filter((a) => isActivityPublished(a.settings));\n const byModule = modules.map((m) => ({\n id: m.id,\n activityIds: published.filter((a) => a.moduleId === m.id).map((a) => a.id),\n }));\n const allIds = byModule.flatMap((m) => m.activityIds);\n const records = await scope.progress.findByTargets(orgId, input.orgUserId, allIds);\n const done = new Set(\n records.filter((r) => r.targetType === 'activity' && r.completedAt).map((r) => r.targetId),\n );\n const containing = byModule.find((m) => m.activityIds.includes(input.activityId));\n if (containing && containing.activityIds.every((id) => done.has(id))) {\n await this.ensureContainerComplete(orgId, input, 'module', containing.id, scope, events);\n }\n if (allIds.length > 0 && allIds.every((id) => done.has(id))) {\n await this.ensureContainerComplete(orgId, input, 'course', courseId, scope, events);\n }\n }\n\n private async ensureContainerComplete(\n orgId: string,\n input: ReportProgressInput,\n targetType: 'module' | 'course',\n targetId: string,\n scope: ProgressWriteScope,\n events: NewProgressEvent[],\n ): Promise<void> {\n const target: ProgressTarget = { orgUserId: input.orgUserId, targetType, targetId };\n let existing = await scope.progress.findByTarget(orgId, target);\n if (existing?.completedAt) {\n return;\n }\n if (!existing) {\n const now = new Date();\n const inserted = await scope.progress.insert(orgId, {\n id: genId('progress'),\n orgId,\n orgUserId: input.orgUserId,\n targetType,\n targetId,\n startedAt: now,\n position: null,\n completedAt: now,\n updatedAt: now,\n });\n if (inserted) {\n events.push(\n progressEvents.progressCompleted.make({ orgId, data: inserted }),\n );\n return;\n }\n // Lost a concurrent insert: re-read the winner's row.\n existing = await scope.progress.findByTarget(orgId, target);\n if (existing?.completedAt) {\n return;\n }\n if (!existing) {\n throw new Error('progress container record vanished after insert conflict');\n }\n }\n const record =\n (await scope.progress.update(orgId, existing.id, {\n completedAt: new Date(),\n })) ?? existing;\n events.push(progressEvents.progressCompleted.make({ orgId, data: record }));\n }\n\n get(orgId: string, target: ProgressTarget): Promise<ProgressRecord | null> {\n return this.repo.findByTarget(orgId, target);\n }\n\n listByTargets(orgId: string, orgUserId: string, targetIds: string[]): Promise<ProgressRecord[]> {\n return this.repo.findByTargets(orgId, orgUserId, targetIds);\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { EnrollmentPoint, OverviewStats } from "./model.js";
|
|
2
|
+
import { DashboardReportRepository, DashboardReportService } from "./ports.js";
|
|
3
|
+
import { DashboardReportServiceImpl } from "./service.js";
|
|
4
|
+
export { type DashboardReportRepository, type DashboardReportService, DashboardReportServiceImpl, type EnrollmentPoint, type OverviewStats };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/reporting/dashboard/model.d.ts
|
|
2
|
+
interface OverviewStats {
|
|
3
|
+
publishedCourses: number;
|
|
4
|
+
draftCourses: number;
|
|
5
|
+
activeStudents: number;
|
|
6
|
+
expiringSoon: number;
|
|
7
|
+
}
|
|
8
|
+
interface EnrollmentPoint {
|
|
9
|
+
date: string;
|
|
10
|
+
count: number;
|
|
11
|
+
}
|
|
12
|
+
//#endregion
|
|
13
|
+
export { EnrollmentPoint, OverviewStats };
|
|
14
|
+
//# sourceMappingURL=model.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { EnrollmentPoint, OverviewStats } from "./model.js";
|
|
2
|
+
//#region src/reporting/dashboard/ports.d.ts
|
|
3
|
+
interface DashboardReportService {
|
|
4
|
+
overview(orgId: string): Promise<OverviewStats>;
|
|
5
|
+
enrollments(orgId: string, days: number): Promise<EnrollmentPoint[]>;
|
|
6
|
+
}
|
|
7
|
+
interface DashboardReportRepository {
|
|
8
|
+
overview(orgId: string): Promise<OverviewStats>;
|
|
9
|
+
enrollments(orgId: string, days: number): Promise<EnrollmentPoint[]>;
|
|
10
|
+
}
|
|
11
|
+
//#endregion
|
|
12
|
+
export { DashboardReportRepository, DashboardReportService };
|
|
13
|
+
//# sourceMappingURL=ports.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Logger } from "../../types/ports.js";
|
|
2
|
+
import "../../shared/ports.js";
|
|
3
|
+
import { EnrollmentPoint, OverviewStats } from "./model.js";
|
|
4
|
+
import { DashboardReportRepository, DashboardReportService } from "./ports.js";
|
|
5
|
+
//#region src/reporting/dashboard/service.d.ts
|
|
6
|
+
type DashboardReportServiceParams = {
|
|
7
|
+
repo: DashboardReportRepository;
|
|
8
|
+
logger?: Logger;
|
|
9
|
+
};
|
|
10
|
+
declare class DashboardReportServiceImpl implements DashboardReportService {
|
|
11
|
+
private readonly repo;
|
|
12
|
+
private readonly logger;
|
|
13
|
+
constructor(params: DashboardReportServiceParams);
|
|
14
|
+
overview(orgId: string): Promise<OverviewStats>;
|
|
15
|
+
enrollments(orgId: string, days: number): Promise<EnrollmentPoint[]>;
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
export { DashboardReportServiceImpl, DashboardReportServiceParams };
|
|
19
|
+
//# sourceMappingURL=service.d.ts.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { noopLogger } from "../../shared/logger.js";
|
|
2
|
+
//#region src/reporting/dashboard/service.ts
|
|
3
|
+
var DashboardReportServiceImpl = class {
|
|
4
|
+
repo;
|
|
5
|
+
logger;
|
|
6
|
+
constructor(params) {
|
|
7
|
+
this.repo = params.repo;
|
|
8
|
+
this.logger = params.logger ?? noopLogger;
|
|
9
|
+
}
|
|
10
|
+
overview(orgId) {
|
|
11
|
+
return this.repo.overview(orgId);
|
|
12
|
+
}
|
|
13
|
+
enrollments(orgId, days) {
|
|
14
|
+
return this.repo.enrollments(orgId, days);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
|
18
|
+
export { DashboardReportServiceImpl };
|
|
19
|
+
|
|
20
|
+
//# sourceMappingURL=service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.js","names":[],"sources":["../../../src/reporting/dashboard/service.ts"],"sourcesContent":["// reporting/dashboard — service implementation (inbound port).\nimport type { EnrollmentPoint, OverviewStats } from './model.js';\nimport type { DashboardReportRepository, DashboardReportService } from './ports.js';\nimport type { Logger } from '../../shared/ports.js';\nimport { noopLogger } from '../../shared/logger.js';\n\nexport type DashboardReportServiceParams = {\n repo: DashboardReportRepository;\n logger?: Logger;\n};\n\nexport class DashboardReportServiceImpl implements DashboardReportService {\n private readonly repo: DashboardReportRepository;\n private readonly logger: Logger;\n\n constructor(params: DashboardReportServiceParams) {\n this.repo = params.repo;\n this.logger = params.logger ?? noopLogger;\n }\n\n overview(orgId: string): Promise<OverviewStats> {\n return this.repo.overview(orgId);\n }\n\n enrollments(orgId: string, days: number): Promise<EnrollmentPoint[]> {\n return this.repo.enrollments(orgId, days);\n }\n}\n"],"mappings":";;AAWA,IAAa,6BAAb,MAA0E;CACxE;CACA;CAEA,YAAY,QAAsC;EAChD,KAAK,OAAO,OAAO;EACnB,KAAK,SAAS,OAAO,UAAU;CACjC;CAEA,SAAS,OAAuC;EAC9C,OAAO,KAAK,KAAK,SAAS,KAAK;CACjC;CAEA,YAAY,OAAe,MAA0C;EACnE,OAAO,KAAK,KAAK,YAAY,OAAO,IAAI;CAC1C;AACF"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Course, Module } from "../../types/schemas/content.js";
|
|
2
|
+
import { ContentRef, CourseProgressView } from "./model.js";
|
|
3
|
+
import { LearnEntitlementReader, LearnReportService } from "./ports.js";
|
|
4
|
+
import { LearnReportServiceImpl } from "./service.js";
|
|
5
|
+
export { type ContentRef, type Course, type CourseProgressView, type LearnEntitlementReader, type LearnReportService, LearnReportServiceImpl, type Module };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Course, Download, DownloadAsset, Module } from "../../types/schemas/content.js";
|
|
2
|
+
import "../../content/index.js";
|
|
3
|
+
//#region src/reporting/learn/model.d.ts
|
|
4
|
+
/** An active-entitlement pointer: the org + content item a student may consume. */
|
|
5
|
+
interface ContentRef {
|
|
6
|
+
orgId: string;
|
|
7
|
+
contentId: string;
|
|
8
|
+
}
|
|
9
|
+
/** Per-course progress for the student surface. Derived on read against the
|
|
10
|
+
* current published structure — never stored. */
|
|
11
|
+
interface CourseProgressView {
|
|
12
|
+
/** Keyed by activity id; absent key = not started. */
|
|
13
|
+
activities: Record<string, 'in-progress' | 'completed'>;
|
|
14
|
+
/** Integer 0–100: completed ÷ current published activities, rounded. */
|
|
15
|
+
percent: number;
|
|
16
|
+
/** The course target's own record says so. */
|
|
17
|
+
completed: boolean;
|
|
18
|
+
/** Per-activity reported state (the record's position map); absent key = none reported. */
|
|
19
|
+
positions: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
export { ContentRef, type Course, CourseProgressView, type Download, type DownloadAsset, type Module };
|
|
23
|
+
//# sourceMappingURL=model.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Course, Download, DownloadAsset, Module } from "../../types/schemas/content.js";
|
|
2
|
+
import { ContentRef, CourseProgressView } from "./model.js";
|
|
3
|
+
//#region src/reporting/learn/ports.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Inbound: the student-scoped read use-cases. Scoped by `(orgId, orgUserId)` —
|
|
6
|
+
* the portal org resolved at the boundary. `null` ⇒ not enrolled (→ 404).
|
|
7
|
+
*/
|
|
8
|
+
interface LearnReportService {
|
|
9
|
+
listCourses(orgId: string, orgUserId: string): Promise<Course[]>;
|
|
10
|
+
getCourse(orgId: string, orgUserId: string, courseId: string): Promise<Course | null>;
|
|
11
|
+
listModules(orgId: string, orgUserId: string, courseId: string): Promise<Module[] | null>;
|
|
12
|
+
courseProgress(orgId: string, orgUserId: string, courseId: string): Promise<CourseProgressView | null>;
|
|
13
|
+
listDownloads(orgId: string, orgUserId: string): Promise<Download[]>;
|
|
14
|
+
getDownload(orgId: string, orgUserId: string, downloadId: string): Promise<{
|
|
15
|
+
download: Download;
|
|
16
|
+
assets: DownloadAsset[];
|
|
17
|
+
} | null>;
|
|
18
|
+
/** Entitlement-gated. Returns null (→ 404) for every failure — never 403,
|
|
19
|
+
* which would confirm the resource exists to someone not entitled to it. */
|
|
20
|
+
downloadAssetUrl(orgId: string, orgUserId: string, downloadId: string, assetId: string): Promise<{
|
|
21
|
+
url: string;
|
|
22
|
+
filename: string;
|
|
23
|
+
} | null>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Outbound: the student's active, non-expired course entitlements in PUBLISHED
|
|
27
|
+
* courses, scoped to the portal org. Implemented by a Drizzle read repo; the
|
|
28
|
+
* service resolves each ref against the content service for the full
|
|
29
|
+
* Course/Module payload.
|
|
30
|
+
*/
|
|
31
|
+
interface LearnEntitlementReader {
|
|
32
|
+
activeRefs(orgId: string, orgUserId: string): Promise<ContentRef[]>;
|
|
33
|
+
activeRef(orgId: string, orgUserId: string, courseId: string): Promise<ContentRef | null>;
|
|
34
|
+
/** Active, non-expired grants to PUBLISHED downloads, scoped to the org. */
|
|
35
|
+
activeDownloadRefs(orgId: string, orgUserId: string): Promise<ContentRef[]>;
|
|
36
|
+
activeDownloadRef(orgId: string, orgUserId: string, downloadId: string): Promise<ContentRef | null>;
|
|
37
|
+
/** Whether this asset is linked to this download. The paywall's second gate. */
|
|
38
|
+
downloadHasAsset(orgId: string, downloadId: string, assetId: string): Promise<boolean>;
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
export { LearnEntitlementReader, LearnReportService };
|
|
42
|
+
//# sourceMappingURL=ports.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Logger } from "../../types/ports.js";
|
|
2
|
+
import { Course, Download, DownloadAsset, Module } from "../../types/schemas/content.js";
|
|
3
|
+
import "../../shared/ports.js";
|
|
4
|
+
import { AssetsService } from "../../assets/ports.js";
|
|
5
|
+
import "../../assets/index.js";
|
|
6
|
+
import { ContentService } from "../../content/ports.js";
|
|
7
|
+
import "../../content/index.js";
|
|
8
|
+
import { ProgressService } from "../../progress/ports.js";
|
|
9
|
+
import "../../progress/index.js";
|
|
10
|
+
import { CourseProgressView } from "./model.js";
|
|
11
|
+
import { LearnEntitlementReader, LearnReportService } from "./ports.js";
|
|
12
|
+
//#region src/reporting/learn/service.d.ts
|
|
13
|
+
type LearnReportServiceParams = {
|
|
14
|
+
reader: LearnEntitlementReader;
|
|
15
|
+
content: ContentService;
|
|
16
|
+
progress: ProgressService;
|
|
17
|
+
assets: AssetsService;
|
|
18
|
+
deliveryExpirySeconds: number;
|
|
19
|
+
logger?: Logger;
|
|
20
|
+
};
|
|
21
|
+
declare class LearnReportServiceImpl implements LearnReportService {
|
|
22
|
+
private readonly reader;
|
|
23
|
+
private readonly content;
|
|
24
|
+
private readonly progress;
|
|
25
|
+
private readonly assets;
|
|
26
|
+
private readonly deliveryExpirySeconds;
|
|
27
|
+
private readonly logger;
|
|
28
|
+
constructor(params: LearnReportServiceParams);
|
|
29
|
+
listCourses(orgId: string, orgUserId: string): Promise<Course[]>;
|
|
30
|
+
getCourse(orgId: string, orgUserId: string, courseId: string): Promise<Course | null>;
|
|
31
|
+
listModules(orgId: string, orgUserId: string, courseId: string): Promise<Module[] | null>;
|
|
32
|
+
courseProgress(orgId: string, orgUserId: string, courseId: string): Promise<CourseProgressView | null>;
|
|
33
|
+
listDownloads(orgId: string, orgUserId: string): Promise<Download[]>;
|
|
34
|
+
getDownload(orgId: string, orgUserId: string, downloadId: string): Promise<{
|
|
35
|
+
download: Download;
|
|
36
|
+
assets: DownloadAsset[];
|
|
37
|
+
} | null>;
|
|
38
|
+
/** The paywall. Two gates, in order: an active entitlement to a published
|
|
39
|
+
* download, then the asset actually belonging to that download — so a
|
|
40
|
+
* student entitled to X cannot reach an asset of Y by swapping the id. */
|
|
41
|
+
downloadAssetUrl(orgId: string, orgUserId: string, downloadId: string, assetId: string): Promise<{
|
|
42
|
+
url: string;
|
|
43
|
+
filename: string;
|
|
44
|
+
} | null>;
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
export { LearnReportServiceImpl, LearnReportServiceParams };
|
|
48
|
+
//# sourceMappingURL=service.d.ts.map
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { noopLogger } from "../../shared/logger.js";
|
|
2
|
+
//#region src/reporting/learn/service.ts
|
|
3
|
+
function isActivityPublished(settings) {
|
|
4
|
+
return settings?.published !== false;
|
|
5
|
+
}
|
|
6
|
+
var LearnReportServiceImpl = class {
|
|
7
|
+
reader;
|
|
8
|
+
content;
|
|
9
|
+
progress;
|
|
10
|
+
assets;
|
|
11
|
+
deliveryExpirySeconds;
|
|
12
|
+
logger;
|
|
13
|
+
constructor(params) {
|
|
14
|
+
this.reader = params.reader;
|
|
15
|
+
this.content = params.content;
|
|
16
|
+
this.progress = params.progress;
|
|
17
|
+
this.assets = params.assets;
|
|
18
|
+
this.deliveryExpirySeconds = params.deliveryExpirySeconds;
|
|
19
|
+
this.logger = params.logger ?? noopLogger;
|
|
20
|
+
}
|
|
21
|
+
async listCourses(orgId, orgUserId) {
|
|
22
|
+
const refs = await this.reader.activeRefs(orgId, orgUserId);
|
|
23
|
+
return (await Promise.all(refs.map((ref) => this.content.getCourse(ref.orgId, ref.contentId)))).filter((c) => c !== null && c.status === "published");
|
|
24
|
+
}
|
|
25
|
+
async getCourse(orgId, orgUserId, courseId) {
|
|
26
|
+
const ref = await this.reader.activeRef(orgId, orgUserId, courseId);
|
|
27
|
+
if (!ref) return null;
|
|
28
|
+
const course = await this.content.getCourse(ref.orgId, courseId);
|
|
29
|
+
return course && course.status === "published" ? course : null;
|
|
30
|
+
}
|
|
31
|
+
async listModules(orgId, orgUserId, courseId) {
|
|
32
|
+
const ref = await this.reader.activeRef(orgId, orgUserId, courseId);
|
|
33
|
+
if (!ref) return null;
|
|
34
|
+
return this.content.listCourseModules(ref.orgId, courseId);
|
|
35
|
+
}
|
|
36
|
+
async courseProgress(orgId, orgUserId, courseId) {
|
|
37
|
+
const ref = await this.reader.activeRef(orgId, orgUserId, courseId);
|
|
38
|
+
if (!ref) return null;
|
|
39
|
+
const ids = (await this.content.listCourseActivities(ref.orgId, courseId)).filter((a) => isActivityPublished(a.settings)).map((a) => a.id);
|
|
40
|
+
const records = await this.progress.listByTargets(ref.orgId, orgUserId, ids);
|
|
41
|
+
const activities = {};
|
|
42
|
+
const positions = {};
|
|
43
|
+
let done = 0;
|
|
44
|
+
for (const r of records) {
|
|
45
|
+
if (r.targetType !== "activity") continue;
|
|
46
|
+
activities[r.targetId] = r.completedAt ? "completed" : "in-progress";
|
|
47
|
+
if (r.completedAt) done += 1;
|
|
48
|
+
if (r.position != null) positions[r.targetId] = r.position;
|
|
49
|
+
}
|
|
50
|
+
const courseRecord = await this.progress.get(ref.orgId, {
|
|
51
|
+
orgUserId,
|
|
52
|
+
targetType: "course",
|
|
53
|
+
targetId: courseId
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
activities,
|
|
57
|
+
percent: ids.length > 0 ? Math.round(done / ids.length * 100) : 0,
|
|
58
|
+
completed: courseRecord?.completedAt != null,
|
|
59
|
+
positions
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async listDownloads(orgId, orgUserId) {
|
|
63
|
+
const refs = await this.reader.activeDownloadRefs(orgId, orgUserId);
|
|
64
|
+
return (await Promise.all(refs.map((ref) => this.content.getDownload(ref.orgId, ref.contentId)))).filter((d) => d !== null && d.status === "published");
|
|
65
|
+
}
|
|
66
|
+
async getDownload(orgId, orgUserId, downloadId) {
|
|
67
|
+
const ref = await this.reader.activeDownloadRef(orgId, orgUserId, downloadId);
|
|
68
|
+
if (!ref) return null;
|
|
69
|
+
const download = await this.content.getDownload(ref.orgId, downloadId);
|
|
70
|
+
if (!download || download.status !== "published") return null;
|
|
71
|
+
return {
|
|
72
|
+
download,
|
|
73
|
+
assets: await this.content.listDownloadAssets(ref.orgId, downloadId)
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** The paywall. Two gates, in order: an active entitlement to a published
|
|
77
|
+
* download, then the asset actually belonging to that download — so a
|
|
78
|
+
* student entitled to X cannot reach an asset of Y by swapping the id. */
|
|
79
|
+
async downloadAssetUrl(orgId, orgUserId, downloadId, assetId) {
|
|
80
|
+
const ref = await this.reader.activeDownloadRef(orgId, orgUserId, downloadId);
|
|
81
|
+
if (!ref) return null;
|
|
82
|
+
if (!await this.reader.downloadHasAsset(ref.orgId, downloadId, assetId)) {
|
|
83
|
+
this.logger.warn("download asset not linked", {
|
|
84
|
+
orgId,
|
|
85
|
+
downloadId,
|
|
86
|
+
assetId
|
|
87
|
+
});
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const link = (await this.content.listDownloadAssets(ref.orgId, downloadId)).find((l) => l.assetId === assetId);
|
|
91
|
+
if (!link) return null;
|
|
92
|
+
const asset = await this.assets.get(ref.orgId, assetId);
|
|
93
|
+
if (!asset) return null;
|
|
94
|
+
const filename = link.displayName ?? asset.filename;
|
|
95
|
+
const ticket = await this.assets.requestDownload(ref.orgId, assetId, filename, this.deliveryExpirySeconds);
|
|
96
|
+
if (!ticket) return null;
|
|
97
|
+
this.logger.info("download asset signed", {
|
|
98
|
+
orgId,
|
|
99
|
+
downloadId,
|
|
100
|
+
assetId
|
|
101
|
+
});
|
|
102
|
+
return {
|
|
103
|
+
url: ticket.url,
|
|
104
|
+
filename
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
//#endregion
|
|
109
|
+
export { LearnReportServiceImpl };
|
|
110
|
+
|
|
111
|
+
//# sourceMappingURL=service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.js","names":[],"sources":["../../../src/reporting/learn/service.ts"],"sourcesContent":["// reporting/learn — service implementation. Composes the enrollment reader\n// (which published courses the student is actively enrolled in) with the content\n// service (the Course/Module payload). Activities are filtered to published;\n// `settings.published === false` is the only draft signal (missing ⇒ published).\nimport type { ContentService } from '../../content/index.js';\nimport type { ProgressService } from '../../progress/index.js';\nimport type { AssetsService } from '../../assets/index.js';\nimport type { Course, Module, CourseProgressView, Download, DownloadAsset } from './model.js';\nimport type { LearnEntitlementReader, LearnReportService } from './ports.js';\nimport type { Logger } from '../../shared/ports.js';\nimport { noopLogger } from '../../shared/logger.js';\n\nfunction isActivityPublished(settings: unknown): boolean {\n return (settings as { published?: boolean } | null)?.published !== false;\n}\n\nexport type LearnReportServiceParams = {\n reader: LearnEntitlementReader;\n content: ContentService;\n progress: ProgressService;\n assets: AssetsService;\n deliveryExpirySeconds: number;\n logger?: Logger;\n};\n\nexport class LearnReportServiceImpl implements LearnReportService {\n private readonly reader: LearnEntitlementReader;\n private readonly content: ContentService;\n private readonly progress: ProgressService;\n private readonly assets: AssetsService;\n private readonly deliveryExpirySeconds: number;\n private readonly logger: Logger;\n\n constructor(params: LearnReportServiceParams) {\n this.reader = params.reader;\n this.content = params.content;\n this.progress = params.progress;\n this.assets = params.assets;\n this.deliveryExpirySeconds = params.deliveryExpirySeconds;\n this.logger = params.logger ?? noopLogger;\n }\n\n async listCourses(orgId: string, orgUserId: string): Promise<Course[]> {\n const refs = await this.reader.activeRefs(orgId, orgUserId);\n const courses = await Promise.all(\n refs.map((ref) => this.content.getCourse(ref.orgId, ref.contentId)),\n );\n return courses.filter((c): c is Course => c !== null && c.status === 'published');\n }\n\n async getCourse(orgId: string, orgUserId: string, courseId: string): Promise<Course | null> {\n const ref = await this.reader.activeRef(orgId, orgUserId, courseId);\n if (!ref) {\n return null;\n }\n const course = await this.content.getCourse(ref.orgId, courseId);\n return course && course.status === 'published' ? course : null;\n }\n\n async listModules(orgId: string, orgUserId: string, courseId: string): Promise<Module[] | null> {\n const ref = await this.reader.activeRef(orgId, orgUserId, courseId);\n if (!ref) {\n return null;\n }\n return this.content.listCourseModules(ref.orgId, courseId);\n }\n\n async courseProgress(\n orgId: string,\n orgUserId: string,\n courseId: string,\n ): Promise<CourseProgressView | null> {\n const ref = await this.reader.activeRef(orgId, orgUserId, courseId);\n if (!ref) {\n return null;\n }\n const courseActivities = await this.content.listCourseActivities(ref.orgId, courseId);\n const ids = courseActivities.filter((a) => isActivityPublished(a.settings)).map((a) => a.id);\n const records = await this.progress.listByTargets(ref.orgId, orgUserId, ids);\n const activities: CourseProgressView['activities'] = {};\n const positions: CourseProgressView['positions'] = {};\n let done = 0;\n for (const r of records) {\n if (r.targetType !== 'activity') {\n continue;\n }\n activities[r.targetId] = r.completedAt ? 'completed' : 'in-progress';\n if (r.completedAt) {\n done += 1;\n }\n if (r.position != null) {\n positions[r.targetId] = r.position;\n }\n }\n const courseRecord = await this.progress.get(ref.orgId, {\n orgUserId,\n targetType: 'course',\n targetId: courseId,\n });\n return {\n activities,\n percent: ids.length > 0 ? Math.round((done / ids.length) * 100) : 0,\n completed: courseRecord?.completedAt != null,\n positions,\n };\n }\n\n async listDownloads(orgId: string, orgUserId: string): Promise<Download[]> {\n const refs = await this.reader.activeDownloadRefs(orgId, orgUserId);\n const rows = await Promise.all(\n refs.map((ref) => this.content.getDownload(ref.orgId, ref.contentId)),\n );\n return rows.filter((d): d is Download => d !== null && d.status === 'published');\n }\n\n async getDownload(\n orgId: string,\n orgUserId: string,\n downloadId: string,\n ): Promise<{ download: Download; assets: DownloadAsset[] } | null> {\n const ref = await this.reader.activeDownloadRef(orgId, orgUserId, downloadId);\n if (!ref) {\n return null;\n }\n const download = await this.content.getDownload(ref.orgId, downloadId);\n if (!download || download.status !== 'published') {\n return null;\n }\n const assets = await this.content.listDownloadAssets(ref.orgId, downloadId);\n return { download, assets };\n }\n\n /** The paywall. Two gates, in order: an active entitlement to a published\n * download, then the asset actually belonging to that download — so a\n * student entitled to X cannot reach an asset of Y by swapping the id. */\n async downloadAssetUrl(\n orgId: string,\n orgUserId: string,\n downloadId: string,\n assetId: string,\n ): Promise<{ url: string; filename: string } | null> {\n const ref = await this.reader.activeDownloadRef(orgId, orgUserId, downloadId);\n if (!ref) {\n return null;\n }\n const linked = await this.reader.downloadHasAsset(ref.orgId, downloadId, assetId);\n if (!linked) {\n this.logger.warn('download asset not linked', { orgId, downloadId, assetId });\n return null;\n }\n const links = await this.content.listDownloadAssets(ref.orgId, downloadId);\n const link = links.find((l) => l.assetId === assetId);\n if (!link) {\n return null;\n }\n const asset = await this.assets.get(ref.orgId, assetId);\n if (!asset) {\n return null;\n }\n const filename = link.displayName ?? asset.filename;\n const ticket = await this.assets.requestDownload(\n ref.orgId,\n assetId,\n filename,\n this.deliveryExpirySeconds,\n );\n if (!ticket) {\n return null;\n }\n // Never log the signed URL — it is a bearer capability.\n this.logger.info('download asset signed', { orgId, downloadId, assetId });\n return { url: ticket.url, filename };\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { Page, Student, StudentsQuery } from "./model.js";
|
|
2
|
+
import { StudentsReportRepository, StudentsReportService } from "./ports.js";
|
|
3
|
+
import { StudentsReportServiceImpl } from "./service.js";
|
|
4
|
+
export { type Page, type Student, type StudentsQuery, type StudentsReportRepository, type StudentsReportService, StudentsReportServiceImpl };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { OrgUserProfile, OrgUserStatus } from "../../types/schemas/organizations.js";
|
|
2
|
+
import "../../types/index.js";
|
|
3
|
+
//#region src/reporting/students/model.d.ts
|
|
4
|
+
interface Student extends OrgUserProfile {
|
|
5
|
+
/** As typed on the invite form. `name` is the composed rendering name and
|
|
6
|
+
* drifts once the person edits their own profile; these are what the admin
|
|
7
|
+
* edits. Null for anyone who arrived by self-signup. */
|
|
8
|
+
firstName: string | null;
|
|
9
|
+
lastName: string | null;
|
|
10
|
+
entitlementCount: number;
|
|
11
|
+
avgProgress: number;
|
|
12
|
+
/** `invited` until they accept — the admin added them, they have not arrived. */
|
|
13
|
+
status: OrgUserStatus;
|
|
14
|
+
joinedAt: string;
|
|
15
|
+
lastActiveAt: string | null;
|
|
16
|
+
}
|
|
17
|
+
interface StudentsQuery {
|
|
18
|
+
page: number;
|
|
19
|
+
pageSize: number;
|
|
20
|
+
search?: string | undefined;
|
|
21
|
+
sort?: string | undefined;
|
|
22
|
+
}
|
|
23
|
+
interface Page<T> {
|
|
24
|
+
rows: T[];
|
|
25
|
+
total: number;
|
|
26
|
+
page: number;
|
|
27
|
+
pageSize: number;
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
export { Page, Student, StudentsQuery };
|
|
31
|
+
//# sourceMappingURL=model.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Page, Student, StudentsQuery } from "./model.js";
|
|
2
|
+
//#region src/reporting/students/ports.d.ts
|
|
3
|
+
interface StudentsReportService {
|
|
4
|
+
list(orgId: string, query: StudentsQuery): Promise<Page<Student>>;
|
|
5
|
+
get(orgId: string, id: string): Promise<Student | null>;
|
|
6
|
+
}
|
|
7
|
+
interface StudentsReportRepository {
|
|
8
|
+
list(orgId: string, query: StudentsQuery): Promise<Page<Student>>;
|
|
9
|
+
findById(orgId: string, id: string): Promise<Student | null>;
|
|
10
|
+
}
|
|
11
|
+
//#endregion
|
|
12
|
+
export { StudentsReportRepository, StudentsReportService };
|
|
13
|
+
//# sourceMappingURL=ports.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|