@studious-lms/server 1.4.5 → 1.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/lib/config/cors.d.ts.map +1 -1
  2. package/dist/lib/config/cors.js +8 -3
  3. package/dist/lib/config/cors.js.map +1 -1
  4. package/dist/models/comment.d.ts +163 -0
  5. package/dist/models/comment.d.ts.map +1 -1
  6. package/dist/models/comment.js +145 -3
  7. package/dist/models/comment.js.map +1 -1
  8. package/dist/models/worksheet.d.ts +2 -0
  9. package/dist/models/worksheet.d.ts.map +1 -1
  10. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  11. package/dist/pipelines/aiLabChat.js +4 -3
  12. package/dist/pipelines/aiLabChat.js.map +1 -1
  13. package/dist/routers/_app.d.ts +562 -8
  14. package/dist/routers/_app.d.ts.map +1 -1
  15. package/dist/routers/announcement.d.ts +8 -0
  16. package/dist/routers/announcement.d.ts.map +1 -1
  17. package/dist/routers/assignment.d.ts +230 -0
  18. package/dist/routers/assignment.d.ts.map +1 -1
  19. package/dist/routers/assignment.js +207 -2
  20. package/dist/routers/assignment.js.map +1 -1
  21. package/dist/routers/comment.d.ts +37 -0
  22. package/dist/routers/comment.d.ts.map +1 -1
  23. package/dist/routers/comment.js +14 -5
  24. package/dist/routers/comment.js.map +1 -1
  25. package/dist/routers/worksheet.d.ts +2 -0
  26. package/dist/routers/worksheet.d.ts.map +1 -1
  27. package/dist/services/comment.d.ts +28 -2
  28. package/dist/services/comment.d.ts.map +1 -1
  29. package/dist/services/comment.js +97 -19
  30. package/dist/services/comment.js.map +1 -1
  31. package/dist/services/worksheet.d.ts +2 -0
  32. package/dist/services/worksheet.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/prisma/migrations/20260502230500_assignment_submission_comments/migration.sql +60 -0
  35. package/prisma/schema.prisma +9 -0
  36. package/src/lib/config/cors.ts +8 -1
  37. package/src/models/comment.ts +148 -1
  38. package/src/pipelines/aiLabChat.ts +2 -1
  39. package/src/routers/assignment.ts +224 -1
  40. package/src/routers/comment.ts +19 -2
  41. package/src/services/comment.ts +113 -18
  42. package/tests/lib/cors.test.ts +13 -1
  43. package/tests/routers/assignment.test.ts +68 -2
  44. package/tests/routers/comment.test.ts +42 -1
  45. package/vercel.json +8 -0
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Comment service – get comments, replies, and reactions. Used for announcements and worksheets.
2
+ * Comment service – get comments, replies, and reactions.
3
3
  */
4
4
  import { TRPCError } from "@trpc/server";
