@studious-lms/server 1.11.0 → 1.13.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/.env.example +3 -0
- package/dist/index.js +40 -4
- package/dist/index.js.map +1 -1
- package/dist/lib/config/cors.d.ts.map +1 -1
- package/dist/lib/config/cors.js +7 -2
- package/dist/lib/config/cors.js.map +1 -1
- package/dist/lib/config/env.d.ts +8 -0
- package/dist/lib/config/env.d.ts.map +1 -1
- package/dist/lib/config/env.js +5 -2
- package/dist/lib/config/env.js.map +1 -1
- package/dist/lib/pusher.d.ts +2 -1
- package/dist/lib/pusher.d.ts.map +1 -1
- package/dist/lib/pusher.js +4 -3
- package/dist/lib/pusher.js.map +1 -1
- package/dist/models/agenda.d.ts +6 -0
- package/dist/models/agenda.d.ts.map +1 -1
- package/dist/models/announcement.d.ts +3 -0
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/assignment.d.ts +61 -0
- package/dist/models/assignment.d.ts.map +1 -1
- package/dist/models/auth.d.ts +6 -0
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/class.d.ts +28 -0
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +12 -3
- package/dist/models/class.js.map +1 -1
- package/dist/models/comment.d.ts +13 -0
- package/dist/models/comment.d.ts.map +1 -1
- package/dist/models/comment.js +3 -2
- package/dist/models/comment.js.map +1 -1
- package/dist/models/conversation.d.ts +17 -0
- package/dist/models/conversation.d.ts.map +1 -1
- package/dist/models/conversation.js +13 -2
- package/dist/models/conversation.js.map +1 -1
- package/dist/models/event.d.ts +12 -0
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/file.d.ts +38 -0
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/folder.d.ts +6 -0
- package/dist/models/folder.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +6 -0
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/newtonChat.d.ts +3 -0
- package/dist/models/newtonChat.d.ts.map +1 -1
- package/dist/models/school.d.ts +3 -0
- package/dist/models/school.d.ts.map +1 -1
- package/dist/models/section.d.ts +3 -0
- package/dist/models/section.d.ts.map +1 -1
- package/dist/models/worksheet.d.ts +62 -0
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +37 -7
- package/dist/models/worksheet.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
- package/dist/pipelines/gradeWorksheet.js +59 -14
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/routers/_app.d.ts +4641 -2285
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +6 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/admin.d.ts +190 -0
- package/dist/routers/admin.d.ts.map +1 -0
- package/dist/routers/admin.js +50 -0
- package/dist/routers/admin.js.map +1 -0
- package/dist/routers/agenda.d.ts +6 -0
- package/dist/routers/agenda.d.ts.map +1 -1
- package/dist/routers/announcement.d.ts +6 -0
- package/dist/routers/announcement.d.ts.map +1 -1
- package/dist/routers/announcement.js +6 -5
- package/dist/routers/announcement.js.map +1 -1
- package/dist/routers/assignment.d.ts +58 -0
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/assignment.js +4 -4
- package/dist/routers/assignment.js.map +1 -1
- package/dist/routers/class.d.ts +54 -0
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +5 -2
- package/dist/routers/class.js.map +1 -1
- package/dist/routers/comment.d.ts +26 -0
- package/dist/routers/comment.d.ts.map +1 -1
- package/dist/routers/comment.js +6 -3
- package/dist/routers/comment.js.map +1 -1
- package/dist/routers/discussion.d.ts +823 -0
- package/dist/routers/discussion.d.ts.map +1 -0
- package/dist/routers/discussion.js +151 -0
- package/dist/routers/discussion.js.map +1 -0
- package/dist/routers/event.d.ts +3 -0
- package/dist/routers/event.d.ts.map +1 -1
- package/dist/routers/file.d.ts +4 -0
- package/dist/routers/file.d.ts.map +1 -1
- package/dist/routers/school.d.ts +6 -0
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/worksheet.d.ts +6 -0
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +4 -2
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/seedDatabase.d.ts.map +1 -1
- package/dist/seedDatabase.js +1490 -1307
- package/dist/seedDatabase.js.map +1 -1
- package/dist/services/adminInstitution.d.ts +102 -0
- package/dist/services/adminInstitution.d.ts.map +1 -0
- package/dist/services/adminInstitution.js +117 -0
- package/dist/services/adminInstitution.js.map +1 -0
- package/dist/services/adminRunJob.d.ts +45 -0
- package/dist/services/adminRunJob.d.ts.map +1 -0
- package/dist/services/adminRunJob.js +41 -0
- package/dist/services/adminRunJob.js.map +1 -0
- package/dist/services/agenda.d.ts +6 -0
- package/dist/services/agenda.d.ts.map +1 -1
- package/dist/services/assignment.d.ts +50 -0
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +16 -9
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/class.d.ts +54 -0
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +20 -3
- package/dist/services/class.js.map +1 -1
- package/dist/services/comment.d.ts +20 -0
- package/dist/services/comment.d.ts.map +1 -1
- package/dist/services/comment.js +23 -2
- package/dist/services/comment.js.map +1 -1
- package/dist/services/conversation.d.ts.map +1 -1
- package/dist/services/conversation.js +9 -7
- package/dist/services/conversation.js.map +1 -1
- package/dist/services/discussion.d.ts +762 -0
- package/dist/services/discussion.d.ts.map +1 -0
- package/dist/services/discussion.js +625 -0
- package/dist/services/discussion.js.map +1 -0
- package/dist/services/event.d.ts +3 -0
- package/dist/services/event.d.ts.map +1 -1
- package/dist/services/file.d.ts +8 -0
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/school.d.ts +6 -0
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +27 -5
- package/dist/services/studentProgress.js.map +1 -1
- package/dist/services/studentProgressInferenceCache.d.ts +16 -0
- package/dist/services/studentProgressInferenceCache.d.ts.map +1 -0
- package/dist/services/studentProgressInferenceCache.js +43 -0
- package/dist/services/studentProgressInferenceCache.js.map +1 -0
- package/dist/services/worksheet.d.ts +6 -0
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +5 -5
- package/dist/services/worksheet.js.map +1 -1
- package/dist/trpc.d.ts +12 -0
- package/dist/trpc.d.ts.map +1 -1
- package/dist/trpc.js +16 -3
- package/dist/trpc.js.map +1 -1
- package/dist/utils/adminPanelAccess.d.ts +8 -0
- package/dist/utils/adminPanelAccess.d.ts.map +1 -0
- package/dist/utils/adminPanelAccess.js +44 -0
- package/dist/utils/adminPanelAccess.js.map +1 -0
- package/package.json +1 -1
- package/prisma/migrations/20260510183000_add_discussions/migration.sql +151 -0
- package/prisma/schema.prisma +98 -0
- package/src/index.ts +39 -2
- package/src/lib/config/cors.ts +5 -0
- package/src/lib/config/env.ts +3 -0
- package/src/lib/pusher.ts +2 -1
- package/src/models/class.ts +10 -1
- package/src/models/comment.ts +1 -0
- package/src/models/conversation.ts +12 -0
- package/src/models/worksheet.ts +39 -5
- package/src/pipelines/gradeWorksheet.ts +66 -15
- package/src/routers/_app.ts +4 -0
- package/src/routers/admin.ts +70 -0
- package/src/routers/announcement.ts +4 -3
- package/src/routers/assignment.ts +2 -2
- package/src/routers/class.ts +3 -0
- package/src/routers/comment.ts +5 -0
- package/src/routers/discussion.ts +205 -0
- package/src/routers/worksheet.ts +2 -0
- package/src/seedDatabase.ts +1713 -1361
- package/src/services/adminInstitution.ts +140 -0
- package/src/services/adminRunJob.ts +46 -0
- package/src/services/assignment.ts +19 -8
- package/src/services/class.ts +28 -1
- package/src/services/comment.ts +22 -0
- package/src/services/conversation.ts +9 -4
- package/src/services/discussion.ts +701 -0
- package/src/services/studentProgress.ts +80 -3
- package/src/services/studentProgressInferenceCache.ts +64 -0
- package/src/services/worksheet.ts +5 -2
- package/src/trpc.ts +15 -0
- package/src/utils/adminPanelAccess.ts +46 -0
- package/tests/integration/routers/conversation.test.ts +9 -7
- package/tests/integration/routers/discussion.test.ts +253 -0
- package/tests/unit/services/discussion.test.ts +356 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"discussion.d.ts","sourceRoot":"/","sources":["services/discussion.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="b10e68f8-0106-57a7-be41-a32938701565")}catch(e){}}();
|
|
3
|
+
import { TRPCError } from "@trpc/server";
|
|
4
|
+
import { prisma } from "../lib/prisma.js";
|
|
5
|
+
import { discussionChannel, pusher } from "../lib/pusher.js";
|
|
6
|
+
import { logger } from "../utils/logger.js";
|
|
7
|
+
import { classMemberOrSchoolAdminWhere, classTeacherOrSchoolAdminWhere } from "../models/access.js";
|
|
8
|
+
import { sendNotification } from "./notification.js";
|
|
9
|
+
const authorSelect = {
|
|
10
|
+
id: true,
|
|
11
|
+
username: true,
|
|
12
|
+
profile: {
|
|
13
|
+
select: {
|
|
14
|
+
displayName: true,
|
|
15
|
+
profilePicture: true,
|
|
16
|
+
profilePictureThumbnail: true,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
const attachmentSelect = {
|
|
21
|
+
id: true,
|
|
22
|
+
name: true,
|
|
23
|
+
type: true,
|
|
24
|
+
size: true,
|
|
25
|
+
path: true,
|
|
26
|
+
uploadedAt: true,
|
|
27
|
+
uploadStatus: true,
|
|
28
|
+
thumbnailId: true,
|
|
29
|
+
thumbnail: { select: { id: true, path: true } },
|
|
30
|
+
};
|
|
31
|
+
const reactionCounts = {
|
|
32
|
+
THUMBSUP: 0,
|
|
33
|
+
CELEBRATE: 0,
|
|
34
|
+
CARE: 0,
|
|
35
|
+
HEART: 0,
|
|
36
|
+
IDEA: 0,
|
|
37
|
+
HAPPY: 0,
|
|
38
|
+
};
|
|
39
|
+
const discussionInclude = {
|
|
40
|
+
author: { select: authorSelect },
|
|
41
|
+
attachments: { select: attachmentSelect },
|
|
42
|
+
tags: { select: { id: true, name: true, color: true } },
|
|
43
|
+
reactions: { select: { type: true, userId: true } },
|
|
44
|
+
_count: { select: { replies: { where: { deletedAt: null } } } },
|
|
45
|
+
};
|
|
46
|
+
const replyInclude = {
|
|
47
|
+
author: { select: authorSelect },
|
|
48
|
+
attachments: { select: attachmentSelect },
|
|
49
|
+
reactions: { select: { type: true, userId: true } },
|
|
50
|
+
};
|
|
51
|
+
const unclaimedAttachmentWhere = {
|
|
52
|
+
assignmentId: null,
|
|
53
|
+
submissionId: null,
|
|
54
|
+
annotationId: null,
|
|
55
|
+
classDraftId: null,
|
|
56
|
+
folderId: null,
|
|
57
|
+
messageId: null,
|
|
58
|
+
announcementId: null,
|
|
59
|
+
discussionId: null,
|
|
60
|
+
discussionReplyId: null,
|
|
61
|
+
};
|
|
62
|
+
function summarizeReactions(reactions, userId) {
|
|
63
|
+
const counts = { ...reactionCounts };
|
|
64
|
+
for (const reaction of reactions) {
|
|
65
|
+
counts[reaction.type] += 1;
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
counts,
|
|
69
|
+
userReaction: reactions.find((reaction) => reaction.userId === userId)?.type ?? null,
|
|
70
|
+
total: reactions.length,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function serializeDiscussion(discussion, userId, unreadCount = 0) {
|
|
74
|
+
return {
|
|
75
|
+
id: discussion.id,
|
|
76
|
+
classId: discussion.classId,
|
|
77
|
+
authorId: discussion.authorId,
|
|
78
|
+
title: discussion.title,
|
|
79
|
+
body: discussion.body,
|
|
80
|
+
type: discussion.type,
|
|
81
|
+
isPinned: discussion.isPinned,
|
|
82
|
+
isLocked: discussion.isLocked,
|
|
83
|
+
allowReplies: discussion.allowReplies,
|
|
84
|
+
createdAt: discussion.createdAt,
|
|
85
|
+
updatedAt: discussion.updatedAt,
|
|
86
|
+
lastActivityAt: discussion.lastActivityAt,
|
|
87
|
+
author: discussion.author,
|
|
88
|
+
attachments: discussion.attachments,
|
|
89
|
+
tags: discussion.tags,
|
|
90
|
+
replyCount: discussion._count.replies,
|
|
91
|
+
hasAcceptedAnswer: false,
|
|
92
|
+
reactions: summarizeReactions(discussion.reactions, userId),
|
|
93
|
+
unreadCount,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function serializeReply(reply, userId) {
|
|
97
|
+
return {
|
|
98
|
+
id: reply.id,
|
|
99
|
+
discussionId: reply.discussionId,
|
|
100
|
+
authorId: reply.authorId,
|
|
101
|
+
parentId: reply.parentId,
|
|
102
|
+
body: reply.deletedAt ? "[deleted]" : reply.body,
|
|
103
|
+
deletedAt: reply.deletedAt,
|
|
104
|
+
isAcceptedAnswer: reply.isAcceptedAnswer,
|
|
105
|
+
createdAt: reply.createdAt,
|
|
106
|
+
modifiedAt: reply.modifiedAt,
|
|
107
|
+
author: reply.author,
|
|
108
|
+
attachments: reply.deletedAt ? [] : reply.attachments,
|
|
109
|
+
reactions: summarizeReactions(reply.reactions, userId),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export function buildReplyTree(replies, userId) {
|
|
113
|
+
const byId = new Map();
|
|
114
|
+
const roots = [];
|
|
115
|
+
for (const reply of replies) {
|
|
116
|
+
byId.set(reply.id, { ...serializeReply(reply, userId), children: [] });
|
|
117
|
+
}
|
|
118
|
+
for (const reply of replies) {
|
|
119
|
+
const node = byId.get(reply.id);
|
|
120
|
+
if (reply.parentId && byId.has(reply.parentId)) {
|
|
121
|
+
byId.get(reply.parentId).children.push(node);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
roots.push(node);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return roots;
|
|
128
|
+
}
|
|
129
|
+
function isWithinAuthorEditWindow(createdAt) {
|
|
130
|
+
return Date.now() - createdAt.getTime() <= 24 * 60 * 60 * 1000;
|
|
131
|
+
}
|
|
132
|
+
export async function getDiscussionClassAccess(classId, userId) {
|
|
133
|
+
const classData = await prisma.class.findFirst({
|
|
134
|
+
where: { id: classId, ...classMemberOrSchoolAdminWhere(userId) },
|
|
135
|
+
select: {
|
|
136
|
+
id: true,
|
|
137
|
+
name: true,
|
|
138
|
+
discussionsEnabled: true,
|
|
139
|
+
studentsCanCreateDiscussions: true,
|
|
140
|
+
studentsCanReply: true,
|
|
141
|
+
students: { where: { id: userId }, select: { id: true } },
|
|
142
|
+
teachers: { where: { id: userId }, select: { id: true } },
|
|
143
|
+
school: {
|
|
144
|
+
select: {
|
|
145
|
+
memberships: {
|
|
146
|
+
where: { userId, role: "ADMIN", status: "ACTIVE" },
|
|
147
|
+
select: { userId: true },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
if (!classData) {
|
|
154
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "Not a member in this class" });
|
|
155
|
+
}
|
|
156
|
+
if (!classData.discussionsEnabled) {
|
|
157
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "Discussions are disabled for this class" });
|
|
158
|
+
}
|
|
159
|
+
const isTeacher = classData.teachers.length > 0 || Boolean(classData.school?.memberships.length);
|
|
160
|
+
const isStudent = classData.students.length > 0;
|
|
161
|
+
return { ...classData, isTeacher, isStudent };
|
|
162
|
+
}
|
|
163
|
+
async function getDiscussionForUser(discussionId, userId) {
|
|
164
|
+
const discussion = await prisma.discussion.findUnique({
|
|
165
|
+
where: { id: discussionId },
|
|
166
|
+
include: {
|
|
167
|
+
class: {
|
|
168
|
+
select: {
|
|
169
|
+
id: true,
|
|
170
|
+
discussionsEnabled: true,
|
|
171
|
+
studentsCanReply: true,
|
|
172
|
+
teachers: { where: { id: userId }, select: { id: true } },
|
|
173
|
+
students: { where: { id: userId }, select: { id: true } },
|
|
174
|
+
school: {
|
|
175
|
+
select: {
|
|
176
|
+
memberships: {
|
|
177
|
+
where: { userId, role: "ADMIN", status: "ACTIVE" },
|
|
178
|
+
select: { userId: true },
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
if (!discussion) {
|
|
187
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Discussion not found" });
|
|
188
|
+
}
|
|
189
|
+
const isTeacher = discussion.class.teachers.length > 0 || Boolean(discussion.class.school?.memberships.length);
|
|
190
|
+
const isStudent = discussion.class.students.length > 0;
|
|
191
|
+
if (!isTeacher && !isStudent) {
|
|
192
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "Not a member in this class" });
|
|
193
|
+
}
|
|
194
|
+
if (!discussion.class.discussionsEnabled) {
|
|
195
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "Discussions are disabled for this class" });
|
|
196
|
+
}
|
|
197
|
+
return { discussion, isTeacher, isStudent };
|
|
198
|
+
}
|
|
199
|
+
async function claimDiscussionAttachments(tx, userId, discussionId, attachmentIds = []) {
|
|
200
|
+
const uniqueIds = [...new Set(attachmentIds)];
|
|
201
|
+
if (!uniqueIds.length)
|
|
202
|
+
return;
|
|
203
|
+
const { count } = await tx.file.updateMany({
|
|
204
|
+
where: { id: { in: uniqueIds }, userId, uploadStatus: "COMPLETED", ...unclaimedAttachmentWhere },
|
|
205
|
+
data: { discussionId },
|
|
206
|
+
});
|
|
207
|
+
if (count !== uniqueIds.length) {
|
|
208
|
+
throw new TRPCError({ code: "BAD_REQUEST", message: "Some attachments are missing, unfinished, or unavailable" });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async function claimReplyAttachments(tx, userId, replyId, attachmentIds = []) {
|
|
212
|
+
const uniqueIds = [...new Set(attachmentIds)];
|
|
213
|
+
if (!uniqueIds.length)
|
|
214
|
+
return;
|
|
215
|
+
const { count } = await tx.file.updateMany({
|
|
216
|
+
where: { id: { in: uniqueIds }, userId, uploadStatus: "COMPLETED", ...unclaimedAttachmentWhere },
|
|
217
|
+
data: { discussionReplyId: replyId },
|
|
218
|
+
});
|
|
219
|
+
if (count !== uniqueIds.length) {
|
|
220
|
+
throw new TRPCError({ code: "BAD_REQUEST", message: "Some attachments are missing, unfinished, or unavailable" });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function triggerDiscussionEvent(classId, event, payload) {
|
|
224
|
+
pusher.trigger(discussionChannel(classId), event, payload).catch((error) => {
|
|
225
|
+
logger.error("Failed to trigger discussion pusher event", { event, classId, error });
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
async function notify(receiverId, senderId, title, content) {
|
|
229
|
+
if (!receiverId || receiverId === senderId)
|
|
230
|
+
return;
|
|
231
|
+
await sendNotification({ receiverId, title, content }).catch((error) => {
|
|
232
|
+
logger.error("Failed to send discussion notification", { receiverId, error });
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
async function notifyMentionedUsers(classId, senderId, body, link) {
|
|
236
|
+
const usernames = [...new Set(Array.from(body.matchAll(/(^|\s)@([a-zA-Z0-9_.-]{2,32})/g)).map((match) => match[2]))];
|
|
237
|
+
if (!usernames.length)
|
|
238
|
+
return;
|
|
239
|
+
const members = await prisma.user.findMany({
|
|
240
|
+
where: {
|
|
241
|
+
username: { in: usernames },
|
|
242
|
+
OR: [
|
|
243
|
+
{ teacherIn: { some: { id: classId } } },
|
|
244
|
+
{ studentIn: { some: { id: classId } } },
|
|
245
|
+
{ schoolMemberships: { some: { status: "ACTIVE", school: { classes: { some: { id: classId } } } } } },
|
|
246
|
+
],
|
|
247
|
+
},
|
|
248
|
+
select: { id: true },
|
|
249
|
+
});
|
|
250
|
+
await Promise.all(members.map((member) => notify(member.id, senderId, "You were mentioned in a discussion", link)));
|
|
251
|
+
}
|
|
252
|
+
async function unreadCountsForDiscussions(userId, discussionIds) {
|
|
253
|
+
if (!discussionIds.length)
|
|
254
|
+
return new Map();
|
|
255
|
+
const reads = await prisma.discussionRead.findMany({
|
|
256
|
+
where: { userId, discussionId: { in: discussionIds } },
|
|
257
|
+
select: { discussionId: true, lastReadAt: true },
|
|
258
|
+
});
|
|
259
|
+
const readMap = new Map(reads.map((read) => [read.discussionId, read.lastReadAt]));
|
|
260
|
+
const neverRead = discussionIds.filter((discussionId) => !readMap.has(discussionId));
|
|
261
|
+
const readPredicates = Array.from(readMap.entries()).map(([discussionId, lastReadAt]) => ({
|
|
262
|
+
discussionId,
|
|
263
|
+
createdAt: { gt: lastReadAt },
|
|
264
|
+
}));
|
|
265
|
+
const counts = new Map();
|
|
266
|
+
const [neverReadCounts, readCounts] = await Promise.all([
|
|
267
|
+
neverRead.length
|
|
268
|
+
? prisma.discussionReply.groupBy({
|
|
269
|
+
by: ["discussionId"],
|
|
270
|
+
where: { discussionId: { in: neverRead }, deletedAt: null, authorId: { not: userId } },
|
|
271
|
+
_count: true,
|
|
272
|
+
})
|
|
273
|
+
: Promise.resolve([]),
|
|
274
|
+
readPredicates.length
|
|
275
|
+
? prisma.discussionReply.groupBy({
|
|
276
|
+
by: ["discussionId"],
|
|
277
|
+
where: { deletedAt: null, authorId: { not: userId }, OR: readPredicates },
|
|
278
|
+
_count: true,
|
|
279
|
+
})
|
|
280
|
+
: Promise.resolve([]),
|
|
281
|
+
]);
|
|
282
|
+
for (const item of [...neverReadCounts, ...readCounts]) {
|
|
283
|
+
counts.set(item.discussionId, item._count);
|
|
284
|
+
}
|
|
285
|
+
return counts;
|
|
286
|
+
}
|
|
287
|
+
export async function listDiscussions(userId, input) {
|
|
288
|
+
await getDiscussionClassAccess(input.classId, userId);
|
|
289
|
+
const where = {
|
|
290
|
+
classId: input.classId,
|
|
291
|
+
...(input.tagId ? { tags: { some: { id: input.tagId } } } : {}),
|
|
292
|
+
...(input.type ? { type: input.type } : {}),
|
|
293
|
+
...(input.search?.trim()
|
|
294
|
+
? {
|
|
295
|
+
OR: [
|
|
296
|
+
{ title: { contains: input.search.trim(), mode: "insensitive" } },
|
|
297
|
+
{ body: { contains: input.search.trim(), mode: "insensitive" } },
|
|
298
|
+
],
|
|
299
|
+
}
|
|
300
|
+
: {}),
|
|
301
|
+
};
|
|
302
|
+
const orderBy = input.sort === "recent"
|
|
303
|
+
? [{ isPinned: "desc" }, { createdAt: "desc" }]
|
|
304
|
+
: input.sort === "top"
|
|
305
|
+
? [{ isPinned: "desc" }, { replies: { _count: "desc" } }, { lastActivityAt: "desc" }]
|
|
306
|
+
: [{ isPinned: "desc" }, { lastActivityAt: "desc" }];
|
|
307
|
+
const items = await prisma.discussion.findMany({
|
|
308
|
+
where,
|
|
309
|
+
include: discussionInclude,
|
|
310
|
+
orderBy,
|
|
311
|
+
cursor: input.cursor ? { id: input.cursor } : undefined,
|
|
312
|
+
skip: input.cursor ? 1 : 0,
|
|
313
|
+
take: input.limit + 1,
|
|
314
|
+
});
|
|
315
|
+
const next = items.length > input.limit ? items.pop() : undefined;
|
|
316
|
+
const unreadMap = await unreadCountsForDiscussions(userId, items.map((item) => item.id));
|
|
317
|
+
const accepted = await prisma.discussionReply.groupBy({
|
|
318
|
+
by: ["discussionId"],
|
|
319
|
+
where: { discussionId: { in: items.map((item) => item.id) }, isAcceptedAnswer: true, deletedAt: null },
|
|
320
|
+
});
|
|
321
|
+
const acceptedIds = new Set(accepted.map((item) => item.discussionId));
|
|
322
|
+
return {
|
|
323
|
+
items: items.map((item) => ({
|
|
324
|
+
...serializeDiscussion(item, userId, unreadMap.get(item.id) ?? 0),
|
|
325
|
+
hasAcceptedAnswer: acceptedIds.has(item.id),
|
|
326
|
+
})),
|
|
327
|
+
nextCursor: next?.id,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
export async function getDiscussion(userId, discussionId) {
|
|
331
|
+
const { discussion: accessDiscussion, isTeacher } = await getDiscussionForUser(discussionId, userId);
|
|
332
|
+
const [discussion, replies, myReaction] = await Promise.all([
|
|
333
|
+
prisma.discussion.findUniqueOrThrow({ where: { id: discussionId }, include: discussionInclude }),
|
|
334
|
+
prisma.discussionReply.findMany({ where: { discussionId }, include: replyInclude, orderBy: { createdAt: "asc" } }),
|
|
335
|
+
prisma.reaction.findUnique({ where: { userId_discussionId: { userId, discussionId } }, select: { type: true } }),
|
|
336
|
+
prisma.discussionRead.upsert({
|
|
337
|
+
where: { userId_discussionId: { userId, discussionId } },
|
|
338
|
+
update: { lastReadAt: new Date() },
|
|
339
|
+
create: { userId, discussionId },
|
|
340
|
+
}),
|
|
341
|
+
]);
|
|
342
|
+
const canAuthorEdit = accessDiscussion.authorId === userId && isWithinAuthorEditWindow(accessDiscussion.createdAt);
|
|
343
|
+
const canReply = isTeacher || (accessDiscussion.class.studentsCanReply && !accessDiscussion.isLocked && accessDiscussion.allowReplies);
|
|
344
|
+
return {
|
|
345
|
+
discussion: serializeDiscussion(discussion, userId),
|
|
346
|
+
replies: buildReplyTree(replies, userId),
|
|
347
|
+
myReaction: myReaction?.type ?? null,
|
|
348
|
+
canEdit: isTeacher || canAuthorEdit,
|
|
349
|
+
canPin: isTeacher,
|
|
350
|
+
canLock: isTeacher,
|
|
351
|
+
canDelete: isTeacher || accessDiscussion.authorId === userId,
|
|
352
|
+
canReply,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
export async function createDiscussion(userId, input) {
|
|
356
|
+
const classData = await getDiscussionClassAccess(input.classId, userId);
|
|
357
|
+
if (!classData.isTeacher && !classData.studentsCanCreateDiscussions) {
|
|
358
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "Students cannot create discussions in this class" });
|
|
359
|
+
}
|
|
360
|
+
if (input.type === "ANNOUNCEMENT" && !classData.isTeacher) {
|
|
361
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "Only teachers can create announcement discussions" });
|
|
362
|
+
}
|
|
363
|
+
const discussion = await prisma.$transaction(async (tx) => {
|
|
364
|
+
const created = await tx.discussion.create({
|
|
365
|
+
data: {
|
|
366
|
+
classId: input.classId,
|
|
367
|
+
authorId: userId,
|
|
368
|
+
title: input.title.trim(),
|
|
369
|
+
body: input.body.trim(),
|
|
370
|
+
type: input.type,
|
|
371
|
+
allowReplies: input.type !== "ANNOUNCEMENT",
|
|
372
|
+
tags: input.tagIds?.length ? { connect: input.tagIds.map((id) => ({ id })) } : undefined,
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
await claimDiscussionAttachments(tx, userId, created.id, input.attachmentIds);
|
|
376
|
+
return tx.discussion.findUniqueOrThrow({ where: { id: created.id }, include: discussionInclude });
|
|
377
|
+
});
|
|
378
|
+
triggerDiscussionEvent(input.classId, "discussion-created", { discussion: serializeDiscussion(discussion, userId) });
|
|
379
|
+
await notifyMentionedUsers(input.classId, userId, input.body, `/class/${input.classId}/discussions/${discussion.id}`);
|
|
380
|
+
return { discussion: serializeDiscussion(discussion, userId) };
|
|
381
|
+
}
|
|
382
|
+
export async function updateDiscussion(userId, input) {
|
|
383
|
+
const { discussion, isTeacher } = await getDiscussionForUser(input.discussionId, userId);
|
|
384
|
+
if (!isTeacher && !(discussion.authorId === userId && isWithinAuthorEditWindow(discussion.createdAt))) {
|
|
385
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "You cannot edit this discussion" });
|
|
386
|
+
}
|
|
387
|
+
if (input.type === "ANNOUNCEMENT" && !isTeacher) {
|
|
388
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "Only teachers can create announcement discussions" });
|
|
389
|
+
}
|
|
390
|
+
const updated = await prisma.discussion.update({
|
|
391
|
+
where: { id: input.discussionId },
|
|
392
|
+
data: {
|
|
393
|
+
...(input.title !== undefined ? { title: input.title.trim() } : {}),
|
|
394
|
+
...(input.body !== undefined ? { body: input.body.trim() } : {}),
|
|
395
|
+
...(input.type !== undefined ? { type: input.type } : {}),
|
|
396
|
+
...(input.allowReplies !== undefined ? { allowReplies: input.allowReplies } : {}),
|
|
397
|
+
...(input.tagIds ? { tags: { set: input.tagIds.map((id) => ({ id })) } } : {}),
|
|
398
|
+
},
|
|
399
|
+
include: discussionInclude,
|
|
400
|
+
});
|
|
401
|
+
triggerDiscussionEvent(updated.classId, "discussion-updated", { discussion: serializeDiscussion(updated, userId) });
|
|
402
|
+
return { discussion: serializeDiscussion(updated, userId) };
|
|
403
|
+
}
|
|
404
|
+
export async function deleteDiscussion(userId, discussionId) {
|
|
405
|
+
const { discussion, isTeacher } = await getDiscussionForUser(discussionId, userId);
|
|
406
|
+
if (!isTeacher && discussion.authorId !== userId) {
|
|
407
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "You cannot delete this discussion" });
|
|
408
|
+
}
|
|
409
|
+
await prisma.discussion.delete({ where: { id: discussionId } });
|
|
410
|
+
triggerDiscussionEvent(discussion.classId, "discussion-deleted", { discussionId });
|
|
411
|
+
return { success: true };
|
|
412
|
+
}
|
|
413
|
+
export async function replyToDiscussion(userId, input) {
|
|
414
|
+
const { discussion, isTeacher } = await getDiscussionForUser(input.discussionId, userId);
|
|
415
|
+
const canReply = isTeacher || (discussion.class.studentsCanReply && !discussion.isLocked && discussion.allowReplies);
|
|
416
|
+
if (!canReply) {
|
|
417
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "You cannot reply to this discussion" });
|
|
418
|
+
}
|
|
419
|
+
let parent = null;
|
|
420
|
+
if (input.parentReplyId) {
|
|
421
|
+
parent = await prisma.discussionReply.findFirst({
|
|
422
|
+
where: { id: input.parentReplyId, discussionId: input.discussionId },
|
|
423
|
+
select: { id: true, authorId: true },
|
|
424
|
+
});
|
|
425
|
+
if (!parent)
|
|
426
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Parent reply not found" });
|
|
427
|
+
}
|
|
428
|
+
const reply = await prisma.$transaction(async (tx) => {
|
|
429
|
+
const created = await tx.discussionReply.create({
|
|
430
|
+
data: {
|
|
431
|
+
discussionId: input.discussionId,
|
|
432
|
+
authorId: userId,
|
|
433
|
+
parentId: input.parentReplyId,
|
|
434
|
+
body: input.body.trim(),
|
|
435
|
+
},
|
|
436
|
+
});
|
|
437
|
+
await claimReplyAttachments(tx, userId, created.id, input.attachmentIds);
|
|
438
|
+
await tx.discussion.update({
|
|
439
|
+
where: { id: input.discussionId },
|
|
440
|
+
data: { lastActivityAt: new Date() },
|
|
441
|
+
});
|
|
442
|
+
return tx.discussionReply.findUniqueOrThrow({ where: { id: created.id }, include: replyInclude });
|
|
443
|
+
});
|
|
444
|
+
const link = `/class/${discussion.classId}/discussions/${discussion.id}#${reply.id}`;
|
|
445
|
+
await Promise.all([
|
|
446
|
+
notify(discussion.authorId, userId, "New reply to your discussion", link),
|
|
447
|
+
notify(parent?.authorId, userId, "New reply in a discussion thread", link),
|
|
448
|
+
notifyMentionedUsers(discussion.classId, userId, input.body, link),
|
|
449
|
+
]);
|
|
450
|
+
triggerDiscussionEvent(discussion.classId, "reply-created", { discussionId: discussion.id, reply: serializeReply(reply, userId) });
|
|
451
|
+
return { reply: serializeReply(reply, userId) };
|
|
452
|
+
}
|
|
453
|
+
export async function updateDiscussionReply(userId, input) {
|
|
454
|
+
const existing = await prisma.discussionReply.findUnique({ where: { id: input.replyId }, include: { discussion: true } });
|
|
455
|
+
if (!existing)
|
|
456
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Reply not found" });
|
|
457
|
+
const { isTeacher } = await getDiscussionForUser(existing.discussionId, userId);
|
|
458
|
+
if (!isTeacher && !(existing.authorId === userId && isWithinAuthorEditWindow(existing.createdAt))) {
|
|
459
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "You cannot edit this reply" });
|
|
460
|
+
}
|
|
461
|
+
const reply = await prisma.$transaction(async (tx) => {
|
|
462
|
+
await tx.file.updateMany({ where: { discussionReplyId: input.replyId }, data: { discussionReplyId: null } });
|
|
463
|
+
await claimReplyAttachments(tx, userId, input.replyId, input.attachmentIds);
|
|
464
|
+
return tx.discussionReply.update({
|
|
465
|
+
where: { id: input.replyId },
|
|
466
|
+
data: { body: input.body.trim(), deletedAt: null },
|
|
467
|
+
include: replyInclude,
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
triggerDiscussionEvent(existing.discussion.classId, "reply-updated", { discussionId: existing.discussionId, reply: serializeReply(reply, userId) });
|
|
471
|
+
return { reply: serializeReply(reply, userId) };
|
|
472
|
+
}
|
|
473
|
+
export async function deleteDiscussionReply(userId, replyId) {
|
|
474
|
+
const existing = await prisma.discussionReply.findUnique({ where: { id: replyId }, include: { discussion: true } });
|
|
475
|
+
if (!existing)
|
|
476
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Reply not found" });
|
|
477
|
+
const { isTeacher } = await getDiscussionForUser(existing.discussionId, userId);
|
|
478
|
+
if (!isTeacher && existing.authorId !== userId) {
|
|
479
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "You cannot delete this reply" });
|
|
480
|
+
}
|
|
481
|
+
const reply = await prisma.discussionReply.update({
|
|
482
|
+
where: { id: replyId },
|
|
483
|
+
data: { body: "[deleted]", deletedAt: new Date(), isAcceptedAnswer: false },
|
|
484
|
+
include: replyInclude,
|
|
485
|
+
});
|
|
486
|
+
triggerDiscussionEvent(existing.discussion.classId, "reply-deleted", { discussionId: existing.discussionId, reply: serializeReply(reply, userId) });
|
|
487
|
+
return { reply: serializeReply(reply, userId) };
|
|
488
|
+
}
|
|
489
|
+
export async function pinDiscussion(userId, discussionId, isPinned) {
|
|
490
|
+
const { discussion, isTeacher } = await getDiscussionForUser(discussionId, userId);
|
|
491
|
+
if (!isTeacher)
|
|
492
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "Only teachers can pin discussions" });
|
|
493
|
+
const updated = await prisma.discussion.update({ where: { id: discussionId }, data: { isPinned }, include: discussionInclude });
|
|
494
|
+
await notify(discussion.authorId, userId, isPinned ? "Your discussion was pinned" : "Your discussion was unpinned", `/class/${discussion.classId}/discussions/${discussionId}`);
|
|
495
|
+
triggerDiscussionEvent(discussion.classId, "discussion-updated", { discussion: serializeDiscussion(updated, userId) });
|
|
496
|
+
return { discussion: serializeDiscussion(updated, userId) };
|
|
497
|
+
}
|
|
498
|
+
export async function lockDiscussion(userId, discussionId, isLocked) {
|
|
499
|
+
const { discussion, isTeacher } = await getDiscussionForUser(discussionId, userId);
|
|
500
|
+
if (!isTeacher)
|
|
501
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "Only teachers can lock discussions" });
|
|
502
|
+
const updated = await prisma.discussion.update({ where: { id: discussionId }, data: { isLocked }, include: discussionInclude });
|
|
503
|
+
triggerDiscussionEvent(discussion.classId, "discussion-updated", { discussion: serializeDiscussion(updated, userId) });
|
|
504
|
+
return { discussion: serializeDiscussion(updated, userId) };
|
|
505
|
+
}
|
|
506
|
+
export async function markAcceptedAnswer(userId, replyId, accepted) {
|
|
507
|
+
const reply = await prisma.discussionReply.findUnique({ where: { id: replyId }, include: { discussion: true } });
|
|
508
|
+
if (!reply)
|
|
509
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Reply not found" });
|
|
510
|
+
const { isTeacher } = await getDiscussionForUser(reply.discussionId, userId);
|
|
511
|
+
if (reply.discussion.type !== "QUESTION") {
|
|
512
|
+
throw new TRPCError({ code: "BAD_REQUEST", message: "Only question discussions can have accepted answers" });
|
|
513
|
+
}
|
|
514
|
+
if (!isTeacher && reply.discussion.authorId !== userId) {
|
|
515
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "Only the discussion author or a teacher can mark an accepted answer" });
|
|
516
|
+
}
|
|
517
|
+
const updated = await prisma.$transaction(async (tx) => {
|
|
518
|
+
if (accepted) {
|
|
519
|
+
await tx.discussionReply.updateMany({ where: { discussionId: reply.discussionId, isAcceptedAnswer: true }, data: { isAcceptedAnswer: false } });
|
|
520
|
+
}
|
|
521
|
+
return tx.discussionReply.update({ where: { id: replyId }, data: { isAcceptedAnswer: accepted }, include: replyInclude });
|
|
522
|
+
});
|
|
523
|
+
await notify(reply.authorId, userId, accepted ? "Your answer was accepted" : "Your answer was unmarked", `/class/${reply.discussion.classId}/discussions/${reply.discussionId}#${replyId}`);
|
|
524
|
+
triggerDiscussionEvent(reply.discussion.classId, "reply-updated", { discussionId: reply.discussionId, reply: serializeReply(updated, userId) });
|
|
525
|
+
return { reply: serializeReply(updated, userId) };
|
|
526
|
+
}
|
|
527
|
+
export async function markDiscussionRead(userId, discussionId) {
|
|
528
|
+
await getDiscussionForUser(discussionId, userId);
|
|
529
|
+
return prisma.discussionRead.upsert({
|
|
530
|
+
where: { userId_discussionId: { userId, discussionId } },
|
|
531
|
+
update: { lastReadAt: new Date() },
|
|
532
|
+
create: { userId, discussionId },
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
export async function getUnreadCounts(userId, classIds) {
|
|
536
|
+
const allowedClasses = await prisma.class.findMany({
|
|
537
|
+
where: { id: { in: classIds }, discussionsEnabled: true, ...classMemberOrSchoolAdminWhere(userId) },
|
|
538
|
+
select: { id: true },
|
|
539
|
+
});
|
|
540
|
+
const result = {};
|
|
541
|
+
for (const cls of allowedClasses) {
|
|
542
|
+
const discussions = await prisma.discussion.findMany({
|
|
543
|
+
where: { classId: cls.id },
|
|
544
|
+
select: { id: true },
|
|
545
|
+
});
|
|
546
|
+
const countMap = await unreadCountsForDiscussions(userId, discussions.map((discussion) => discussion.id));
|
|
547
|
+
const perDiscussion = Array.from(countMap.entries())
|
|
548
|
+
.map(([discussionId, count]) => ({ discussionId, count }))
|
|
549
|
+
.filter((item) => item.count > 0);
|
|
550
|
+
result[cls.id] = {
|
|
551
|
+
total: perDiscussion.reduce((sum, item) => sum + item.count, 0),
|
|
552
|
+
perDiscussion,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
return result;
|
|
556
|
+
}
|
|
557
|
+
export async function toggleDiscussionReaction(userId, input) {
|
|
558
|
+
if (!input.discussionId && !input.discussionReplyId) {
|
|
559
|
+
throw new TRPCError({ code: "BAD_REQUEST", message: "A discussion or reply is required" });
|
|
560
|
+
}
|
|
561
|
+
if (input.discussionId && input.discussionReplyId) {
|
|
562
|
+
throw new TRPCError({ code: "BAD_REQUEST", message: "React to either a discussion or a reply" });
|
|
563
|
+
}
|
|
564
|
+
const target = input.discussionId
|
|
565
|
+
? await getDiscussionForUser(input.discussionId, userId).then(({ discussion }) => ({ classId: discussion.classId, discussionId: discussion.id }))
|
|
566
|
+
: await prisma.discussionReply.findUnique({ where: { id: input.discussionReplyId }, include: { discussion: true } }).then(async (reply) => {
|
|
567
|
+
if (!reply)
|
|
568
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Reply not found" });
|
|
569
|
+
await getDiscussionForUser(reply.discussionId, userId);
|
|
570
|
+
return { classId: reply.discussion.classId, discussionId: reply.discussionId };
|
|
571
|
+
});
|
|
572
|
+
const where = input.discussionId
|
|
573
|
+
? { userId_discussionId: { userId, discussionId: input.discussionId } }
|
|
574
|
+
: { userId_discussionReplyId: { userId, discussionReplyId: input.discussionReplyId } };
|
|
575
|
+
const existing = await prisma.reaction.findUnique({ where });
|
|
576
|
+
if (existing?.type === input.type) {
|
|
577
|
+
await prisma.reaction.delete({ where: { id: existing.id } });
|
|
578
|
+
triggerDiscussionEvent(target.classId, "reaction-removed", { discussionId: target.discussionId, discussionReplyId: input.discussionReplyId, userId });
|
|
579
|
+
return { reaction: null };
|
|
580
|
+
}
|
|
581
|
+
const reaction = input.discussionId
|
|
582
|
+
? await prisma.reaction.upsert({
|
|
583
|
+
where: { userId_discussionId: { userId, discussionId: input.discussionId } },
|
|
584
|
+
update: { type: input.type },
|
|
585
|
+
create: { userId, discussionId: input.discussionId, type: input.type },
|
|
586
|
+
})
|
|
587
|
+
: await prisma.reaction.upsert({
|
|
588
|
+
where: { userId_discussionReplyId: { userId, discussionReplyId: input.discussionReplyId } },
|
|
589
|
+
update: { type: input.type },
|
|
590
|
+
create: { userId, discussionReplyId: input.discussionReplyId, type: input.type },
|
|
591
|
+
});
|
|
592
|
+
triggerDiscussionEvent(target.classId, "reaction-added", { discussionId: target.discussionId, discussionReplyId: input.discussionReplyId, reaction });
|
|
593
|
+
return { reaction };
|
|
594
|
+
}
|
|
595
|
+
export async function listDiscussionTags(userId, classId) {
|
|
596
|
+
await getDiscussionClassAccess(classId, userId);
|
|
597
|
+
return prisma.discussionTag.findMany({ where: { classId }, orderBy: { name: "asc" } });
|
|
598
|
+
}
|
|
599
|
+
export async function createDiscussionTag(userId, input) {
|
|
600
|
+
const isTeacher = await prisma.class.findFirst({ where: { id: input.classId, ...classTeacherOrSchoolAdminWhere(userId) }, select: { id: true, discussionsEnabled: true } });
|
|
601
|
+
if (!isTeacher?.discussionsEnabled)
|
|
602
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "Only teachers can manage discussion tags" });
|
|
603
|
+
return prisma.discussionTag.create({ data: { classId: input.classId, name: input.name.trim(), color: input.color } });
|
|
604
|
+
}
|
|
605
|
+
export async function updateDiscussionTag(userId, input) {
|
|
606
|
+
const tag = await prisma.discussionTag.findUnique({ where: { id: input.tagId }, select: { classId: true } });
|
|
607
|
+
if (!tag)
|
|
608
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Tag not found" });
|
|
609
|
+
const isTeacher = await prisma.class.findFirst({ where: { id: tag.classId, ...classTeacherOrSchoolAdminWhere(userId) }, select: { id: true, discussionsEnabled: true } });
|
|
610
|
+
if (!isTeacher?.discussionsEnabled)
|
|
611
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "Only teachers can manage discussion tags" });
|
|
612
|
+
return prisma.discussionTag.update({ where: { id: input.tagId }, data: { name: input.name?.trim(), color: input.color } });
|
|
613
|
+
}
|
|
614
|
+
export async function deleteDiscussionTag(userId, tagId) {
|
|
615
|
+
const tag = await prisma.discussionTag.findUnique({ where: { id: tagId }, select: { classId: true } });
|
|
616
|
+
if (!tag)
|
|
617
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Tag not found" });
|
|
618
|
+
const isTeacher = await prisma.class.findFirst({ where: { id: tag.classId, ...classTeacherOrSchoolAdminWhere(userId) }, select: { id: true, discussionsEnabled: true } });
|
|
619
|
+
if (!isTeacher?.discussionsEnabled)
|
|
620
|
+
throw new TRPCError({ code: "FORBIDDEN", message: "Only teachers can manage discussion tags" });
|
|
621
|
+
await prisma.discussionTag.delete({ where: { id: tagId } });
|
|
622
|
+
return { success: true };
|
|
623
|
+
}
|
|
624
|
+
//# sourceMappingURL=discussion.js.map
|
|
625
|
+
//# debugId=b10e68f8-0106-57a7-be41-a32938701565
|