@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,343 @@
|
|
|
1
|
+
import { genId } from "../shared/id.js";
|
|
2
|
+
import { noopLogger } from "../shared/logger.js";
|
|
3
|
+
import "../shared/settings.js";
|
|
4
|
+
import { ForbiddenError, NotFoundError } from "../shared/errors.js";
|
|
5
|
+
import { discussionEvents } from "./events.js";
|
|
6
|
+
//#region src/discussion/service.ts
|
|
7
|
+
/** A course with no stored settings. Discussion is opt-in, so the common case
|
|
8
|
+
* persists no row at all and every existing course stays silent. */
|
|
9
|
+
const DEFAULT_SETTINGS = {
|
|
10
|
+
enabled: false,
|
|
11
|
+
threaded: true,
|
|
12
|
+
requireReview: false,
|
|
13
|
+
reactions: true
|
|
14
|
+
};
|
|
15
|
+
const EMPTY_REACTIONS = { reactions: {} };
|
|
16
|
+
var DiscussionServiceImpl = class {
|
|
17
|
+
repo;
|
|
18
|
+
access;
|
|
19
|
+
content;
|
|
20
|
+
uow;
|
|
21
|
+
settings;
|
|
22
|
+
logger;
|
|
23
|
+
constructor(params) {
|
|
24
|
+
this.repo = params.repo;
|
|
25
|
+
this.access = params.access;
|
|
26
|
+
this.content = params.content;
|
|
27
|
+
this.uow = params.uow;
|
|
28
|
+
this.settings = params.settings;
|
|
29
|
+
this.logger = params.logger ?? noopLogger;
|
|
30
|
+
}
|
|
31
|
+
async resolveConfig(orgId, activityId) {
|
|
32
|
+
const activity = await this.content.getActivity(orgId, activityId);
|
|
33
|
+
if (!activity) throw new NotFoundError("Activity", activityId);
|
|
34
|
+
const stored = await this.settings.get(orgId, "content", activity.courseId);
|
|
35
|
+
const settings = {
|
|
36
|
+
...DEFAULT_SETTINGS,
|
|
37
|
+
...stored?.comments
|
|
38
|
+
};
|
|
39
|
+
const rule = (activity?.settings ?? {}).comments ?? "inherit";
|
|
40
|
+
return {
|
|
41
|
+
enabled: settings.enabled && rule !== "never",
|
|
42
|
+
threaded: settings.threaded,
|
|
43
|
+
requireReview: settings.requireReview,
|
|
44
|
+
reactions: settings.reactions
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Any org user that is not a learner moderates. The role arrives from
|
|
48
|
+
* the edge already resolved; core never looks one up to authorise. */
|
|
49
|
+
isStaff(actor) {
|
|
50
|
+
return actor.role !== "student";
|
|
51
|
+
}
|
|
52
|
+
toAuthor(record) {
|
|
53
|
+
return {
|
|
54
|
+
id: record.id,
|
|
55
|
+
firstName: record.firstName,
|
|
56
|
+
lastName: record.lastName,
|
|
57
|
+
image: record.image,
|
|
58
|
+
role: record.role
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
toView(comment, authors, reactions) {
|
|
62
|
+
const author = authors[comment.orgUserId];
|
|
63
|
+
if (!author) throw new NotFoundError("OrgUser", comment.orgUserId);
|
|
64
|
+
if (comment.removedBy && !authors[comment.removedBy]) throw new NotFoundError("OrgUser", comment.removedBy);
|
|
65
|
+
const remover = comment.removedBy ? authors[comment.removedBy] : void 0;
|
|
66
|
+
return {
|
|
67
|
+
id: comment.id,
|
|
68
|
+
activityId: comment.activityId,
|
|
69
|
+
parentId: comment.parentId,
|
|
70
|
+
author: this.toAuthor(author),
|
|
71
|
+
body: comment.status === "removed" ? null : comment.body,
|
|
72
|
+
status: comment.status,
|
|
73
|
+
removedBy: remover ? this.toAuthor(remover) : null,
|
|
74
|
+
reactions: reactions.reactions,
|
|
75
|
+
viewerReaction: reactions.viewerReaction,
|
|
76
|
+
createdAt: comment.createdAt,
|
|
77
|
+
updatedAt: comment.updatedAt
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
peopleIn(comments) {
|
|
81
|
+
const ids = /* @__PURE__ */ new Set();
|
|
82
|
+
for (const c of comments) {
|
|
83
|
+
ids.add(c.orgUserId);
|
|
84
|
+
if (c.removedBy) ids.add(c.removedBy);
|
|
85
|
+
}
|
|
86
|
+
return [...ids];
|
|
87
|
+
}
|
|
88
|
+
async renderOne(orgId, comment, actor) {
|
|
89
|
+
const authors = await this.repo.authorsOf(orgId, this.peopleIn([comment]));
|
|
90
|
+
const stored = comment.status === "removed" ? EMPTY_REACTIONS : (await this.repo.reactionsOf(orgId, [comment.id], actor.id))[comment.id] ?? EMPTY_REACTIONS;
|
|
91
|
+
return this.toView(comment, authors, stored);
|
|
92
|
+
}
|
|
93
|
+
async hasAccess(orgId, activityId, actor) {
|
|
94
|
+
return this.isStaff(actor) || await (async () => {
|
|
95
|
+
const activity = await this.content.getActivity(orgId, activityId);
|
|
96
|
+
return activity ? this.access.hasCourseAccess(orgId, actor.id, activity.courseId) : false;
|
|
97
|
+
})();
|
|
98
|
+
}
|
|
99
|
+
async postComment(orgId, { activityId, actor, parentId, body }) {
|
|
100
|
+
await this.hasAccess(orgId, activityId, actor);
|
|
101
|
+
const config = await this.resolveConfig(orgId, activityId);
|
|
102
|
+
if (!config.enabled) throw new ForbiddenError("discussion is not open on this activity");
|
|
103
|
+
if (parentId !== null) {
|
|
104
|
+
if (!config.threaded) throw new ForbiddenError("replies are disabled on this course");
|
|
105
|
+
const parent = await this.repo.findComment(orgId, parentId);
|
|
106
|
+
if (!parent || parent.activityId !== activityId) throw new NotFoundError("Comment", parentId);
|
|
107
|
+
if (parent.parentId !== null) throw new ForbiddenError("replies nest one level");
|
|
108
|
+
if (parent.status !== "published") throw new ForbiddenError("cannot reply to a comment that is not published");
|
|
109
|
+
}
|
|
110
|
+
const status = config.requireReview && !this.isStaff(actor) ? "pending" : "published";
|
|
111
|
+
const comment = {
|
|
112
|
+
id: genId("comment"),
|
|
113
|
+
orgId,
|
|
114
|
+
activityId,
|
|
115
|
+
parentId,
|
|
116
|
+
orgUserId: actor.id,
|
|
117
|
+
body,
|
|
118
|
+
status,
|
|
119
|
+
removedBy: null,
|
|
120
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
121
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
122
|
+
};
|
|
123
|
+
const saved = await this.uow.run(async (scope) => {
|
|
124
|
+
const row = await scope.discussion.insertComment(orgId, comment);
|
|
125
|
+
await scope.outbox.append([discussionEvents.commentCreated.make({
|
|
126
|
+
orgId,
|
|
127
|
+
data: row
|
|
128
|
+
})]);
|
|
129
|
+
this.logger.info("comment created", {
|
|
130
|
+
orgId,
|
|
131
|
+
commentId: row.id,
|
|
132
|
+
status
|
|
133
|
+
});
|
|
134
|
+
return row;
|
|
135
|
+
});
|
|
136
|
+
return this.renderOne(orgId, saved, actor);
|
|
137
|
+
}
|
|
138
|
+
async activityComments(orgId, activityId, actor) {
|
|
139
|
+
const config = await this.resolveConfig(orgId, activityId);
|
|
140
|
+
if (!config.enabled) return {
|
|
141
|
+
config,
|
|
142
|
+
comments: []
|
|
143
|
+
};
|
|
144
|
+
const readable = (await this.repo.listByActivity(orgId, activityId)).filter((c) => {
|
|
145
|
+
if (c.status === "published" || c.status === "removed") return true;
|
|
146
|
+
return this.isStaff(actor) || c.orgUserId === actor.id;
|
|
147
|
+
});
|
|
148
|
+
const replies = readable.filter((c) => c.parentId !== null && c.status !== "removed");
|
|
149
|
+
const heldOpen = new Set(replies.map((r) => r.parentId));
|
|
150
|
+
const roots = readable.filter((c) => c.parentId === null && (c.status !== "removed" || heldOpen.has(c.id)));
|
|
151
|
+
const rootIds = new Set(roots.map((r) => r.id));
|
|
152
|
+
const served = [...roots, ...replies.filter((r) => rootIds.has(r.parentId))].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
153
|
+
const [authors, reactions] = await Promise.all([this.repo.authorsOf(orgId, this.peopleIn(served)), config.reactions ? this.repo.reactionsOf(orgId, served.map((c) => c.id), actor.id) : Promise.resolve({})]);
|
|
154
|
+
return {
|
|
155
|
+
config,
|
|
156
|
+
comments: served.map((c) => this.toView(c, authors, reactions[c.id] ?? EMPTY_REACTIONS))
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
async edit(orgId, commentId, actor, body) {
|
|
160
|
+
const { comment, config } = await this.loadWithConfig(orgId, commentId, actor);
|
|
161
|
+
if (!config.enabled) throw new ForbiddenError("discussion is not open on this activity");
|
|
162
|
+
if (comment.orgUserId !== actor.id) throw new ForbiddenError("only the author may edit a comment");
|
|
163
|
+
if (comment.status === "removed") throw new ForbiddenError("a removed comment cannot be edited");
|
|
164
|
+
const updated = await this.repo.updateComment(orgId, commentId, { body });
|
|
165
|
+
if (!updated) throw new NotFoundError("Comment", commentId);
|
|
166
|
+
return this.renderOne(orgId, updated, actor);
|
|
167
|
+
}
|
|
168
|
+
async remove(orgId, commentId, actor) {
|
|
169
|
+
const comment = await this.getComment(orgId, commentId);
|
|
170
|
+
if (!comment) throw new NotFoundError("Comment", commentId);
|
|
171
|
+
await this.hasAccess(orgId, comment.activityId, actor);
|
|
172
|
+
if (comment.orgUserId !== actor.id && !this.isStaff(actor)) throw new ForbiddenError("only the author or a moderator may remove a comment");
|
|
173
|
+
if (comment.status === "removed") return comment;
|
|
174
|
+
return this.uow.run(async (scope) => {
|
|
175
|
+
const updated = await scope.discussion.updateComment(orgId, commentId, {
|
|
176
|
+
status: "removed",
|
|
177
|
+
removedBy: actor.id
|
|
178
|
+
});
|
|
179
|
+
if (!updated) throw new NotFoundError("Comment", commentId);
|
|
180
|
+
await scope.outbox.append([discussionEvents.commentRemoved.make({
|
|
181
|
+
orgId,
|
|
182
|
+
data: updated
|
|
183
|
+
})]);
|
|
184
|
+
this.logger.info("comment removed", {
|
|
185
|
+
orgId,
|
|
186
|
+
commentId,
|
|
187
|
+
by: actor.id
|
|
188
|
+
});
|
|
189
|
+
return updated;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
async restore(orgId, commentId, actor) {
|
|
193
|
+
if (!this.isStaff(actor)) throw new ForbiddenError("only a moderator may restore a comment");
|
|
194
|
+
const comment = await this.getComment(orgId, commentId);
|
|
195
|
+
if (!comment) throw new NotFoundError("Comment", commentId);
|
|
196
|
+
if (comment.status !== "removed") return comment;
|
|
197
|
+
const updated = await this.repo.updateComment(orgId, commentId, {
|
|
198
|
+
status: "published",
|
|
199
|
+
removedBy: null
|
|
200
|
+
});
|
|
201
|
+
if (!updated) throw new NotFoundError("Comment", commentId);
|
|
202
|
+
return updated;
|
|
203
|
+
}
|
|
204
|
+
/** Load a comment together with its activity's resolved comments config,
|
|
205
|
+
* having proved the actor may reach that activity at all. Every path that
|
|
206
|
+
* acts on one comment needs the pair, and none should read the row twice. */
|
|
207
|
+
async loadWithConfig(orgId, commentId, actor) {
|
|
208
|
+
const comment = await this.getComment(orgId, commentId);
|
|
209
|
+
if (!comment) throw new NotFoundError("Comment", commentId);
|
|
210
|
+
await this.hasAccess(orgId, comment.activityId, actor);
|
|
211
|
+
return {
|
|
212
|
+
comment,
|
|
213
|
+
config: await this.resolveConfig(orgId, comment.activityId)
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
async setReaction(orgId, commentId, actor, emoji) {
|
|
217
|
+
const { comment, config } = await this.loadWithConfig(orgId, commentId, actor);
|
|
218
|
+
if (!config.enabled) throw new ForbiddenError("discussion is not open on this activity");
|
|
219
|
+
if (!config.reactions && emoji !== null) throw new ForbiddenError("reactions are disabled on this course");
|
|
220
|
+
if (comment.status === "removed") throw new ForbiddenError("a removed comment cannot be reacted to");
|
|
221
|
+
await this.repo.setReaction(orgId, commentId, actor.id, emoji);
|
|
222
|
+
return (await this.repo.reactionsOf(orgId, [commentId], actor.id))[commentId] ?? EMPTY_REACTIONS;
|
|
223
|
+
}
|
|
224
|
+
async approve(orgId, commentId, actor) {
|
|
225
|
+
if (!this.isStaff(actor)) throw new ForbiddenError("only a moderator may approve a comment");
|
|
226
|
+
const comment = await this.getComment(orgId, commentId);
|
|
227
|
+
if (!comment) throw new NotFoundError("Comment", commentId);
|
|
228
|
+
if (comment.status !== "pending") throw new ForbiddenError("only a pending comment can be approved");
|
|
229
|
+
return this.uow.run(async (scope) => {
|
|
230
|
+
const updated = await scope.discussion.updateComment(orgId, commentId, { status: "published" });
|
|
231
|
+
if (!updated) throw new NotFoundError("Comment", commentId);
|
|
232
|
+
await scope.outbox.append([discussionEvents.commentPublished.make({
|
|
233
|
+
orgId,
|
|
234
|
+
data: updated
|
|
235
|
+
})]);
|
|
236
|
+
this.logger.info("comment published", {
|
|
237
|
+
orgId,
|
|
238
|
+
commentId
|
|
239
|
+
});
|
|
240
|
+
return updated;
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
/** Loads the comment and dispatches to whichever transition applies — the
|
|
244
|
+
* caller must not decide that itself. `approve` and `restore` still enforce
|
|
245
|
+
* their own staff check; the check here covers the third path, where a
|
|
246
|
+
* comment is already published and neither of them would run. */
|
|
247
|
+
async publish(orgId, commentId, actor) {
|
|
248
|
+
if (!this.isStaff(actor)) throw new ForbiddenError("only a moderator may publish a comment");
|
|
249
|
+
const comment = await this.getComment(orgId, commentId);
|
|
250
|
+
if (!comment) throw new NotFoundError("Comment", commentId);
|
|
251
|
+
if (comment.status === "pending") return this.approve(orgId, commentId, actor);
|
|
252
|
+
if (comment.status === "removed") return this.restore(orgId, commentId, actor);
|
|
253
|
+
return comment;
|
|
254
|
+
}
|
|
255
|
+
async resolveReports(orgId, commentId, actor) {
|
|
256
|
+
if (!this.isStaff(actor)) throw new ForbiddenError("only a moderator may resolve a report");
|
|
257
|
+
if (!await this.getComment(orgId, commentId)) throw new NotFoundError("Comment", commentId);
|
|
258
|
+
await this.repo.resolveReportsFor(orgId, commentId, /* @__PURE__ */ new Date());
|
|
259
|
+
this.logger.info("comment reports resolved", {
|
|
260
|
+
orgId,
|
|
261
|
+
commentId
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
async reportComment(orgId, commentId, actor, reason) {
|
|
265
|
+
const { config } = await this.loadWithConfig(orgId, commentId, actor);
|
|
266
|
+
if (!config.enabled) throw new ForbiddenError("discussion is not open on this activity");
|
|
267
|
+
const report = {
|
|
268
|
+
id: genId("commentReport"),
|
|
269
|
+
orgId,
|
|
270
|
+
commentId,
|
|
271
|
+
orgUserId: actor.id,
|
|
272
|
+
reason,
|
|
273
|
+
resolvedAt: null,
|
|
274
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
275
|
+
};
|
|
276
|
+
return this.uow.run(async (scope) => {
|
|
277
|
+
const saved = await scope.discussion.insertReport(orgId, report);
|
|
278
|
+
if (!saved) {
|
|
279
|
+
const mine = (await scope.discussion.listOpenReports(orgId, [commentId])).find((r) => r.orgUserId === actor.id);
|
|
280
|
+
if (mine) return mine;
|
|
281
|
+
return report;
|
|
282
|
+
}
|
|
283
|
+
await scope.outbox.append([discussionEvents.commentReported.make({
|
|
284
|
+
orgId,
|
|
285
|
+
data: saved
|
|
286
|
+
})]);
|
|
287
|
+
this.logger.info("comment reported", {
|
|
288
|
+
orgId,
|
|
289
|
+
commentId
|
|
290
|
+
});
|
|
291
|
+
return saved;
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
async listComments(orgId, query) {
|
|
295
|
+
const page = await this.repo.listComments(orgId, query);
|
|
296
|
+
const rows = page.rows.map((r) => r.comment);
|
|
297
|
+
if (rows.length === 0) return {
|
|
298
|
+
...page,
|
|
299
|
+
rows: []
|
|
300
|
+
};
|
|
301
|
+
const reports = await this.repo.listOpenReports(orgId, rows.map((c) => c.id));
|
|
302
|
+
const authors = await this.repo.authorsOf(orgId, [.../* @__PURE__ */ new Set([...this.peopleIn(rows), ...reports.map((r) => r.orgUserId)])]);
|
|
303
|
+
const byComment = /* @__PURE__ */ new Map();
|
|
304
|
+
for (const report of reports) byComment.set(report.commentId, [...byComment.get(report.commentId) ?? [], report]);
|
|
305
|
+
return {
|
|
306
|
+
...page,
|
|
307
|
+
rows: page.rows.map(({ comment, courseId, activityTitle }) => {
|
|
308
|
+
const author = authors[comment.orgUserId];
|
|
309
|
+
if (!author) throw new NotFoundError("OrgUser", comment.orgUserId);
|
|
310
|
+
return {
|
|
311
|
+
id: comment.id,
|
|
312
|
+
parentId: comment.parentId,
|
|
313
|
+
activityId: comment.activityId,
|
|
314
|
+
activityTitle,
|
|
315
|
+
courseId,
|
|
316
|
+
body: comment.body,
|
|
317
|
+
status: comment.status,
|
|
318
|
+
author: this.toAuthor(author),
|
|
319
|
+
authorEmail: author.email,
|
|
320
|
+
removedBy: comment.removedBy,
|
|
321
|
+
reports: (byComment.get(comment.id) ?? []).map((r) => {
|
|
322
|
+
const reporter = authors[r.orgUserId];
|
|
323
|
+
if (!reporter) throw new NotFoundError("OrgUser", r.orgUserId);
|
|
324
|
+
return {
|
|
325
|
+
reporter: this.toAuthor(reporter),
|
|
326
|
+
reason: r.reason,
|
|
327
|
+
createdAt: r.createdAt
|
|
328
|
+
};
|
|
329
|
+
}),
|
|
330
|
+
createdAt: comment.createdAt,
|
|
331
|
+
updatedAt: comment.updatedAt
|
|
332
|
+
};
|
|
333
|
+
})
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
getComment(orgId, commentId) {
|
|
337
|
+
return this.repo.findComment(orgId, commentId);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
//#endregion
|
|
341
|
+
export { DEFAULT_SETTINGS, DiscussionServiceImpl };
|
|
342
|
+
|
|
343
|
+
//# sourceMappingURL=service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.js","names":[],"sources":["../../src/discussion/service.ts"],"sourcesContent":["import { genId } from '../shared/id.js';\nimport { ForbiddenError, NotFoundError } from '../shared/errors.js';\nimport type { Logger } from '../shared/ports.js';\nimport { noopLogger } from '../shared/logger.js';\nimport type {\n ActivityCommentsRule,\n Comment,\n CommentAuthor,\n CommentReport,\n CommentSettings,\n} from './model.js';\nimport type {\n CommentListItem,\n CommentsConfig,\n CommentView,\n ListCommentsQuery,\n Page,\n ReactionEmoji,\n} from './types.js';\nimport { SettingsNamespace, type SettingsService } from '../shared/settings.js';\nimport type {\n ActivityComments,\n ActivityGetter,\n Actor,\n AuthorRecord,\n CommentReactions,\n CourseAccessReader,\n DiscussionRepository,\n DiscussionService,\n DiscussionUnitOfWork,\n PostCommentInput,\n} from './ports.js';\nimport { discussionEvents } from './events.js';\n\n/** A course with no stored settings. Discussion is opt-in, so the common case\n * persists no row at all and every existing course stays silent. */\nexport const DEFAULT_SETTINGS = {\n enabled: false,\n threaded: true,\n requireReview: false,\n reactions: true,\n} as const;\n\n/** The slice of the course's stored settings this context reads. Absent for a\n * course that has never been configured, so the defaults apply. */\ninterface StoredCourseSettings {\n comments?: Partial<CommentSettings>;\n}\n\n/** The slice of an activity's opaque settings blob this context reads. Absent\n * or \"inherit\" = no override, so the course setting stands. */\ninterface StoredActivitySettings {\n comments?: ActivityCommentsRule;\n}\n\nconst EMPTY_REACTIONS: CommentReactions = { reactions: {} };\n\nexport type DiscussionServiceParams = {\n repo: DiscussionRepository;\n access: CourseAccessReader;\n content: ActivityGetter;\n uow: DiscussionUnitOfWork;\n settings: SettingsService;\n logger?: Logger;\n};\n\nexport class DiscussionServiceImpl implements DiscussionService {\n private readonly repo: DiscussionRepository;\n private readonly access: CourseAccessReader;\n private readonly content: ActivityGetter;\n private readonly uow: DiscussionUnitOfWork;\n private readonly settings: SettingsService;\n private readonly logger: Logger;\n\n constructor(params: DiscussionServiceParams) {\n this.repo = params.repo;\n this.access = params.access;\n this.content = params.content;\n this.uow = params.uow;\n this.settings = params.settings;\n this.logger = params.logger ?? noopLogger;\n }\n\n async resolveConfig(orgId: string, activityId: string): Promise<CommentsConfig> {\n const activity = await this.content.getActivity(orgId, activityId);\n if (!activity) {\n throw new NotFoundError('Activity', activityId);\n }\n\n // The `content` namespace row is the whole CourseSettings; comment settings\n // are one key inside it, written there by content.patchSettings.\n const stored = await this.settings.get<StoredCourseSettings>(\n orgId,\n SettingsNamespace.content,\n activity.courseId,\n );\n const settings = { ...DEFAULT_SETTINGS, ...stored?.comments };\n // Comments off for the course cannot be overridden back on by an activity:\n // the course switch is the master.\n const rule = ((activity?.settings ?? {}) as StoredActivitySettings).comments ?? 'inherit';\n return {\n enabled: settings.enabled && rule !== 'never',\n threaded: settings.threaded,\n requireReview: settings.requireReview,\n reactions: settings.reactions,\n };\n }\n\n /** Any org user that is not a learner moderates. The role arrives from\n * the edge already resolved; core never looks one up to authorise. */\n private isStaff(actor: Actor): boolean {\n return actor.role !== 'student';\n }\n\n private toAuthor(record: AuthorRecord): CommentAuthor {\n return {\n id: record.id,\n firstName: record.firstName,\n lastName: record.lastName,\n image: record.image,\n role: record.role,\n };\n }\n\n private toView(\n comment: Comment,\n authors: Record<string, AuthorRecord>,\n reactions: CommentReactions,\n ): CommentView {\n const author = authors[comment.orgUserId];\n if (!author) {\n throw new NotFoundError('OrgUser', comment.orgUserId);\n }\n if (comment.removedBy && !authors[comment.removedBy]) {\n throw new NotFoundError('OrgUser', comment.removedBy);\n }\n const remover = comment.removedBy ? authors[comment.removedBy] : undefined;\n return {\n id: comment.id,\n activityId: comment.activityId,\n parentId: comment.parentId,\n author: this.toAuthor(author),\n body: comment.status === 'removed' ? null : comment.body,\n status: comment.status,\n removedBy: remover ? this.toAuthor(remover) : null,\n reactions: reactions.reactions,\n viewerReaction: reactions.viewerReaction,\n createdAt: comment.createdAt,\n updatedAt: comment.updatedAt,\n };\n }\n\n private peopleIn(comments: Comment[]): string[] {\n const ids = new Set<string>();\n for (const c of comments) {\n ids.add(c.orgUserId);\n if (c.removedBy) {\n ids.add(c.removedBy);\n }\n }\n return [...ids];\n }\n\n private async renderOne(orgId: string, comment: Comment, actor: Actor): Promise<CommentView> {\n const authors = await this.repo.authorsOf(orgId, this.peopleIn([comment]));\n const stored =\n comment.status === 'removed'\n ? EMPTY_REACTIONS\n : ((await this.repo.reactionsOf(orgId, [comment.id], actor.id))[comment.id] ??\n EMPTY_REACTIONS);\n return this.toView(comment, authors, stored);\n }\n\n private async hasAccess(orgId: string, activityId: string, actor: Actor): Promise<boolean> {\n return (\n this.isStaff(actor) ||\n (await (async () => {\n const activity = await this.content.getActivity(orgId, activityId);\n return activity ? this.access.hasCourseAccess(orgId, actor.id, activity.courseId) : false;\n })())\n );\n }\n\n async postComment(\n orgId: string,\n { activityId, actor, parentId, body }: PostCommentInput,\n ): Promise<CommentView> {\n await this.hasAccess(orgId, activityId, actor);\n const config = await this.resolveConfig(orgId, activityId);\n\n if (!config.enabled) {\n throw new ForbiddenError('discussion is not open on this activity');\n }\n\n if (parentId !== null) {\n if (!config.threaded) {\n throw new ForbiddenError('replies are disabled on this course');\n }\n const parent = await this.repo.findComment(orgId, parentId);\n if (!parent || parent.activityId !== activityId) {\n throw new NotFoundError('Comment', parentId);\n }\n\n if (parent.parentId !== null) {\n throw new ForbiddenError('replies nest one level');\n }\n\n if (parent.status !== 'published') {\n throw new ForbiddenError('cannot reply to a comment that is not published');\n }\n }\n\n const status: Comment['status'] =\n config.requireReview && !this.isStaff(actor) ? 'pending' : 'published';\n\n const comment: Comment = {\n id: genId('comment'),\n orgId,\n activityId: activityId,\n parentId: parentId,\n orgUserId: actor.id,\n body: body,\n status,\n removedBy: null,\n createdAt: new Date(),\n updatedAt: new Date(),\n };\n const saved = await this.uow.run(async (scope) => {\n const row = await scope.discussion.insertComment(orgId, comment);\n await scope.outbox.append([\n discussionEvents.commentCreated.make({ orgId, data: row }),\n ]);\n this.logger.info('comment created', { orgId, commentId: row.id, status });\n return row;\n });\n return this.renderOne(orgId, saved, actor);\n }\n\n async activityComments(\n orgId: string,\n activityId: string,\n actor: Actor,\n ): Promise<ActivityComments> {\n const config = await this.resolveConfig(orgId, activityId);\n if (!config.enabled) {\n return { config, comments: [] };\n }\n const rows = await this.repo.listByActivity(orgId, activityId);\n // A pending comment is visible to its own author and to staff, nobody else.\n const readable = rows.filter((c) => {\n if (c.status === 'published' || c.status === 'removed') {\n return true;\n }\n return this.isStaff(actor) || c.orgUserId === actor.id;\n });\n // Replies nest one level, so a reply has nothing hanging off it — a removed\n // reply is simply dropped rather than held open as a placeholder.\n const replies = readable.filter((c) => c.parentId !== null && c.status !== 'removed');\n const heldOpen = new Set(replies.map((r) => r.parentId));\n // A removed root survives only to hold replies THIS reader can see. Judging\n // it against every reply would show a marker with nothing beneath it.\n const roots = readable.filter(\n (c) => c.parentId === null && (c.status !== 'removed' || heldOpen.has(c.id)),\n );\n const rootIds = new Set(roots.map((r) => r.id));\n const served = [...roots, ...replies.filter((r) => rootIds.has(r.parentId!))].sort(\n (a, b) => a.createdAt.getTime() - b.createdAt.getTime(),\n );\n\n const [authors, reactions] = await Promise.all([\n this.repo.authorsOf(orgId, this.peopleIn(served)),\n config.reactions\n ? this.repo.reactionsOf(\n orgId,\n served.map((c) => c.id),\n actor.id,\n )\n : Promise.resolve<Record<string, CommentReactions>>({}),\n ]);\n\n return {\n config,\n comments: served.map((c) => this.toView(c, authors, reactions[c.id] ?? EMPTY_REACTIONS)),\n };\n }\n\n async edit(orgId: string, commentId: string, actor: Actor, body: string): Promise<CommentView> {\n const { comment, config } = await this.loadWithConfig(orgId, commentId, actor);\n if (!config.enabled) {\n throw new ForbiddenError('discussion is not open on this activity');\n }\n // Editing is the author's alone — moderators remove, they do not rewrite.\n if (comment.orgUserId !== actor.id) {\n throw new ForbiddenError('only the author may edit a comment');\n }\n if (comment.status === 'removed') {\n throw new ForbiddenError('a removed comment cannot be edited');\n }\n const updated = await this.repo.updateComment(orgId, commentId, {\n body,\n });\n if (!updated) {\n // getComment() proved it existed; a null here means it vanished mid-write.\n // Returning the pre-edit comment would report success and hand back\n // the old body.\n throw new NotFoundError('Comment', commentId);\n }\n return this.renderOne(orgId, updated, actor);\n }\n\n async remove(orgId: string, commentId: string, actor: Actor): Promise<Comment> {\n const comment = await this.getComment(orgId, commentId);\n if (!comment) {\n throw new NotFoundError('Comment', commentId);\n }\n\n await this.hasAccess(orgId, comment.activityId, actor);\n if (comment.orgUserId !== actor.id && !this.isStaff(actor)) {\n throw new ForbiddenError('only the author or a moderator may remove a comment');\n }\n\n if (comment.status === 'removed') {\n return comment;\n }\n\n return this.uow.run(async (scope) => {\n const updated = await scope.discussion.updateComment(orgId, commentId, {\n status: 'removed',\n removedBy: actor.id,\n });\n if (!updated) {\n // getComment() proved it existed; a null here means it vanished mid-operation.\n // Roll back rather than emit an event for a transition that didn't happen.\n throw new NotFoundError('Comment', commentId);\n }\n await scope.outbox.append([\n discussionEvents.commentRemoved.make({ orgId, data: updated }),\n ]);\n this.logger.info('comment removed', { orgId, commentId, by: actor.id });\n return updated;\n });\n }\n\n async restore(orgId: string, commentId: string, actor: Actor): Promise<Comment> {\n if (!this.isStaff(actor)) {\n throw new ForbiddenError('only a moderator may restore a comment');\n }\n const comment = await this.getComment(orgId, commentId);\n if (!comment) {\n throw new NotFoundError('Comment', commentId);\n }\n if (comment.status !== 'removed') {\n return comment;\n }\n const updated = await this.repo.updateComment(orgId, commentId, {\n status: 'published',\n removedBy: null,\n });\n if (!updated) {\n // getComment() proved it existed; a null here means it vanished mid-write.\n // Returning the still-removed comment would report success while\n // handing back the removed row.\n throw new NotFoundError('Comment', commentId);\n }\n return updated;\n }\n\n /** Load a comment together with its activity's resolved comments config,\n * having proved the actor may reach that activity at all. Every path that\n * acts on one comment needs the pair, and none should read the row twice. */\n private async loadWithConfig(\n orgId: string,\n commentId: string,\n actor: Actor,\n ): Promise<{ comment: Comment; config: CommentsConfig }> {\n const comment = await this.getComment(orgId, commentId);\n if (!comment) {\n throw new NotFoundError('Comment', commentId);\n }\n await this.hasAccess(orgId, comment.activityId, actor);\n const config = await this.resolveConfig(orgId, comment.activityId);\n return { comment, config };\n }\n\n async setReaction(\n orgId: string,\n commentId: string,\n actor: Actor,\n emoji: ReactionEmoji | null,\n ): Promise<CommentReactions> {\n const { comment, config } = await this.loadWithConfig(orgId, commentId, actor);\n // Writing to comments — locked and hidden both refuse.\n if (!config.enabled) {\n throw new ForbiddenError('discussion is not open on this activity');\n }\n if (!config.reactions && emoji !== null) {\n throw new ForbiddenError('reactions are disabled on this course');\n }\n if (comment.status === 'removed') {\n throw new ForbiddenError('a removed comment cannot be reacted to');\n }\n await this.repo.setReaction(orgId, commentId, actor.id, emoji);\n const stored = await this.repo.reactionsOf(orgId, [commentId], actor.id);\n return stored[commentId] ?? EMPTY_REACTIONS;\n }\n\n async approve(orgId: string, commentId: string, actor: Actor): Promise<Comment> {\n if (!this.isStaff(actor)) {\n throw new ForbiddenError('only a moderator may approve a comment');\n }\n const comment = await this.getComment(orgId, commentId);\n if (!comment) {\n throw new NotFoundError('Comment', commentId);\n }\n if (comment.status !== 'pending') {\n throw new ForbiddenError('only a pending comment can be approved');\n }\n return this.uow.run(async (scope) => {\n const updated = await scope.discussion.updateComment(orgId, commentId, {\n status: 'published',\n });\n if (!updated) {\n // getComment() proved it existed; a null here means it vanished mid-operation.\n // Roll back rather than emit an event for a transition that didn't happen.\n throw new NotFoundError('Comment', commentId);\n }\n await scope.outbox.append([\n discussionEvents.commentPublished.make({ orgId, data: updated }),\n ]);\n this.logger.info('comment published', { orgId, commentId });\n return updated;\n });\n }\n\n /** Loads the comment and dispatches to whichever transition applies — the\n * caller must not decide that itself. `approve` and `restore` still enforce\n * their own staff check; the check here covers the third path, where a\n * comment is already published and neither of them would run. */\n async publish(orgId: string, commentId: string, actor: Actor): Promise<Comment> {\n if (!this.isStaff(actor)) {\n throw new ForbiddenError('only a moderator may publish a comment');\n }\n const comment = await this.getComment(orgId, commentId);\n if (!comment) {\n throw new NotFoundError('Comment', commentId);\n }\n if (comment.status === 'pending') {\n return this.approve(orgId, commentId, actor);\n }\n if (comment.status === 'removed') {\n return this.restore(orgId, commentId, actor);\n }\n return comment;\n }\n\n async resolveReports(orgId: string, commentId: string, actor: Actor): Promise<void> {\n if (!this.isStaff(actor)) {\n throw new ForbiddenError('only a moderator may resolve a report');\n }\n const comment = await this.getComment(orgId, commentId);\n if (!comment) {\n throw new NotFoundError('Comment', commentId);\n }\n await this.repo.resolveReportsFor(orgId, commentId, new Date());\n this.logger.info('comment reports resolved', { orgId, commentId });\n }\n\n async reportComment(\n orgId: string,\n commentId: string,\n actor: Actor,\n reason: string,\n ): Promise<CommentReport> {\n const { config } = await this.loadWithConfig(orgId, commentId, actor);\n // Locked accepts reports — a locked activity can still hold something a\n // moderator needs to see. Hidden does not: nothing in it is being served,\n // so nobody is looking at a comment to flag.\n if (!config.enabled) {\n throw new ForbiddenError('discussion is not open on this activity');\n }\n const report: CommentReport = {\n id: genId('commentReport'),\n orgId,\n commentId,\n orgUserId: actor.id,\n reason,\n resolvedAt: null,\n createdAt: new Date(),\n };\n return this.uow.run(async (scope) => {\n const saved = await scope.discussion.insertReport(orgId, report);\n if (!saved) {\n // Already reported by this person. Return the report that actually\n // exists — a fabricated id resolves to nothing — and emit no second\n // event, which would double-count any threshold automation.\n const open = await scope.discussion.listOpenReports(orgId, [commentId]);\n const mine = open.find((r) => r.orgUserId === actor.id);\n if (mine) {\n return mine;\n }\n return report;\n }\n await scope.outbox.append([\n discussionEvents.commentReported.make({ orgId, data: saved }),\n ]);\n this.logger.info('comment reported', { orgId, commentId });\n return saved;\n });\n }\n\n async listComments(orgId: string, query: ListCommentsQuery): Promise<Page<CommentListItem>> {\n const page = await this.repo.listComments(orgId, query);\n const rows = page.rows.map((r) => r.comment);\n if (rows.length === 0) {\n return { ...page, rows: [] };\n }\n const reports = await this.repo.listOpenReports(\n orgId,\n rows.map((c) => c.id),\n );\n const authors = await this.repo.authorsOf(orgId, [\n ...new Set([...this.peopleIn(rows), ...reports.map((r) => r.orgUserId)]),\n ]);\n\n const byComment = new Map<string, CommentReport[]>();\n for (const report of reports) {\n byComment.set(report.commentId, [...(byComment.get(report.commentId) ?? []), report]);\n }\n\n return {\n ...page,\n rows: page.rows.map(({ comment, courseId, activityTitle }) => {\n const author = authors[comment.orgUserId];\n if (!author) {\n throw new NotFoundError('OrgUser', comment.orgUserId);\n }\n return {\n id: comment.id,\n parentId: comment.parentId,\n activityId: comment.activityId,\n activityTitle,\n courseId,\n body: comment.body,\n status: comment.status,\n author: this.toAuthor(author),\n authorEmail: author.email,\n removedBy: comment.removedBy,\n reports: (byComment.get(comment.id) ?? []).map((r) => {\n const reporter = authors[r.orgUserId];\n if (!reporter) {\n throw new NotFoundError('OrgUser', r.orgUserId);\n }\n return {\n reporter: this.toAuthor(reporter),\n reason: r.reason,\n createdAt: r.createdAt,\n };\n }),\n createdAt: comment.createdAt,\n updatedAt: comment.updatedAt,\n };\n }),\n };\n }\n\n getComment(orgId: string, commentId: string): Promise<Comment | null> {\n return this.repo.findComment(orgId, commentId);\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { Page } from "../types/shared.js";
|
|
2
|
+
import { CommentListItem, CommentReportSummary, CommentView, CommentsConfig, ListCommentsQuery, ReactionCounts, ReactionEmoji } from "../types/schemas/discussion.js";
|
|
3
|
+
import "../types/index.js";
|
|
4
|
+
export type { CommentListItem, CommentReportSummary, CommentView, CommentsConfig, ListCommentsQuery, Page, ReactionCounts, ReactionEmoji };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { EventOf, EventOfValues, RuntimeEventDefinition, VersionedDomainEvent } from "../shared/events.js";
|
|
2
|
+
import "../shared/ports.js";
|
|
3
|
+
//#region src/entitlements/events.d.ts
|
|
4
|
+
declare const entitlementEvents: {
|
|
5
|
+
entitlementCreated: RuntimeEventDefinition<VersionedDomainEvent<{
|
|
6
|
+
orgId: string;
|
|
7
|
+
id: string;
|
|
8
|
+
orgUserId: string;
|
|
9
|
+
contentId: string;
|
|
10
|
+
status: "active" | "revoked";
|
|
11
|
+
source: string;
|
|
12
|
+
grantedAt: Date;
|
|
13
|
+
expiresAt: Date | null;
|
|
14
|
+
}, "entitlement.created", 1>, {
|
|
15
|
+
orgId: string;
|
|
16
|
+
id: string;
|
|
17
|
+
orgUserId: string;
|
|
18
|
+
contentId: string;
|
|
19
|
+
status: "active" | "revoked";
|
|
20
|
+
source: string;
|
|
21
|
+
grantedAt: unknown;
|
|
22
|
+
expiresAt: unknown;
|
|
23
|
+
}>;
|
|
24
|
+
entitlementUpdated: RuntimeEventDefinition<VersionedDomainEvent<{
|
|
25
|
+
orgId: string;
|
|
26
|
+
id: string;
|
|
27
|
+
orgUserId: string;
|
|
28
|
+
contentId: string;
|
|
29
|
+
status: "active" | "revoked";
|
|
30
|
+
source: string;
|
|
31
|
+
grantedAt: Date;
|
|
32
|
+
expiresAt: Date | null;
|
|
33
|
+
}, "entitlement.updated", 1>, {
|
|
34
|
+
orgId: string;
|
|
35
|
+
id: string;
|
|
36
|
+
orgUserId: string;
|
|
37
|
+
contentId: string;
|
|
38
|
+
status: "active" | "revoked";
|
|
39
|
+
source: string;
|
|
40
|
+
grantedAt: unknown;
|
|
41
|
+
expiresAt: unknown;
|
|
42
|
+
}>;
|
|
43
|
+
entitlementDeleted: RuntimeEventDefinition<VersionedDomainEvent<{
|
|
44
|
+
orgId: string;
|
|
45
|
+
id: string;
|
|
46
|
+
orgUserId: string;
|
|
47
|
+
contentId: string;
|
|
48
|
+
status: "active" | "revoked";
|
|
49
|
+
source: string;
|
|
50
|
+
grantedAt: Date;
|
|
51
|
+
expiresAt: Date | null;
|
|
52
|
+
}, "entitlement.deleted", 1>, {
|
|
53
|
+
orgId: string;
|
|
54
|
+
id: string;
|
|
55
|
+
orgUserId: string;
|
|
56
|
+
contentId: string;
|
|
57
|
+
status: "active" | "revoked";
|
|
58
|
+
source: string;
|
|
59
|
+
grantedAt: unknown;
|
|
60
|
+
expiresAt: unknown;
|
|
61
|
+
}>;
|
|
62
|
+
entitlementExpired: RuntimeEventDefinition<VersionedDomainEvent<{
|
|
63
|
+
orgId: string;
|
|
64
|
+
id: string;
|
|
65
|
+
orgUserId: string;
|
|
66
|
+
contentId: string;
|
|
67
|
+
status: "active" | "revoked";
|
|
68
|
+
source: string;
|
|
69
|
+
grantedAt: Date;
|
|
70
|
+
expiresAt: Date | null;
|
|
71
|
+
}, "entitlement.expired", 1>, {
|
|
72
|
+
orgId: string;
|
|
73
|
+
id: string;
|
|
74
|
+
orgUserId: string;
|
|
75
|
+
contentId: string;
|
|
76
|
+
status: "active" | "revoked";
|
|
77
|
+
source: string;
|
|
78
|
+
grantedAt: unknown;
|
|
79
|
+
expiresAt: unknown;
|
|
80
|
+
}>;
|
|
81
|
+
};
|
|
82
|
+
type EntitlementCreated = EventOf<typeof entitlementEvents.entitlementCreated>;
|
|
83
|
+
type EntitlementUpdated = EventOf<typeof entitlementEvents.entitlementUpdated>;
|
|
84
|
+
type EntitlementDeleted = EventOf<typeof entitlementEvents.entitlementDeleted>;
|
|
85
|
+
type EntitlementExpired = EventOf<typeof entitlementEvents.entitlementExpired>;
|
|
86
|
+
type EntitlementEvent = EventOfValues<typeof entitlementEvents>;
|
|
87
|
+
//#endregion
|
|
88
|
+
export { EntitlementCreated, EntitlementDeleted, EntitlementEvent, EntitlementExpired, EntitlementUpdated, entitlementEvents };
|
|
89
|
+
//# sourceMappingURL=events.d.ts.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { entitlementSchema } from "../types/schemas/entitlements.js";
|
|
2
|
+
import { defineEvent } from "../shared/events.js";
|
|
3
|
+
//#region src/entitlements/events.ts
|
|
4
|
+
const entitlementEvents = {
|
|
5
|
+
entitlementCreated: defineEvent({
|
|
6
|
+
type: "entitlement.created",
|
|
7
|
+
version: 1,
|
|
8
|
+
data: entitlementSchema
|
|
9
|
+
}),
|
|
10
|
+
entitlementUpdated: defineEvent({
|
|
11
|
+
type: "entitlement.updated",
|
|
12
|
+
version: 1,
|
|
13
|
+
data: entitlementSchema
|
|
14
|
+
}),
|
|
15
|
+
entitlementDeleted: defineEvent({
|
|
16
|
+
type: "entitlement.deleted",
|
|
17
|
+
version: 1,
|
|
18
|
+
data: entitlementSchema
|
|
19
|
+
}),
|
|
20
|
+
entitlementExpired: defineEvent({
|
|
21
|
+
type: "entitlement.expired",
|
|
22
|
+
version: 1,
|
|
23
|
+
data: entitlementSchema
|
|
24
|
+
})
|
|
25
|
+
};
|
|
26
|
+
//#endregion
|
|
27
|
+
export { entitlementEvents };
|
|
28
|
+
|
|
29
|
+
//# sourceMappingURL=events.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"events.js","names":[],"sources":["../../src/entitlements/events.ts"],"sourcesContent":["import { entitlementSchema } from '../types/schemas/index.js';\nimport { defineEvent, type EventOf, type EventOfValues } from '../shared/ports.js';\n\nexport const entitlementEvents = {\n entitlementCreated: defineEvent({\n type: 'entitlement.created',\n version: 1,\n data: entitlementSchema,\n }),\n entitlementUpdated: defineEvent({\n type: 'entitlement.updated',\n version: 1,\n data: entitlementSchema,\n }),\n entitlementDeleted: defineEvent({\n type: 'entitlement.deleted',\n version: 1,\n data: entitlementSchema,\n }),\n entitlementExpired: defineEvent({\n type: 'entitlement.expired',\n version: 1,\n data: entitlementSchema,\n }),\n};\n\nexport type EntitlementCreated = EventOf<typeof entitlementEvents.entitlementCreated>;\nexport type EntitlementUpdated = EventOf<typeof entitlementEvents.entitlementUpdated>;\nexport type EntitlementDeleted = EventOf<typeof entitlementEvents.entitlementDeleted>;\nexport type EntitlementExpired = EventOf<typeof entitlementEvents.entitlementExpired>;\nexport type EntitlementEvent = EventOfValues<typeof entitlementEvents>;\n"],"mappings":";;;AAGA,MAAa,oBAAoB;CAC/B,oBAAoB,YAAY;EAC9B,MAAM;EACN,SAAS;EACT,MAAM;CACR,CAAC;CACD,oBAAoB,YAAY;EAC9B,MAAM;EACN,SAAS;EACT,MAAM;CACR,CAAC;CACD,oBAAoB,YAAY;EAC9B,MAAM;EACN,SAAS;EACT,MAAM;CACR,CAAC;CACD,oBAAoB,YAAY;EAC9B,MAAM;EACN,SAAS;EACT,MAAM;CACR,CAAC;AACH"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Page } from "../types/shared.js";
|
|
2
|
+
import { Entitlement, EntitlementStatus, EntitlementsQuery, GrantEntitlementInput } from "../types/schemas/entitlements.js";
|
|
3
|
+
import { EntitlementCreated, EntitlementDeleted, EntitlementEvent, EntitlementExpired, EntitlementUpdated, entitlementEvents } from "./events.js";
|
|
4
|
+
import "./model.js";
|
|
5
|
+
import { EntitlementsRepository, EntitlementsService } from "./ports.js";
|
|
6
|
+
import { EntitlementsServiceImpl } from "./service.js";
|
|
7
|
+
export { type Entitlement, type EntitlementCreated, type EntitlementDeleted, type EntitlementEvent, type EntitlementExpired, type EntitlementStatus, type EntitlementUpdated, type EntitlementsQuery, type EntitlementsRepository, type EntitlementsService, EntitlementsServiceImpl, type GrantEntitlementInput, type Page, entitlementEvents };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { Page } from "../types/shared.js";
|
|
2
|
+
import { Entitlement, EntitlementStatus, EntitlementsQuery, GrantEntitlementInput } from "../types/schemas/entitlements.js";
|
|
3
|
+
import "../types/index.js";
|
|
4
|
+
export type { Entitlement, EntitlementStatus, EntitlementsQuery, GrantEntitlementInput, Page };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Page } from "../types/shared.js";
|
|
2
|
+
import { Entitlement, EntitlementsQuery, GrantEntitlementInput } from "../types/schemas/entitlements.js";
|
|
3
|
+
import { OutboxAppender, UnitOfWork } from "../shared/ports.js";
|
|
4
|
+
import "./model.js";
|
|
5
|
+
//#region src/entitlements/ports.d.ts
|
|
6
|
+
interface EntitlementsService {
|
|
7
|
+
list(orgId: string, query: EntitlementsQuery): Promise<Page<Entitlement>>;
|
|
8
|
+
grant(orgId: string, input: GrantEntitlementInput): Promise<Entitlement>;
|
|
9
|
+
setStatus(orgId: string, id: string, status: 'active' | 'revoked'): Promise<Entitlement | null>;
|
|
10
|
+
/** Whether this org user currently holds active access to the course.
|
|
11
|
+
* The authority for course access — a reporting read must never be used to
|
|
12
|
+
* infer it. */
|
|
13
|
+
hasCourseAccess(orgId: string, orgUserId: string, courseId: string): Promise<boolean>;
|
|
14
|
+
}
|
|
15
|
+
interface EntitlementsRepository {
|
|
16
|
+
list(orgId: string, query: EntitlementsQuery): Promise<Page<Entitlement>>;
|
|
17
|
+
insert(orgId: string, input: GrantEntitlementInput): Promise<Entitlement>;
|
|
18
|
+
setStatus(orgId: string, id: string, status: 'active' | 'revoked'): Promise<Entitlement | null>;
|
|
19
|
+
/** Whether this org user currently holds active access to the course.
|
|
20
|
+
* The authority for course access — a reporting read must never be used to
|
|
21
|
+
* infer it. */
|
|
22
|
+
hasCourseAccess(orgId: string, orgUserId: string, courseId: string): Promise<boolean>;
|
|
23
|
+
}
|
|
24
|
+
/** Tx-scoped port bundle for this context's mutating use cases — every member
|
|
25
|
+
* is bound to the UnitOfWork's transaction, so the write and the event append
|
|
26
|
+
* commit or roll back as one. */
|
|
27
|
+
interface EntitlementsTxScope {
|
|
28
|
+
entitlements: EntitlementsRepository;
|
|
29
|
+
outbox: OutboxAppender;
|
|
30
|
+
}
|
|
31
|
+
type EntitlementsUnitOfWork = UnitOfWork<EntitlementsTxScope>;
|
|
32
|
+
//#endregion
|
|
33
|
+
export { EntitlementsRepository, EntitlementsService, EntitlementsTxScope, EntitlementsUnitOfWork };
|
|
34
|
+
//# sourceMappingURL=ports.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Page } from "../types/shared.js";
|
|
2
|
+
import { Logger } from "../types/ports.js";
|
|
3
|
+
import { Entitlement, EntitlementsQuery, GrantEntitlementInput } from "../types/schemas/entitlements.js";
|
|
4
|
+
import "../shared/ports.js";
|
|
5
|
+
import "./model.js";
|
|
6
|
+
import { EntitlementsRepository, EntitlementsService, EntitlementsUnitOfWork } from "./ports.js";
|
|
7
|
+
//#region src/entitlements/service.d.ts
|
|
8
|
+
type EntitlementsServiceParams = {
|
|
9
|
+
/** Read-only access (list) — runs outside any transaction. */
|
|
10
|
+
repo: EntitlementsRepository;
|
|
11
|
+
/** Atomic write scope: tx-bound repo + outbox appender. */
|
|
12
|
+
uow: EntitlementsUnitOfWork;
|
|
13
|
+
logger?: Logger;
|
|
14
|
+
};
|
|
15
|
+
declare class EntitlementsServiceImpl implements EntitlementsService {
|
|
16
|
+
private readonly repo;
|
|
17
|
+
private readonly uow;
|
|
18
|
+
private readonly logger;
|
|
19
|
+
constructor(params: EntitlementsServiceParams);
|
|
20
|
+
list(orgId: string, query: EntitlementsQuery): Promise<Page<Entitlement>>;
|
|
21
|
+
grant(orgId: string, input: GrantEntitlementInput): Promise<Entitlement>;
|
|
22
|
+
setStatus(orgId: string, id: string, status: 'active' | 'revoked'): Promise<Entitlement | null>;
|
|
23
|
+
hasCourseAccess(orgId: string, orgUserId: string, courseId: string): Promise<boolean>;
|
|
24
|
+
}
|
|
25
|
+
//#endregion
|
|
26
|
+
export { EntitlementsServiceImpl, EntitlementsServiceParams };
|
|
27
|
+
//# sourceMappingURL=service.d.ts.map
|