5
+ import { prisma } from "../lib/prisma.js";
5
6
  import {
6
7
  findCommentById,
7
8
  findRepliesByCommentId,
@@ -9,12 +10,81 @@ import {
9
10
  findReactionByUserAndComment,
10
11
  upsertReaction,
11
12
  deleteReactionById,
12
- findCommentWithAnnouncement,
13
+ findCommentWithContext,
13
14
  getReactionCountsByCommentId,
14
15
  } from "../models/comment.js";
15
16
 
17
+ function getCommentClass(comment: any) {
18
+ return (
19
+ comment.announcement?.class ??
20
+ comment.assignment?.class ??
21
+ comment.submission?.assignment?.class ??
22
+ comment.studentQuestionProgress?.studentWorksheetResponse?.submission?.assignment?.class ??
23
+ comment.parentComment?.announcement?.class ??
24
+ comment.parentComment?.assignment?.class ??
25
+ comment.parentComment?.submission?.assignment?.class ??
26
+ comment.parentComment?.studentQuestionProgress?.studentWorksheetResponse?.submission?.assignment?.class ??
27
+ null
28
+ );
29
+ }
30
+
31
+ function getPrivateStudentId(comment: any) {
32
+ return (
33
+ comment.submission?.studentId ??
34
+ comment.studentQuestionProgress?.studentWorksheetResponse?.submission?.studentId ??
35
+ comment.studentQuestionProgress?.studentId ??
36
+ comment.parentComment?.submission?.studentId ??
37
+ comment.parentComment?.studentQuestionProgress?.studentWorksheetResponse?.submission?.studentId ??
38
+ comment.parentComment?.studentQuestionProgress?.studentId ??
39
+ null
40
+ );
41
+ }
42
+
43
+ function isClassTeacher(comment: any, userId: string) {
44
+ return getCommentClass(comment)?.teachers?.some((teacher: { id: string }) => teacher.id === userId) ?? false;
45
+ }
46
+
47
+ function isClassMember(comment: any, userId: string) {
48
+ const classData = getCommentClass(comment);
49
+ return (
50
+ classData?.teachers?.some((teacher: { id: string }) => teacher.id === userId) ||
51
+ classData?.students?.some((student: { id: string }) => student.id === userId) ||
52
+ false
53
+ );
54
+ }
55
+
56
+ function canReadComment(comment: any, userId: string) {
57
+ const privateStudentId = getPrivateStudentId(comment);
58
+ if (privateStudentId) {
59
+ return privateStudentId === userId || isClassTeacher(comment, userId);
60
+ }
61
+ return isClassMember(comment, userId);
62
+ }
63
+
64
+ function canDeleteComment(comment: any, userId: string) {
65
+ return comment.authorId === userId || isClassTeacher(comment, userId);
66
+ }
67
+
68
+ async function requireCommentAccess(commentId: string, userId: string) {
69
+ const comment = await findCommentWithContext(commentId);
70
+ if (!comment) {
71
+ throw new TRPCError({
72
+ code: "NOT_FOUND",
73
+ message: "Comment not found",
74
+ });
75
+ }
76
+ if (!canReadComment(comment, userId)) {
77
+ throw new TRPCError({
78
+ code: "FORBIDDEN",
79
+ message: "You do not have access to this comment",
80
+ });
81
+ }
82
+ return comment;
83
+ }
84
+
16
85
  /** Get a comment by ID. */
17
- export async function getComment(id: string) {
86
+ export async function getComment(userId: string, id: string) {
87
+ await requireCommentAccess(id, userId);
18
88
  const comment = await findCommentById(id);
19
89
  if (!comment) {
20
90
  throw new TRPCError({
@@ -26,7 +96,8 @@ export async function getComment(id: string) {
26
96
  }
27
97
 
28
98
  /** Get all replies to a comment. */
29
- export async function getReplies(commentId: string) {
99
+ export async function getReplies(userId: string, commentId: string) {
100
+ await requireCommentAccess(commentId, userId);
30
101
  return findRepliesByCommentId(commentId);
31
102
  }
32
103
 
@@ -36,26 +107,55 @@ export async function replyToComment(
36
107
  parentCommentId: string,
37
108
  content: string
38
109
  ) {
110
+ const parentComment = await requireCommentAccess(parentCommentId, userId);
39
111
  return createComment({
40
112
  parentCommentId,
41
113
  content,
42
114
  authorId: userId,
115
+ announcementId: parentComment.announcementId,
116
+ assignmentId: parentComment.assignmentId,
117
+ submissionId: parentComment.submissionId,
118
+ studentQuestionProgressId: parentComment.studentQuestionProgressId,
43
119
  });
44
120
  }
45
121
 
122
+ /** Update a comment authored by the user. */
123
+ export async function updateComment(userId: string, id: string, content: string) {
124
+ const comment = await requireCommentAccess(id, userId);
125
+ if (comment.authorId !== userId) {
126
+ throw new TRPCError({
127
+ code: "FORBIDDEN",
128
+ message: "Only the comment author can update this comment",
129
+ });
130
+ }
131
+
132
+ return prisma.comment.update({
133
+ where: { id },
134
+ data: { content },
135
+ });
136
+ }
137
+
138
+ /** Delete a comment authored by the user or moderated by a class teacher. */
139
+ export async function deleteComment(userId: string, id: string) {
140
+ const comment = await requireCommentAccess(id, userId);
141
+ if (!canDeleteComment(comment, userId)) {
142
+ throw new TRPCError({
143
+ code: "FORBIDDEN",
144
+ message: "Only the comment author or class teachers can delete comments",
145
+ });
146
+ }
147
+
148
+ await prisma.comment.delete({ where: { id } });
149
+ return { success: true };
150
+ }
151
+
46
152
  /** Add or update a reaction (emoji) on a comment. */
47
153
  export async function addReaction(
48
154
  userId: string,
49
155
  commentId: string,
50
156
  type: string
51
157
  ) {
52
- const comment = await findCommentById(commentId);
53
- if (!comment) {
54
- throw new TRPCError({
55
- code: "INTERNAL_SERVER_ERROR",
56
- message: "Unexpected error",
57
- });
58
- }
158
+ await requireCommentAccess(commentId, userId);
59
159
 
60
160
  const reaction = await upsertReaction({ userId, commentId, type });
61
161
  return { reaction };
@@ -63,6 +163,7 @@ export async function addReaction(
63
163
 
64
164
  /** Remove user's reaction from a comment. */
65
165
  export async function removeReaction(userId: string, commentId: string) {
166
+ await requireCommentAccess(commentId, userId);
66
167
  const reaction = await findReactionByUserAndComment(userId, commentId);
67
168
  if (!reaction) {
68
169
  throw new TRPCError({
@@ -75,13 +176,7 @@ export async function removeReaction(userId: string, commentId: string) {
75
176
  }
76
177
 
77
178
  export async function getReactions(userId: string, commentId: string) {
78
- const comment = await findCommentWithAnnouncement(commentId);
79
- if (!comment) {
80
- throw new TRPCError({
81
- code: "NOT_FOUND",
82
- message: "Comment not found",
83
- });
84
- }
179
+ await requireCommentAccess(commentId, userId);
85
180
 
86
181
  const reactionCounts = await getReactionCountsByCommentId(commentId);
87
182
  const userReaction = await findReactionByUserAndComment(userId, commentId);
@@ -64,7 +64,19 @@ describe('CORS configuration', () => {
64
64
  });
65
65
 
66
66
  expect(isAllowedOrigin('https://studious-git-configure-vercel-cors-studious-lms.vercel.app')).toBe(true);
67
- expect(isAllowedOrigin('https://malicious-studious-git-configure-vercel-cors-studious-lms.vercel.app')).toBe(false);
67
+ // Same shape on a non-.vercel.app host must not match the env-only pattern.
68
+ expect(isAllowedOrigin('https://studious-git-fake-studious-lms.evil.example')).toBe(false);
69
+ });
70
+
71
+ test('allows any https://*.vercel.app origin', () => {
72
+ const { isAllowedOrigin } = createCorsOriginMatcher({
73
+ NODE_ENV: 'production',
74
+ NEXT_PUBLIC_APP_URL: 'https://app.example',
75
+ });
76
+
77
+ expect(isAllowedOrigin('https://preview-abc123.vercel.app')).toBe(true);
78
+ expect(isAllowedOrigin('https://my-app-git-main-username.vercel.app')).toBe(true);
79
+ expect(isAllowedOrigin('http://preview.vercel.app')).toBe(false);
68
80
  });
69
81
 
70
82
  test('rejects missing and unconfigured origins instead of using a fallback', () => {
@@ -1,5 +1,5 @@
1
1
  import { test, expect, describe, beforeEach } from 'vitest';
2
- import { user1Caller, user2Caller } from '../setup';
2
+ import { user1Caller, user2Caller, user3Caller } from '../setup';
3
3
  import { expectForbidden, expectUnauth } from '../helpers';
4
4
 
5
5
  describe('Assignment Router', () => {
@@ -145,6 +145,73 @@ describe('Assignment Router', () => {
145
145
  });
146
146
  });
147
147
 
148
+ describe('comments', () => {
149
+ test('allows class members to add and read assignment-level comments', async () => {
150
+ const { comment } = await user2Caller.assignment.addComment({
151
+ classId: testClass.id,
152
+ assignmentId: testAssignment.id,
153
+ content: 'I have a question about this assignment.',
154
+ });
155
+
156
+ expect(comment.assignmentId).toBe(testAssignment.id);
157
+ expect(comment.submissionId).toBeNull();
158
+
159
+ const { comments } = await user1Caller.assignment.getComments({
160
+ classId: testClass.id,
161
+ assignmentId: testAssignment.id,
162
+ });
163
+
164
+ expect(comments.some((item) => item.id === comment.id)).toBe(true);
165
+ });
166
+
167
+ test('allows only the submission student and teachers to read private submission comments', async () => {
168
+ const submission = await user2Caller.assignment.getSubmission({
169
+ classId: testClass.id,
170
+ assignmentId: testAssignment.id,
171
+ });
172
+
173
+ const { comment } = await user1Caller.assignment.addSubmissionComment({
174
+ classId: testClass.id,
175
+ assignmentId: testAssignment.id,
176
+ submissionId: submission.id,
177
+ content: 'Private note for this submission.',
178
+ });
179
+
180
+ expect(comment.submissionId).toBe(submission.id);
181
+
182
+ const studentView = await user2Caller.assignment.getSubmissionComments({
183
+ classId: testClass.id,
184
+ assignmentId: testAssignment.id,
185
+ submissionId: submission.id,
186
+ });
187
+ expect(studentView.comments.some((item) => item.id === comment.id)).toBe(true);
188
+
189
+ const teacherView = await user1Caller.assignment.getSubmissionComments({
190
+ classId: testClass.id,
191
+ assignmentId: testAssignment.id,
192
+ submissionId: submission.id,
193
+ });
194
+ expect(teacherView.comments.some((item) => item.id === comment.id)).toBe(true);
195
+
196
+ const classmateProfile = await user3Caller.user.getProfile();
197
+ await user1Caller.class.addStudent({
198
+ classId: testClass.id,
199
+ studentId: classmateProfile.id,
200
+ });
201
+
202
+ await expect(
203
+ user3Caller.assignment.getSubmissionComments({
204
+ classId: testClass.id,
205
+ assignmentId: testAssignment.id,
206
+ submissionId: submission.id,
207
+ }),
208
+ ).rejects.toMatchObject({
209
+ code: 'NOT_FOUND',
210
+ message: 'Submission not found',
211
+ });
212
+ });
213
+ });
214
+
148
215
  // describe('getAll', () => {
149
216
  // test('should get all assignments for class', async () => {
150
217
  // const result = await user1Caller.assignment.get({
@@ -834,4 +901,3 @@ describe('Assignment Router', () => {
834
901
  });
835
902
  });
836
903
  });
837
-
@@ -1,5 +1,5 @@
1
1
  import { test, expect, describe, beforeAll } from 'vitest';
2
- import { user1Caller, user2Caller } from '../setup';
2
+ import { user1Caller, user2Caller, user3Caller } from '../setup';
3
3
 
4
4
  describe('Comment Router', () => {
5
5
  let testClass: any;
@@ -42,6 +42,47 @@ describe('Comment Router', () => {
42
42
  user1Caller.comment.get({ id: 'nonexistent-id' }),
43
43
  ).rejects.toThrow();
44
44
  });
45
+
46
+ test('should fail for users outside the comment class', async () => {
47
+ await expect(
48
+ user2Caller.comment.get({ id: commentId }),
49
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
50
+ });
51
+
52
+ test('should fail for the author after class removal', async () => {
53
+ const removalClass = await user1Caller.class.create({
54
+ name: 'Removed Comment Author Class',
55
+ subject: 'History',
56
+ section: '11th Grade',
57
+ });
58
+ const user3Profile = await user3Caller.user.getProfile();
59
+ await user1Caller.class.addStudent({
60
+ classId: removalClass.id,
61
+ studentId: user3Profile.id,
62
+ });
63
+
64
+ const { announcement: removalAnnouncement } = await user1Caller.announcement.create({
65
+ classId: removalClass.id,
66
+ remarks: 'Announcement with removable author comment',
67
+ });
68
+ const { comment } = await user3Caller.announcement.addComment({
69
+ announcementId: removalAnnouncement.id,
70
+ classId: removalClass.id,
71
+ content: 'I should lose access when removed.',
72
+ });
73
+
74
+ await user1Caller.class.removeMember({
75
+ classId: removalClass.id,
76
+ userId: user3Profile.id,
77
+ });
78
+
79
+ await expect(
80
+ user3Caller.comment.get({ id: comment.id }),
81
+ ).rejects.toThrow();
82
+ await expect(
83
+ user3Caller.comment.update({ id: comment.id, content: 'Updated after removal' }),
84
+ ).rejects.toThrow();
85
+ });
45
86
  });
46
87
 
47
88
  describe('replyToComment', () => {
package/vercel.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "fluid": true,
3
+ "functions": {
4
+ "src/index.ts": {
5
+ "maxDuration": 500
6
+ }
7
+ }
8
+ }