@studious-lms/server 1.4.6 → 1.4.7
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/dist/models/access.d.ts +112 -0
- package/dist/models/access.d.ts.map +1 -0
- package/dist/models/access.js +49 -0
- package/dist/models/access.js.map +1 -0
- package/dist/models/announcement.d.ts +17 -2
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/announcement.js +21 -4
- package/dist/models/announcement.js.map +1 -1
- package/dist/models/assignment.d.ts +37 -37
- package/dist/models/assignment.d.ts.map +1 -1
- package/dist/models/assignment.js +15 -15
- package/dist/models/assignment.js.map +1 -1
- package/dist/models/attendance.d.ts +1 -1
- package/dist/models/attendance.d.ts.map +1 -1
- package/dist/models/attendance.js +6 -5
- package/dist/models/attendance.js.map +1 -1
- package/dist/models/auth.d.ts +5 -5
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/auth.js +7 -11
- package/dist/models/auth.js.map +1 -1
- package/dist/models/class.d.ts +26 -26
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +7 -11
- package/dist/models/class.js.map +1 -1
- package/dist/models/comment.d.ts +134 -70
- package/dist/models/comment.d.ts.map +1 -1
- package/dist/models/comment.js +30 -42
- package/dist/models/comment.js.map +1 -1
- package/dist/models/conversation.d.ts +7 -7
- package/dist/models/event.d.ts +52 -3
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/event.js +56 -6
- package/dist/models/event.js.map +1 -1
- package/dist/models/file.d.ts +178 -103
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/file.js +54 -14
- package/dist/models/file.js.map +1 -1
- package/dist/models/folder.d.ts +22 -22
- package/dist/models/folder.d.ts.map +1 -1
- package/dist/models/folder.js +5 -4
- package/dist/models/folder.js.map +1 -1
- package/dist/models/labChat.d.ts +20 -20
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/labChat.js +8 -10
- package/dist/models/labChat.js.map +1 -1
- package/dist/models/marketing.d.ts +1 -1
- package/dist/models/message.d.ts +6 -6
- package/dist/models/newtonChat.d.ts +5 -5
- package/dist/models/school.d.ts +361 -0
- package/dist/models/school.d.ts.map +1 -0
- package/dist/models/school.js +314 -0
- package/dist/models/school.js.map +1 -0
- package/dist/models/section.d.ts +1 -1
- package/dist/models/section.d.ts.map +1 -1
- package/dist/models/section.js +5 -4
- package/dist/models/section.js.map +1 -1
- package/dist/models/user.d.ts +4 -4
- package/dist/models/worksheet.d.ts +65 -65
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +6 -14
- package/dist/models/worksheet.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts +2 -2
- package/dist/pipelines/pdfToWorksheet.d.ts +1 -1
- package/dist/routers/_app.d.ts +2855 -2051
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +4 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/announcement.d.ts +5 -5
- package/dist/routers/announcement.d.ts.map +1 -1
- package/dist/routers/announcement.js +25 -5
- package/dist/routers/announcement.js.map +1 -1
- package/dist/routers/assignment.d.ts +44 -44
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/assignment.js +24 -11
- package/dist/routers/assignment.js.map +1 -1
- package/dist/routers/class.d.ts +21 -20
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +3 -2
- package/dist/routers/class.js.map +1 -1
- package/dist/routers/comment.d.ts +5 -5
- package/dist/routers/conversation.d.ts +6 -6
- package/dist/routers/event.d.ts +21 -2
- package/dist/routers/event.d.ts.map +1 -1
- package/dist/routers/file.d.ts +2 -2
- package/dist/routers/folder.d.ts +24 -24
- package/dist/routers/labChat.d.ts +7 -7
- package/dist/routers/school.d.ts +384 -0
- package/dist/routers/school.d.ts.map +1 -0
- package/dist/routers/school.js +79 -0
- package/dist/routers/school.js.map +1 -0
- package/dist/routers/search.d.ts +1 -1
- package/dist/routers/worksheet.d.ts +21 -21
- package/dist/services/announcement.d.ts.map +1 -1
- package/dist/services/announcement.js +10 -6
- package/dist/services/announcement.js.map +1 -1
- package/dist/services/assignment.d.ts +36 -36
- package/dist/services/class.d.ts +20 -19
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +22 -3
- package/dist/services/class.js.map +1 -1
- package/dist/services/comment.d.ts +5 -5
- package/dist/services/comment.d.ts.map +1 -1
- package/dist/services/comment.js +8 -3
- package/dist/services/comment.js.map +1 -1
- package/dist/services/conversation.d.ts +6 -6
- package/dist/services/event.d.ts +21 -2
- package/dist/services/event.d.ts.map +1 -1
- package/dist/services/event.js +11 -7
- package/dist/services/event.js.map +1 -1
- package/dist/services/file.d.ts +2 -2
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/file.js +17 -8
- package/dist/services/file.js.map +1 -1
- package/dist/services/folder.d.ts +24 -24
- package/dist/services/labChat.d.ts +7 -7
- package/dist/services/school.d.ts +299 -0
- package/dist/services/school.d.ts.map +1 -0
- package/dist/services/school.js +225 -0
- package/dist/services/school.js.map +1 -0
- package/dist/services/worksheet.d.ts +21 -21
- package/package.json +1 -1
- package/prisma/migrations/20260504071500_school_memberships/migration.sql +81 -0
- package/prisma/schema.prisma +66 -2
- package/src/models/access.ts +48 -0
- package/src/models/announcement.ts +19 -2
- package/src/models/assignment.ts +17 -13
- package/src/models/attendance.ts +4 -3
- package/src/models/auth.ts +8 -9
- package/src/models/class.ts +8 -9
- package/src/models/comment.ts +29 -40
- package/src/models/event.ts +54 -4
- package/src/models/file.ts +53 -12
- package/src/models/folder.ts +3 -2
- package/src/models/labChat.ts +9 -8
- package/src/models/school.ts +361 -0
- package/src/models/section.ts +3 -2
- package/src/models/worksheet.ts +7 -12
- package/src/routers/_app.ts +2 -0
- package/src/routers/announcement.ts +30 -4
- package/src/routers/assignment.ts +27 -9
- package/src/routers/class.ts +1 -0
- package/src/routers/school.ts +122 -0
- package/src/services/announcement.ts +12 -4
- package/src/services/class.ts +25 -2
- package/src/services/comment.ts +10 -1
- package/src/services/event.ts +23 -6
- package/src/services/file.ts +20 -6
- package/src/services/school.ts +308 -0
|
@@ -157,11 +157,15 @@ export async function updateAnnouncementRecord(
|
|
|
157
157
|
|
|
158
158
|
const isCreator = announcement.teacherId === userId;
|
|
159
159
|
const isClassTeacher = announcement.class.teachers.some((t) => t.id === userId);
|
|
160
|
+
const isSchoolAdmin = Boolean(
|
|
161
|
+
announcement.class.school?.memberships.some((admin) => admin.userId === userId) ||
|
|
162
|
+
announcement.class.school?.users.some((admin) => admin.id === userId)
|
|
163
|
+
);
|
|
160
164
|
|
|
161
|
-
if (!isCreator && !isClassTeacher) {
|
|
165
|
+
if (!isCreator && !isClassTeacher && !isSchoolAdmin) {
|
|
162
166
|
throw new TRPCError({
|
|
163
167
|
code: "FORBIDDEN",
|
|
164
|
-
message: "Only the announcement creator
|
|
168
|
+
message: "Only the announcement creator, class teachers, or school admins can update announcements",
|
|
165
169
|
});
|
|
166
170
|
}
|
|
167
171
|
|
|
@@ -277,11 +281,15 @@ export async function deleteAnnouncementRecord(userId: string, id: string, class
|
|
|
277
281
|
|
|
278
282
|
const isCreator = announcement.teacherId === userId;
|
|
279
283
|
const isClassTeacher = announcement.class.teachers.some((t) => t.id === userId);
|
|
284
|
+
const isSchoolAdmin = Boolean(
|
|
285
|
+
announcement.class.school?.memberships.some((admin) => admin.userId === userId) ||
|
|
286
|
+
announcement.class.school?.users.some((admin) => admin.id === userId)
|
|
287
|
+
);
|
|
280
288
|
|
|
281
|
-
if (!isCreator && !isClassTeacher) {
|
|
289
|
+
if (!isCreator && !isClassTeacher && !isSchoolAdmin) {
|
|
282
290
|
throw new TRPCError({
|
|
283
291
|
code: "FORBIDDEN",
|
|
284
|
-
message: "Only the announcement creator
|
|
292
|
+
message: "Only the announcement creator, class teachers, or school admins can delete announcements",
|
|
285
293
|
});
|
|
286
294
|
}
|
|
287
295
|
|
package/src/services/class.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
findFullExportableClass,
|
|
26
26
|
createClassByImport,
|
|
27
27
|
} from "../models/class.js";
|
|
28
|
+
import { listUserSchoolMemberships } from "../models/school.js";
|
|
28
29
|
import { Class, Folder } from "@prisma/client";
|
|
29
30
|
|
|
30
31
|
/** Get all classes where user is teacher or student, with assignments and members. */
|
|
@@ -121,9 +122,29 @@ export async function createClass(
|
|
|
121
122
|
section: string;
|
|
122
123
|
subject: string;
|
|
123
124
|
color?: string;
|
|
125
|
+
schoolId?: string;
|
|
124
126
|
}
|
|
125
127
|
) {
|
|
126
|
-
const { students, teachers, name, section, subject, color } = input;
|
|
128
|
+
const { students, teachers, name, section, subject, color, schoolId } = input;
|
|
129
|
+
|
|
130
|
+
if (schoolId) {
|
|
131
|
+
const memberships = await listUserSchoolMemberships(userId);
|
|
132
|
+
const membership = memberships.find((item) => item.schoolId === schoolId);
|
|
133
|
+
|
|
134
|
+
if (!membership) {
|
|
135
|
+
throw new TRPCError({
|
|
136
|
+
code: "FORBIDDEN",
|
|
137
|
+
message: "You are not a member of this school",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (membership.role === "STUDENT") {
|
|
142
|
+
throw new TRPCError({
|
|
143
|
+
code: "FORBIDDEN",
|
|
144
|
+
message: "Students cannot create school classes",
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
127
148
|
|
|
128
149
|
if (teachers && teachers.length > 0 && students && students.length > 0) {
|
|
129
150
|
return prisma.class.create({
|
|
@@ -132,6 +153,7 @@ export async function createClass(
|
|
|
132
153
|
section,
|
|
133
154
|
subject,
|
|
134
155
|
color,
|
|
156
|
+
schoolId,
|
|
135
157
|
teachers: { connect: teachers.map((id) => ({ id })) },
|
|
136
158
|
students: { connect: students.map((id) => ({ id })) },
|
|
137
159
|
},
|
|
@@ -145,6 +167,7 @@ export async function createClass(
|
|
|
145
167
|
section,
|
|
146
168
|
subject,
|
|
147
169
|
color,
|
|
170
|
+
schoolId,
|
|
148
171
|
teachers: { connect: { id: userId } },
|
|
149
172
|
},
|
|
150
173
|
});
|
|
@@ -629,4 +652,4 @@ export async function importClass(classId: string, userId: string, year: number,
|
|
|
629
652
|
cause: err,
|
|
630
653
|
});
|
|
631
654
|
}
|
|
632
|
-
}
|
|
655
|
+
}
|
package/src/services/comment.ts
CHANGED
|
@@ -41,7 +41,14 @@ function getPrivateStudentId(comment: any) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
function isClassTeacher(comment: any, userId: string) {
|
|
44
|
-
|
|
44
|
+
const classData = getCommentClass(comment);
|
|
45
|
+
return Boolean(
|
|
46
|
+
classData?.teachers?.some((teacher: { id: string }) => teacher.id === userId) ||
|
|
47
|
+
classData?.school?.memberships?.some(
|
|
48
|
+
(admin: { userId: string }) => admin.userId === userId
|
|
49
|
+
) ||
|
|
50
|
+
classData?.school?.users?.some((admin: { id: string }) => admin.id === userId)
|
|
51
|
+
);
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
function isClassMember(comment: any, userId: string) {
|
|
@@ -49,6 +56,8 @@ function isClassMember(comment: any, userId: string) {
|
|
|
49
56
|
return (
|
|
50
57
|
classData?.teachers?.some((teacher: { id: string }) => teacher.id === userId) ||
|
|
51
58
|
classData?.students?.some((student: { id: string }) => student.id === userId) ||
|
|
59
|
+
classData?.school?.memberships?.some((admin: { userId: string }) => admin.userId === userId) ||
|
|
60
|
+
classData?.school?.users?.some((admin: { id: string }) => admin.id === userId) ||
|
|
52
61
|
false
|
|
53
62
|
);
|
|
54
63
|
}
|
package/src/services/event.ts
CHANGED
|
@@ -26,7 +26,7 @@ export async function getEvent(userId: string, id: string) {
|
|
|
26
26
|
message: "Event not found",
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
|
-
if (event
|
|
29
|
+
if (!canModifyEvent(event, userId)) {
|
|
30
30
|
throw new TRPCError({
|
|
31
31
|
code: "UNAUTHORIZED",
|
|
32
32
|
message: "You are not authorized to view this event",
|
|
@@ -92,7 +92,7 @@ export async function updateEventRecord(
|
|
|
92
92
|
message: "Event not found",
|
|
93
93
|
});
|
|
94
94
|
}
|
|
95
|
-
if (event
|
|
95
|
+
if (!canModifyEvent(event, userId)) {
|
|
96
96
|
throw new TRPCError({
|
|
97
97
|
code: "UNAUTHORIZED",
|
|
98
98
|
message: "You are not authorized to update this event",
|
|
@@ -120,7 +120,7 @@ export async function deleteEventRecord(userId: string, id: string) {
|
|
|
120
120
|
message: "Event not found",
|
|
121
121
|
});
|
|
122
122
|
}
|
|
123
|
-
if (event
|
|
123
|
+
if (!canModifyEvent(event, userId)) {
|
|
124
124
|
throw new TRPCError({
|
|
125
125
|
code: "UNAUTHORIZED",
|
|
126
126
|
message: "You are not authorized to delete this event",
|
|
@@ -132,11 +132,24 @@ export async function deleteEventRecord(userId: string, id: string) {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
function canModifyEvent(
|
|
135
|
-
event: {
|
|
135
|
+
event: {
|
|
136
|
+
userId: string | null;
|
|
137
|
+
class?: {
|
|
138
|
+
teachers: { id: string }[];
|
|
139
|
+
school?: {
|
|
140
|
+
users: { id: string }[];
|
|
141
|
+
memberships?: { userId: string }[];
|
|
142
|
+
} | null;
|
|
143
|
+
} | null;
|
|
144
|
+
},
|
|
136
145
|
userId: string
|
|
137
146
|
) {
|
|
138
147
|
if (event.userId === userId) return true;
|
|
139
|
-
return
|
|
148
|
+
return Boolean(
|
|
149
|
+
event.class?.teachers.some((t) => t.id === userId) ||
|
|
150
|
+
event.class?.school?.memberships?.some((admin) => admin.userId === userId) ||
|
|
151
|
+
event.class?.school?.users.some((admin) => admin.id === userId)
|
|
152
|
+
);
|
|
140
153
|
}
|
|
141
154
|
|
|
142
155
|
export async function attachAssignment(
|
|
@@ -165,7 +178,11 @@ export async function attachAssignment(
|
|
|
165
178
|
message: "Assignment not found",
|
|
166
179
|
});
|
|
167
180
|
}
|
|
168
|
-
if (
|
|
181
|
+
if (
|
|
182
|
+
!assignment.class.teachers.some((t) => t.id === userId) &&
|
|
183
|
+
!assignment.class.school?.memberships?.some((admin) => admin.userId === userId) &&
|
|
184
|
+
!assignment.class.school?.users.some((admin) => admin.id === userId)
|
|
185
|
+
) {
|
|
169
186
|
throw new TRPCError({
|
|
170
187
|
code: "UNAUTHORIZED",
|
|
171
188
|
message: "You are not authorized to modify this assignment",
|
package/src/services/file.ts
CHANGED
|
@@ -16,6 +16,15 @@ import {
|
|
|
16
16
|
} from "../models/file.js";
|
|
17
17
|
import { findConversationMembership } from "../models/message.js";
|
|
18
18
|
|
|
19
|
+
function isSchoolAdminForClass(classData: any, userId: string) {
|
|
20
|
+
return Boolean(
|
|
21
|
+
classData?.school?.memberships?.some(
|
|
22
|
+
(membership: { userId: string }) => membership.userId === userId
|
|
23
|
+
) ||
|
|
24
|
+
classData?.school?.users?.some((admin: { id: string }) => admin.id === userId)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
19
28
|
/** Generate a signed URL for file download. Validates user has access. */
|
|
20
29
|
export async function getFileSignedUrl(userId: string, fileId: string) {
|
|
21
30
|
const file = await findFileById(fileId);
|
|
@@ -35,31 +44,35 @@ export async function getFileSignedUrl(userId: string, fileId: string) {
|
|
|
35
44
|
const isStudent = file.assignment.class.students.some(
|
|
36
45
|
(s) => s.id === userId
|
|
37
46
|
);
|
|
47
|
+
const isSchoolAdmin = isSchoolAdminForClass(file.assignment.class, userId);
|
|
38
48
|
logger.info(
|
|
39
|
-
`Assignment file access check - userId: ${userId}, isTeacher: ${isTeacher}, isStudent: ${isStudent}`
|
|
49
|
+
`Assignment file access check - userId: ${userId}, isTeacher: ${isTeacher}, isStudent: ${isStudent}, isSchoolAdmin: ${isSchoolAdmin}`
|
|
40
50
|
);
|
|
41
|
-
hasAccess = isTeacher || isStudent;
|
|
51
|
+
hasAccess = isTeacher || isStudent || isSchoolAdmin;
|
|
42
52
|
}
|
|
43
53
|
|
|
44
54
|
if ((file as any).announcement?.class) {
|
|
45
55
|
const ann = (file as any).announcement;
|
|
46
56
|
const isTeacher = ann.class.teachers.some((t: any) => t.id === userId);
|
|
47
57
|
const isStudent = ann.class.students.some((s: any) => s.id === userId);
|
|
48
|
-
|
|
58
|
+
const isSchoolAdmin = isSchoolAdminForClass(ann.class, userId);
|
|
59
|
+
hasAccess = hasAccess || isTeacher || isStudent || isSchoolAdmin;
|
|
49
60
|
}
|
|
50
61
|
|
|
51
62
|
if (file.submission?.assignment?.classId) {
|
|
52
63
|
hasAccess =
|
|
53
64
|
hasAccess ||
|
|
54
65
|
file.submission.studentId === userId ||
|
|
55
|
-
file.submission.assignment.class.teachers.some((t) => t.id === userId)
|
|
66
|
+
file.submission.assignment.class.teachers.some((t) => t.id === userId) ||
|
|
67
|
+
isSchoolAdminForClass(file.submission.assignment.class, userId);
|
|
56
68
|
}
|
|
57
69
|
|
|
58
70
|
if (file.annotations?.assignment?.classId) {
|
|
59
71
|
hasAccess =
|
|
60
72
|
hasAccess ||
|
|
61
73
|
file.annotations.studentId === userId ||
|
|
62
|
-
file.annotations.assignment.class.teachers.some((t) => t.id === userId)
|
|
74
|
+
file.annotations.assignment.class.teachers.some((t) => t.id === userId) ||
|
|
75
|
+
isSchoolAdminForClass(file.annotations.assignment.class, userId);
|
|
63
76
|
}
|
|
64
77
|
|
|
65
78
|
if (file.userId === userId) {
|
|
@@ -74,7 +87,8 @@ export async function getFileSignedUrl(userId: string, fileId: string) {
|
|
|
74
87
|
if (file.folder?.class) {
|
|
75
88
|
const isTeacher = file.folder.class.teachers.some((t) => t.id === userId);
|
|
76
89
|
const isStudent = file.folder.class.students.some((s) => s.id === userId);
|
|
77
|
-
|
|
90
|
+
const isSchoolAdmin = isSchoolAdminForClass(file.folder.class, userId);
|
|
91
|
+
hasAccess = hasAccess || isTeacher || isStudent || isSchoolAdmin;
|
|
78
92
|
}
|
|
79
93
|
|
|
80
94
|
if (!hasAccess) {
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* School service - school admin workflow, memberships, and invites.
|
|
3
|
+
*/
|
|
4
|
+
import { SchoolMembershipRole } from "@prisma/client";
|
|
5
|
+
import { TRPCError } from "@trpc/server";
|
|
6
|
+
import { v4 as uuidv4 } from "uuid";
|
|
7
|
+
import { env } from "../lib/config/env.js";
|
|
8
|
+
import { prisma } from "../lib/prisma.js";
|
|
9
|
+
import { sendMail } from "../utils/email.js";
|
|
10
|
+
import { logger } from "../utils/logger.js";
|
|
11
|
+
import {
|
|
12
|
+
acceptSchoolInvite,
|
|
13
|
+
countActiveAdmins,
|
|
14
|
+
createSchoolInvite,
|
|
15
|
+
createSchoolWithAdmin,
|
|
16
|
+
findActiveSchoolAdminMembership,
|
|
17
|
+
findPendingInviteByToken,
|
|
18
|
+
findSchoolMembership,
|
|
19
|
+
findUserByIdForSchool,
|
|
20
|
+
getSchoolById,
|
|
21
|
+
getSchoolOverview,
|
|
22
|
+
listSchoolClasses,
|
|
23
|
+
listSchoolInvites,
|
|
24
|
+
listSchoolMembers,
|
|
25
|
+
listUserSchoolMemberships,
|
|
26
|
+
removeSchoolMembershipPreservingContent,
|
|
27
|
+
revokeSchoolInvite,
|
|
28
|
+
updateSchoolMembershipRole,
|
|
29
|
+
upsertSchoolMembership,
|
|
30
|
+
} from "../models/school.js";
|
|
31
|
+
|
|
32
|
+
async function requireSchoolAdmin(schoolId: string, userId: string) {
|
|
33
|
+
const membership = await findActiveSchoolAdminMembership(schoolId, userId);
|
|
34
|
+
if (!membership) {
|
|
35
|
+
throw new TRPCError({
|
|
36
|
+
code: "FORBIDDEN",
|
|
37
|
+
message: "Only school admins can perform this action",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return membership;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function requireSchoolMember(schoolId: string, userId: string) {
|
|
44
|
+
const membership = await findSchoolMembership(schoolId, userId);
|
|
45
|
+
if (!membership || membership.status !== "ACTIVE") {
|
|
46
|
+
throw new TRPCError({
|
|
47
|
+
code: "FORBIDDEN",
|
|
48
|
+
message: "You are not a member of this school",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return membership;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function getCurrentSchool(userId: string, schoolId?: string) {
|
|
55
|
+
const memberships = await listUserSchoolMemberships(userId);
|
|
56
|
+
const activeMembership = schoolId
|
|
57
|
+
? memberships.find((membership) => membership.schoolId === schoolId)
|
|
58
|
+
: memberships[0];
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
activeSchool: activeMembership?.school ?? null,
|
|
62
|
+
activeRole: activeMembership?.role ?? null,
|
|
63
|
+
memberships,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function createSchoolRecord(
|
|
68
|
+
userId: string,
|
|
69
|
+
input: { name: string; subdomain?: string; logoId?: string }
|
|
70
|
+
) {
|
|
71
|
+
const school = await createSchoolWithAdmin({
|
|
72
|
+
userId,
|
|
73
|
+
name: input.name,
|
|
74
|
+
subdomain: input.subdomain,
|
|
75
|
+
logoId: input.logoId,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return { school };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function getAdminOverview(userId: string, schoolId: string) {
|
|
82
|
+
await requireSchoolAdmin(schoolId, userId);
|
|
83
|
+
const school = await getSchoolOverview(schoolId);
|
|
84
|
+
if (!school) {
|
|
85
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "School not found" });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const roleCounts = school.memberships.reduce(
|
|
89
|
+
(counts, membership) => {
|
|
90
|
+
counts[membership.role] += 1;
|
|
91
|
+
return counts;
|
|
92
|
+
},
|
|
93
|
+
{ ADMIN: 0, TEACHER: 0, STUDENT: 0 } satisfies Record<SchoolMembershipRole, number>
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
school: {
|
|
98
|
+
id: school.id,
|
|
99
|
+
name: school.name,
|
|
100
|
+
subdomain: school.subdomain,
|
|
101
|
+
counts: {
|
|
102
|
+
classes: school._count.classes,
|
|
103
|
+
members: school._count.memberships,
|
|
104
|
+
pendingInvites: school._count.invites,
|
|
105
|
+
...roleCounts,
|
|
106
|
+
},
|
|
107
|
+
recentClasses: school.classes,
|
|
108
|
+
recentInvites: school.invites,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function listClasses(userId: string, schoolId: string) {
|
|
114
|
+
await requireSchoolAdmin(schoolId, userId);
|
|
115
|
+
return { classes: await listSchoolClasses(schoolId) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function createSchoolClass(
|
|
119
|
+
userId: string,
|
|
120
|
+
input: {
|
|
121
|
+
schoolId: string;
|
|
122
|
+
name: string;
|
|
123
|
+
section: string;
|
|
124
|
+
subject: string;
|
|
125
|
+
color?: string;
|
|
126
|
+
teachers?: string[];
|
|
127
|
+
students?: string[];
|
|
128
|
+
}
|
|
129
|
+
) {
|
|
130
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
131
|
+
const createdClass = await prisma.class.create({
|
|
132
|
+
data: {
|
|
133
|
+
name: input.name,
|
|
134
|
+
section: input.section,
|
|
135
|
+
subject: input.subject,
|
|
136
|
+
color: input.color,
|
|
137
|
+
schoolId: input.schoolId,
|
|
138
|
+
teachers: input.teachers?.length
|
|
139
|
+
? { connect: input.teachers.map((id) => ({ id })) }
|
|
140
|
+
: undefined,
|
|
141
|
+
students: input.students?.length
|
|
142
|
+
? { connect: input.students.map((id) => ({ id })) }
|
|
143
|
+
: undefined,
|
|
144
|
+
},
|
|
145
|
+
include: { teachers: true, students: true },
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return { class: createdClass };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function listMembers(userId: string, schoolId: string) {
|
|
152
|
+
await requireSchoolAdmin(schoolId, userId);
|
|
153
|
+
return { members: await listSchoolMembers(schoolId) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function addMember(
|
|
157
|
+
userId: string,
|
|
158
|
+
input: { schoolId: string; memberUserId: string; role: SchoolMembershipRole }
|
|
159
|
+
) {
|
|
160
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
161
|
+
const membership = await upsertSchoolMembership({
|
|
162
|
+
schoolId: input.schoolId,
|
|
163
|
+
userId: input.memberUserId,
|
|
164
|
+
role: input.role,
|
|
165
|
+
});
|
|
166
|
+
return { membership };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function updateMemberRole(
|
|
170
|
+
userId: string,
|
|
171
|
+
input: { schoolId: string; memberUserId: string; role: SchoolMembershipRole }
|
|
172
|
+
) {
|
|
173
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
174
|
+
const current = await findSchoolMembership(input.schoolId, input.memberUserId);
|
|
175
|
+
if (!current) {
|
|
176
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (current.role === "ADMIN" && input.role !== "ADMIN") {
|
|
180
|
+
const adminCount = await countActiveAdmins(input.schoolId);
|
|
181
|
+
if (adminCount <= 1) {
|
|
182
|
+
throw new TRPCError({
|
|
183
|
+
code: "BAD_REQUEST",
|
|
184
|
+
message: "A school must have at least one active admin",
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const membership = await updateSchoolMembershipRole(
|
|
190
|
+
input.schoolId,
|
|
191
|
+
input.memberUserId,
|
|
192
|
+
input.role
|
|
193
|
+
);
|
|
194
|
+
return { membership };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function removeMember(
|
|
198
|
+
userId: string,
|
|
199
|
+
input: { schoolId: string; memberUserId: string }
|
|
200
|
+
) {
|
|
201
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
202
|
+
const current = await findSchoolMembership(input.schoolId, input.memberUserId);
|
|
203
|
+
if (!current) {
|
|
204
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (current.role === "ADMIN" && current.status === "ACTIVE") {
|
|
208
|
+
const adminCount = await countActiveAdmins(input.schoolId);
|
|
209
|
+
if (adminCount <= 1) {
|
|
210
|
+
throw new TRPCError({
|
|
211
|
+
code: "BAD_REQUEST",
|
|
212
|
+
message: "A school must have at least one active admin",
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await removeSchoolMembershipPreservingContent(input.schoolId, input.memberUserId);
|
|
218
|
+
return { success: true };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function listInvites(userId: string, schoolId: string) {
|
|
222
|
+
await requireSchoolAdmin(schoolId, userId);
|
|
223
|
+
return { invites: await listSchoolInvites(schoolId) };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function createInvite(
|
|
227
|
+
userId: string,
|
|
228
|
+
input: { schoolId: string; email: string; role: SchoolMembershipRole }
|
|
229
|
+
) {
|
|
230
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
231
|
+
const token = uuidv4();
|
|
232
|
+
const invite = await createSchoolInvite({
|
|
233
|
+
schoolId: input.schoolId,
|
|
234
|
+
invitedById: userId,
|
|
235
|
+
email: input.email,
|
|
236
|
+
role: input.role,
|
|
237
|
+
token,
|
|
238
|
+
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 14),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
await sendMail({
|
|
243
|
+
from: "hello@studious.sh",
|
|
244
|
+
to: input.email,
|
|
245
|
+
subject: `Join ${invite.school.name} on Studious`,
|
|
246
|
+
text: `Click the link to join ${invite.school.name}: ${env.NEXT_PUBLIC_APP_URL}/school/invite/${token}`,
|
|
247
|
+
});
|
|
248
|
+
} catch (err) {
|
|
249
|
+
logger.error("Failed to send school invite email", {
|
|
250
|
+
schoolId: input.schoolId,
|
|
251
|
+
email: input.email,
|
|
252
|
+
err,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { invite };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function revokeInvite(
|
|
260
|
+
userId: string,
|
|
261
|
+
input: { schoolId: string; inviteId: string }
|
|
262
|
+
) {
|
|
263
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
264
|
+
return { invite: await revokeSchoolInvite(input.inviteId, input.schoolId) };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function getInvite(token: string) {
|
|
268
|
+
const invite = await findPendingInviteByToken(token);
|
|
269
|
+
if (!invite || invite.status !== "PENDING") {
|
|
270
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
|
|
271
|
+
}
|
|
272
|
+
if (invite.expiresAt < new Date()) {
|
|
273
|
+
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invite expired" });
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
invite: {
|
|
277
|
+
id: invite.id,
|
|
278
|
+
email: invite.email,
|
|
279
|
+
role: invite.role,
|
|
280
|
+
school: invite.school,
|
|
281
|
+
expiresAt: invite.expiresAt,
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export async function acceptInvite(userId: string, token: string) {
|
|
287
|
+
const invite = await findPendingInviteByToken(token);
|
|
288
|
+
if (!invite || invite.status !== "PENDING") {
|
|
289
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
|
|
290
|
+
}
|
|
291
|
+
if (invite.expiresAt < new Date()) {
|
|
292
|
+
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invite expired" });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const user = await findUserByIdForSchool(userId);
|
|
296
|
+
if (!user) {
|
|
297
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
|
298
|
+
}
|
|
299
|
+
if (user.email.toLowerCase() !== invite.email.toLowerCase()) {
|
|
300
|
+
throw new TRPCError({
|
|
301
|
+
code: "FORBIDDEN",
|
|
302
|
+
message: "This invite was sent to a different account",
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const membership = await acceptSchoolInvite(invite.id, userId);
|
|
307
|
+
return { membership };
|
|
308
|
+
}
|