@studious-lms/server 1.7.0 → 1.9.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/dist/middleware/security.d.ts.map +1 -1
- package/dist/middleware/security.js +3 -3
- package/dist/middleware/security.js.map +1 -1
- package/dist/models/access.d.ts +21 -66
- package/dist/models/access.d.ts.map +1 -1
- package/dist/models/access.js +10 -26
- package/dist/models/access.js.map +1 -1
- package/dist/models/agenda.d.ts +4 -2
- package/dist/models/agenda.d.ts.map +1 -1
- package/dist/models/announcement.d.ts +4 -3
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/announcement.js +2 -3
- package/dist/models/announcement.js.map +1 -1
- package/dist/models/assignment.d.ts +40 -33
- package/dist/models/assignment.d.ts.map +1 -1
- package/dist/models/auth.d.ts +2 -4
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/auth.js +2 -3
- package/dist/models/auth.js.map +1 -1
- package/dist/models/class.d.ts +34 -6
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +15 -2
- package/dist/models/class.js.map +1 -1
- package/dist/models/cloudStorage.d.ts +153 -0
- package/dist/models/cloudStorage.d.ts.map +1 -0
- package/dist/models/cloudStorage.js +295 -0
- package/dist/models/cloudStorage.js.map +1 -0
- package/dist/models/comment.d.ts +0 -24
- package/dist/models/comment.d.ts.map +1 -1
- package/dist/models/comment.js +2 -6
- package/dist/models/comment.js.map +1 -1
- package/dist/models/conversation.d.ts +22 -22
- package/dist/models/department.d.ts +154 -0
- package/dist/models/department.d.ts.map +1 -0
- package/dist/models/department.js +135 -0
- package/dist/models/department.js.map +1 -0
- package/dist/models/event.d.ts +10 -4
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/event.js +2 -5
- package/dist/models/event.js.map +1 -1
- package/dist/models/file.d.ts +52 -36
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/file.js +4 -42
- package/dist/models/file.js.map +1 -1
- package/dist/models/folder.d.ts +47 -0
- package/dist/models/folder.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +11 -9
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/message.d.ts +46 -12
- package/dist/models/message.d.ts.map +1 -1
- package/dist/models/message.js +19 -10
- package/dist/models/message.js.map +1 -1
- package/dist/models/newtonChat.d.ts +2 -1
- package/dist/models/newtonChat.d.ts.map +1 -1
- package/dist/models/school.d.ts +45 -12
- package/dist/models/school.d.ts.map +1 -1
- package/dist/models/school.js +19 -2
- package/dist/models/school.js.map +1 -1
- package/dist/models/section.d.ts +2 -1
- package/dist/models/section.d.ts.map +1 -1
- package/dist/models/tag.d.ts +117 -0
- package/dist/models/tag.d.ts.map +1 -0
- package/dist/models/tag.js +113 -0
- package/dist/models/tag.js.map +1 -0
- package/dist/models/worksheet.d.ts +3 -0
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +8 -8
- package/dist/routers/_app.d.ts +6704 -5222
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +8 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/agenda.d.ts +4 -2
- package/dist/routers/agenda.d.ts.map +1 -1
- package/dist/routers/announcement.d.ts.map +1 -1
- package/dist/routers/announcement.js +3 -5
- package/dist/routers/announcement.js.map +1 -1
- package/dist/routers/assignment.d.ts +49 -40
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/assignment.js +2 -3
- package/dist/routers/assignment.js.map +1 -1
- package/dist/routers/class.d.ts +92 -19
- 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/conversation.d.ts +5 -5
- package/dist/routers/department.d.ts +293 -0
- package/dist/routers/department.d.ts.map +1 -0
- package/dist/routers/department.js +93 -0
- package/dist/routers/department.js.map +1 -0
- package/dist/routers/event.d.ts +4 -3
- package/dist/routers/event.d.ts.map +1 -1
- package/dist/routers/file.d.ts +10 -8
- package/dist/routers/file.d.ts.map +1 -1
- package/dist/routers/file.js +26 -17
- package/dist/routers/file.js.map +1 -1
- package/dist/routers/folder.d.ts +163 -186
- package/dist/routers/folder.d.ts.map +1 -1
- package/dist/routers/folder.js +102 -88
- package/dist/routers/folder.js.map +1 -1
- package/dist/routers/labChat.d.ts +13 -4
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/message.d.ts +20 -2
- package/dist/routers/message.d.ts.map +1 -1
- package/dist/routers/newtonChat.d.ts +10 -1
- package/dist/routers/newtonChat.d.ts.map +1 -1
- package/dist/routers/permissions.d.ts +120 -0
- package/dist/routers/permissions.d.ts.map +1 -0
- package/dist/routers/permissions.js +50 -0
- package/dist/routers/permissions.js.map +1 -0
- package/dist/routers/school.d.ts +160 -17
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/school.js +46 -3
- package/dist/routers/school.js.map +1 -1
- package/dist/routers/tag.d.ts +119 -0
- package/dist/routers/tag.d.ts.map +1 -0
- package/dist/routers/tag.js +52 -0
- package/dist/routers/tag.js.map +1 -0
- package/dist/routers/worksheet.d.ts +1 -0
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/seedDatabase.d.ts +0 -1
- package/dist/seedDatabase.d.ts.map +1 -1
- package/dist/services/agenda.d.ts +4 -2
- package/dist/services/agenda.d.ts.map +1 -1
- package/dist/services/assignment.d.ts +49 -40
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/class.d.ts +92 -19
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +92 -5
- package/dist/services/class.js.map +1 -1
- package/dist/services/conversation.d.ts +5 -5
- package/dist/services/department.d.ts +200 -0
- package/dist/services/department.d.ts.map +1 -0
- package/dist/services/department.js +368 -0
- package/dist/services/department.js.map +1 -0
- package/dist/services/event.d.ts +4 -3
- package/dist/services/event.d.ts.map +1 -1
- package/dist/services/file.d.ts +105 -3
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/file.js +167 -41
- package/dist/services/file.js.map +1 -1
- package/dist/services/folder.d.ts +220 -149
- package/dist/services/folder.d.ts.map +1 -1
- package/dist/services/folder.js +326 -196
- package/dist/services/folder.js.map +1 -1
- package/dist/services/labChat.d.ts +13 -4
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +4 -3
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/message.d.ts +20 -2
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +7 -23
- package/dist/services/message.js.map +1 -1
- package/dist/services/newtonChat.d.ts +9 -0
- package/dist/services/newtonChat.d.ts.map +1 -1
- package/dist/services/newtonChat.js +4 -3
- package/dist/services/newtonChat.js.map +1 -1
- package/dist/services/permissions.d.ts +188 -0
- package/dist/services/permissions.d.ts.map +1 -0
- package/dist/services/permissions.js +661 -0
- package/dist/services/permissions.js.map +1 -0
- package/dist/services/school.d.ts +142 -15
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/school.js +308 -54
- package/dist/services/school.js.map +1 -1
- package/dist/services/tag.d.ts +71 -0
- package/dist/services/tag.d.ts.map +1 -0
- package/dist/services/tag.js +178 -0
- package/dist/services/tag.js.map +1 -0
- package/dist/services/worksheet.d.ts +1 -0
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/utils/aiUser.d.ts.map +1 -1
- package/dist/utils/aiUser.js +2 -3
- package/dist/utils/aiUser.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260509220000_institution_overhaul/migration.sql +184 -0
- package/prisma/migrations/20260510170000_add_folder_permissions/migration.sql +93 -0
- package/prisma/schema.prisma +193 -35
- package/src/middleware/security.ts +1 -1
- package/src/models/access.ts +8 -24
- package/src/models/announcement.ts +0 -1
- package/src/models/auth.ts +0 -1
- package/src/models/class.ts +13 -0
- package/src/models/cloudStorage.ts +327 -0
- package/src/models/comment.ts +0 -4
- package/src/models/department.ts +159 -0
- package/src/models/event.ts +0 -3
- package/src/models/file.ts +2 -40
- package/src/models/message.ts +19 -5
- package/src/models/school.ts +26 -0
- package/src/models/tag.ts +140 -0
- package/src/routers/_app.ts +6 -0
- package/src/routers/announcement.ts +1 -3
- package/src/routers/assignment.ts +0 -1
- package/src/routers/class.ts +3 -0
- package/src/routers/department.ts +174 -0
- package/src/routers/file.ts +29 -26
- package/src/routers/folder.ts +127 -118
- package/src/routers/permissions.ts +71 -0
- package/src/routers/school.ts +83 -0
- package/src/routers/tag.ts +78 -0
- package/src/services/class.ts +109 -4
- package/src/services/department.ts +478 -0
- package/src/services/file.ts +172 -43
- package/src/services/folder.ts +399 -237
- package/src/services/labChat.ts +2 -1
- package/src/services/message.ts +5 -21
- package/src/services/newtonChat.ts +2 -1
- package/src/services/permissions.ts +778 -0
- package/src/services/school.ts +369 -57
- package/src/services/tag.ts +229 -0
- package/src/utils/aiUser.ts +0 -1
- package/tests/unit/services/permissions.test.ts +463 -0
package/prisma/schema.prisma
CHANGED
|
@@ -111,18 +111,73 @@ enum SchoolInviteStatus {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
model School {
|
|
114
|
-
id
|
|
115
|
-
name
|
|
116
|
-
|
|
117
|
-
logoId
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
114
|
+
id String @id @default(uuid())
|
|
115
|
+
name String
|
|
116
|
+
description String?
|
|
117
|
+
logo File? @relation("SchoolLogo", references: [id], fields: [logoId])
|
|
118
|
+
logoId String?
|
|
119
|
+
subdomain String? @unique
|
|
120
|
+
createdAt DateTime @default(now())
|
|
121
|
+
updatedAt DateTime @updatedAt
|
|
122
|
+
|
|
123
|
+
users User[]
|
|
124
|
+
memberships SchoolMembership[]
|
|
125
|
+
invites SchoolInvite[]
|
|
126
|
+
classes Class[]
|
|
127
|
+
departments Department[]
|
|
128
|
+
tags Tag[]
|
|
129
|
+
storageRoot Folder? @relation("SchoolStorageRoot")
|
|
130
|
+
permissions Permission[] @relation("SchoolPermissions")
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
model Department {
|
|
134
|
+
id String @id @default(uuid())
|
|
135
|
+
schoolId String
|
|
136
|
+
name String
|
|
137
|
+
description String?
|
|
138
|
+
color String? @default("#3B82F6")
|
|
139
|
+
createdAt DateTime @default(now())
|
|
140
|
+
updatedAt DateTime @updatedAt
|
|
141
|
+
|
|
142
|
+
school School @relation(fields: [schoolId], references: [id], onDelete: Cascade)
|
|
143
|
+
members User[] @relation("UserDepartments")
|
|
144
|
+
classes Class[]
|
|
145
|
+
tags Tag[]
|
|
146
|
+
rubricTemplates MarkScheme[]
|
|
147
|
+
boundaryTemplates GradingBoundary[]
|
|
148
|
+
permissions Permission[] @relation("DepartmentPermissions")
|
|
149
|
+
|
|
150
|
+
@@unique([schoolId, name])
|
|
151
|
+
@@index([schoolId])
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
enum TagTarget {
|
|
155
|
+
USER
|
|
156
|
+
CLASS
|
|
157
|
+
RUBRIC
|
|
158
|
+
BOUNDARY
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
model Tag {
|
|
162
|
+
id String @id @default(uuid())
|
|
163
|
+
schoolId String
|
|
164
|
+
departmentId String?
|
|
165
|
+
name String
|
|
166
|
+
color String @default("#3B82F6")
|
|
167
|
+
createdAt DateTime @default(now())
|
|
168
|
+
updatedAt DateTime @updatedAt
|
|
169
|
+
|
|
170
|
+
school School @relation(fields: [schoolId], references: [id], onDelete: Cascade)
|
|
171
|
+
department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull)
|
|
172
|
+
users User[] @relation("UserTags")
|
|
173
|
+
classes Class[] @relation("ClassTags")
|
|
174
|
+
rubrics MarkScheme[] @relation("MarkSchemeTags")
|
|
175
|
+
boundaries GradingBoundary[] @relation("GradingBoundaryTags")
|
|
176
|
+
permissions Permission[] @relation("TagPermissions")
|
|
177
|
+
|
|
178
|
+
@@unique([schoolId, name])
|
|
179
|
+
@@index([schoolId])
|
|
180
|
+
@@index([departmentId])
|
|
126
181
|
}
|
|
127
182
|
|
|
128
183
|
model User {
|
|
@@ -132,7 +187,6 @@ model User {
|
|
|
132
187
|
password String
|
|
133
188
|
profile UserProfile?
|
|
134
189
|
verified Boolean @default(false)
|
|
135
|
-
role UserRole @default(NONE)
|
|
136
190
|
|
|
137
191
|
teacherIn Class[] @relation("UserTeacherToClass")
|
|
138
192
|
studentIn Class[] @relation("UserStudentToClass")
|
|
@@ -158,6 +212,14 @@ model User {
|
|
|
158
212
|
schoolMemberships SchoolMembership[]
|
|
159
213
|
sentSchoolInvites SchoolInvite[] @relation("SchoolInviteInvitedBy")
|
|
160
214
|
|
|
215
|
+
departments Department[] @relation("UserDepartments")
|
|
216
|
+
tags Tag[] @relation("UserTags")
|
|
217
|
+
personalStorage Folder? @relation("UserStorageRoot")
|
|
218
|
+
|
|
219
|
+
// Permission grants where this user is the principal vs. the granter.
|
|
220
|
+
userPermissions Permission[] @relation("UserPermissions")
|
|
221
|
+
grantedPermissions Permission[] @relation("GrantedPermissions")
|
|
222
|
+
|
|
161
223
|
// Chat relations
|
|
162
224
|
conversationMemberships ConversationMember[]
|
|
163
225
|
sentMessages Message[] @relation("SentMessages")
|
|
@@ -238,8 +300,6 @@ model Class {
|
|
|
238
300
|
worksheets Worksheet[]
|
|
239
301
|
students User[] @relation("UserStudentToClass")
|
|
240
302
|
teachers User[] @relation("UserTeacherToClass")
|
|
241
|
-
// markSchemes MarkScheme[] @relation("ClassToMarkScheme")
|
|
242
|
-
// gradingBoundaries GradingBoundary[] @relation("ClassToGradingBoundary")
|
|
243
303
|
draftFiles File[] @relation("ClassDraftFiles")
|
|
244
304
|
classFiles Folder? @relation("ClassFiles")
|
|
245
305
|
labChats LabChat[] @relation("ClassLabChats")
|
|
@@ -247,36 +307,70 @@ model Class {
|
|
|
247
307
|
gradingBoundaries GradingBoundary[]
|
|
248
308
|
school School? @relation(fields: [schoolId], references: [id])
|
|
249
309
|
schoolId String?
|
|
310
|
+
department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull)
|
|
311
|
+
departmentId String?
|
|
312
|
+
tags Tag[] @relation("ClassTags")
|
|
313
|
+
permissions Permission[] @relation("ClassPermissions")
|
|
314
|
+
|
|
315
|
+
@@index([schoolId])
|
|
316
|
+
@@index([departmentId])
|
|
250
317
|
}
|
|
251
318
|
|
|
252
319
|
model MarkScheme {
|
|
253
|
-
id
|
|
254
|
-
classId
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
320
|
+
id String @id @default(uuid())
|
|
321
|
+
classId String?
|
|
322
|
+
class Class? @relation(fields: [classId], references: [id], onDelete: Cascade)
|
|
323
|
+
departmentId String?
|
|
324
|
+
department Department? @relation(fields: [departmentId], references: [id], onDelete: Cascade)
|
|
325
|
+
name String?
|
|
326
|
+
structured String
|
|
327
|
+
assignments Assignment[]
|
|
328
|
+
tags Tag[] @relation("MarkSchemeTags")
|
|
329
|
+
|
|
330
|
+
@@index([classId])
|
|
331
|
+
@@index([departmentId])
|
|
259
332
|
}
|
|
260
333
|
|
|
261
334
|
model GradingBoundary {
|
|
262
|
-
id
|
|
263
|
-
classId
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
335
|
+
id String @id @default(uuid())
|
|
336
|
+
classId String?
|
|
337
|
+
class Class? @relation(fields: [classId], references: [id], onDelete: Cascade)
|
|
338
|
+
departmentId String?
|
|
339
|
+
department Department? @relation(fields: [departmentId], references: [id], onDelete: Cascade)
|
|
340
|
+
name String?
|
|
341
|
+
structured String
|
|
342
|
+
assignments Assignment[]
|
|
343
|
+
tags Tag[] @relation("GradingBoundaryTags")
|
|
344
|
+
|
|
345
|
+
@@index([classId])
|
|
346
|
+
@@index([departmentId])
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
enum FolderScope {
|
|
350
|
+
CLASS
|
|
351
|
+
SCHOOL_STORAGE
|
|
352
|
+
PERSONAL
|
|
353
|
+
GENERIC
|
|
268
354
|
}
|
|
269
355
|
|
|
270
356
|
model Folder {
|
|
271
|
-
id String
|
|
357
|
+
id String @id @default(uuid())
|
|
272
358
|
name String
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
359
|
+
scope FolderScope @default(GENERIC)
|
|
360
|
+
files File[] @relation("FolderFiles")
|
|
361
|
+
childFolders Folder[] @relation("ParentChildFolders")
|
|
362
|
+
parentFolder Folder? @relation("ParentChildFolders", fields: [parentFolderId], references: [id])
|
|
276
363
|
parentFolderId String?
|
|
277
|
-
color String?
|
|
278
|
-
|
|
279
|
-
|
|
364
|
+
color String? @default("#3B82F6")
|
|
365
|
+
|
|
366
|
+
class Class? @relation("ClassFiles", fields: [classId], references: [id])
|
|
367
|
+
classId String? @unique
|
|
368
|
+
schoolStorage School? @relation("SchoolStorageRoot", fields: [schoolStorageId], references: [id], onDelete: Cascade)
|
|
369
|
+
schoolStorageId String? @unique
|
|
370
|
+
personalOwner User? @relation("UserStorageRoot", fields: [personalOwnerId], references: [id], onDelete: Cascade)
|
|
371
|
+
personalOwnerId String? @unique
|
|
372
|
+
|
|
373
|
+
permissions Permission[] @relation("FolderPermissions")
|
|
280
374
|
}
|
|
281
375
|
|
|
282
376
|
model File {
|
|
@@ -328,7 +422,7 @@ model File {
|
|
|
328
422
|
announcement Announcement? @relation("AnnouncementAttachments", fields: [announcementId], references: [id], onDelete: Cascade)
|
|
329
423
|
announcementId String?
|
|
330
424
|
|
|
331
|
-
schools School[]
|
|
425
|
+
schools School[] @relation("SchoolLogo")
|
|
332
426
|
|
|
333
427
|
schoolDevelopementProgram SchoolDevelopementProgram? @relation("SchoolDevelopementProgramSupportingDocumentation", fields: [schoolDevelopementProgramId], references: [id], onDelete: Cascade)
|
|
334
428
|
schoolDevelopementProgramId String?
|
|
@@ -814,3 +908,67 @@ model RunJob {
|
|
|
814
908
|
@@index([classId, status])
|
|
815
909
|
@@index([status, createdAt])
|
|
816
910
|
}
|
|
911
|
+
|
|
912
|
+
/// What kind of principal an explicit Permission row grants access to.
|
|
913
|
+
/// CLASS = everyone in the class (teachers + students). CLASS_TEACHERS /
|
|
914
|
+
/// CLASS_STUDENTS narrow that to one role. Stored alongside a single FK
|
|
915
|
+
/// (`classId`, `userId`, etc.) — see service-layer invariants.
|
|
916
|
+
enum PermissionPrincipalType {
|
|
917
|
+
USER
|
|
918
|
+
TAG
|
|
919
|
+
CLASS
|
|
920
|
+
CLASS_TEACHERS
|
|
921
|
+
CLASS_STUDENTS
|
|
922
|
+
DEPARTMENT
|
|
923
|
+
SCHOOL
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
enum PermissionLevel {
|
|
927
|
+
READ
|
|
928
|
+
WRITE
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/// Explicit ACL grant on a folder. Sits on top of (and is purely additive
|
|
932
|
+
/// to) the scope-derived defaults computed from FolderScope. Files inherit
|
|
933
|
+
/// from their parent folder — file-level overrides are deliberately deferred
|
|
934
|
+
/// to a future, additive migration that adds a `fileId` column + discriminator.
|
|
935
|
+
///
|
|
936
|
+
/// Schema invariant enforced in the service layer (Prisma can't express it):
|
|
937
|
+
/// - Exactly one of `userId` / `tagId` / `classId` / `departmentId` /
|
|
938
|
+
/// `schoolId` is set, matching `principalType`. CLASS, CLASS_TEACHERS,
|
|
939
|
+
/// CLASS_STUDENTS all share `classId`.
|
|
940
|
+
model Permission {
|
|
941
|
+
id String @id @default(uuid())
|
|
942
|
+
folderId String
|
|
943
|
+
folder Folder @relation("FolderPermissions", fields: [folderId], references: [id], onDelete: Cascade)
|
|
944
|
+
|
|
945
|
+
principalType PermissionPrincipalType
|
|
946
|
+
userId String?
|
|
947
|
+
tagId String?
|
|
948
|
+
classId String?
|
|
949
|
+
departmentId String?
|
|
950
|
+
schoolId String?
|
|
951
|
+
|
|
952
|
+
user User? @relation("UserPermissions", fields: [userId], references: [id], onDelete: Cascade)
|
|
953
|
+
tag Tag? @relation("TagPermissions", fields: [tagId], references: [id], onDelete: Cascade)
|
|
954
|
+
class Class? @relation("ClassPermissions", fields: [classId], references: [id], onDelete: Cascade)
|
|
955
|
+
department Department? @relation("DepartmentPermissions", fields: [departmentId], references: [id], onDelete: Cascade)
|
|
956
|
+
school School? @relation("SchoolPermissions", fields: [schoolId], references: [id], onDelete: Cascade)
|
|
957
|
+
|
|
958
|
+
level PermissionLevel
|
|
959
|
+
grantedById String
|
|
960
|
+
grantedBy User @relation("GrantedPermissions", fields: [grantedById], references: [id])
|
|
961
|
+
createdAt DateTime @default(now())
|
|
962
|
+
|
|
963
|
+
/// One grant per (folder, principal). Re-granting upserts the level.
|
|
964
|
+
/// Postgres treats nulls as distinct in unique indexes, but the service layer
|
|
965
|
+
/// guarantees exactly one principal id is non-null, so this still uniquely
|
|
966
|
+
/// identifies the row.
|
|
967
|
+
@@unique([folderId, principalType, userId, tagId, classId, departmentId, schoolId])
|
|
968
|
+
@@index([folderId])
|
|
969
|
+
@@index([userId])
|
|
970
|
+
@@index([tagId])
|
|
971
|
+
@@index([classId])
|
|
972
|
+
@@index([departmentId])
|
|
973
|
+
@@index([schoolId])
|
|
974
|
+
}
|
|
@@ -20,7 +20,7 @@ const rateLimitHandler = (req: Request, res: Response) => {
|
|
|
20
20
|
// General API rate limiter - applies to all routes
|
|
21
21
|
export const generalLimiter = rateLimit({
|
|
22
22
|
windowMs: 10 * 60 * 1000, // 10 minutes
|
|
23
|
-
max:
|
|
23
|
+
max: 1000, // Limit each IP to 100 requests per windowMs
|
|
24
24
|
message: 'Too many requests from this IP, please try again later.',
|
|
25
25
|
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
|
26
26
|
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
package/src/models/access.ts
CHANGED
|
@@ -3,33 +3,17 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
export const schoolAdminForClassWhere = (userId: string) => ({
|
|
6
|
-
|
|
7
|
-
{
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
role: "ADMIN" as const,
|
|
14
|
-
status: "ACTIVE" as const,
|
|
15
|
-
},
|
|
16
|
-
},
|
|
6
|
+
school: {
|
|
7
|
+
is: {
|
|
8
|
+
memberships: {
|
|
9
|
+
some: {
|
|
10
|
+
userId,
|
|
11
|
+
role: "ADMIN" as const,
|
|
12
|
+
status: "ACTIVE" as const,
|
|
17
13
|
},
|
|
18
14
|
},
|
|
19
15
|
},
|
|
20
|
-
|
|
21
|
-
school: {
|
|
22
|
-
is: {
|
|
23
|
-
users: {
|
|
24
|
-
some: {
|
|
25
|
-
id: userId,
|
|
26
|
-
role: "ADMIN" as const,
|
|
27
|
-
},
|
|
28
|
-
},
|
|
29
|
-
},
|
|
30
|
-
},
|
|
31
|
-
},
|
|
32
|
-
],
|
|
16
|
+
},
|
|
33
17
|
});
|
|
34
18
|
|
|
35
19
|
export const classTeacherOrSchoolAdminWhere = (userId: string) => ({
|
package/src/models/auth.ts
CHANGED
package/src/models/class.ts
CHANGED
|
@@ -54,6 +54,19 @@ const classIncludeForGetAll = {
|
|
|
54
54
|
},
|
|
55
55
|
},
|
|
56
56
|
},
|
|
57
|
+
school: {
|
|
58
|
+
select: {
|
|
59
|
+
id: true,
|
|
60
|
+
name: true,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
department: {
|
|
64
|
+
select: {
|
|
65
|
+
id: true,
|
|
66
|
+
name: true,
|
|
67
|
+
color: true,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
57
70
|
} as const;
|
|
58
71
|
|
|
59
72
|
/** @returns Classes where user is a teacher. */
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CloudStorage model — scope-aware folder helpers used by the institution and
|
|
3
|
+
* personal cloud storage UI. Sits on top of the same Folder/File tables that
|
|
4
|
+
* back class folders, but adds scope (school/personal) and ownership semantics.
|
|
5
|
+
*/
|
|
6
|
+
import type { FolderScope } from "@prisma/client";
|
|
7
|
+
import { prisma } from "../lib/prisma.js";
|
|
8
|
+
|
|
9
|
+
export type CloudScope =
|
|
10
|
+
| { kind: "CLASS"; classId: string }
|
|
11
|
+
| { kind: "SCHOOL_STORAGE"; schoolId: string }
|
|
12
|
+
| { kind: "PERSONAL"; userId: string };
|
|
13
|
+
|
|
14
|
+
const folderSelect = {
|
|
15
|
+
id: true,
|
|
16
|
+
name: true,
|
|
17
|
+
color: true,
|
|
18
|
+
scope: true,
|
|
19
|
+
classId: true,
|
|
20
|
+
schoolStorageId: true,
|
|
21
|
+
personalOwnerId: true,
|
|
22
|
+
parentFolderId: true,
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export async function findOrCreateScopeRoot(scope: CloudScope) {
|
|
26
|
+
switch (scope.kind) {
|
|
27
|
+
case "CLASS": {
|
|
28
|
+
let root = await prisma.folder.findFirst({
|
|
29
|
+
where: { classId: scope.classId, parentFolderId: null },
|
|
30
|
+
});
|
|
31
|
+
if (!root) {
|
|
32
|
+
root = await prisma.folder.create({
|
|
33
|
+
data: {
|
|
34
|
+
name: "Class Files",
|
|
35
|
+
scope: "CLASS" as FolderScope,
|
|
36
|
+
class: { connect: { id: scope.classId } },
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
} else if (root.scope !== "CLASS") {
|
|
40
|
+
root = await prisma.folder.update({
|
|
41
|
+
where: { id: root.id },
|
|
42
|
+
data: { scope: "CLASS" as FolderScope },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return root;
|
|
46
|
+
}
|
|
47
|
+
case "SCHOOL_STORAGE": {
|
|
48
|
+
let root = await prisma.folder.findFirst({
|
|
49
|
+
where: { schoolStorageId: scope.schoolId },
|
|
50
|
+
});
|
|
51
|
+
if (!root) {
|
|
52
|
+
root = await prisma.folder.create({
|
|
53
|
+
data: {
|
|
54
|
+
name: "Cloud Storage",
|
|
55
|
+
scope: "SCHOOL_STORAGE" as FolderScope,
|
|
56
|
+
schoolStorage: { connect: { id: scope.schoolId } },
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return root;
|
|
61
|
+
}
|
|
62
|
+
case "PERSONAL": {
|
|
63
|
+
let root = await prisma.folder.findFirst({
|
|
64
|
+
where: { personalOwnerId: scope.userId },
|
|
65
|
+
});
|
|
66
|
+
if (!root) {
|
|
67
|
+
root = await prisma.folder.create({
|
|
68
|
+
data: {
|
|
69
|
+
name: "My Files",
|
|
70
|
+
scope: "PERSONAL" as FolderScope,
|
|
71
|
+
personalOwner: { connect: { id: scope.userId } },
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return root;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Walk up the folder tree until we reach a root with a scope owner. Used to
|
|
82
|
+
* permission-check sub-folders (which only carry a parentFolderId).
|
|
83
|
+
*/
|
|
84
|
+
export async function resolveFolderScope(
|
|
85
|
+
folderId: string
|
|
86
|
+
): Promise<CloudScope | null> {
|
|
87
|
+
let currentId: string | null = folderId;
|
|
88
|
+
let visits = 0;
|
|
89
|
+
while (currentId && visits < 64) {
|
|
90
|
+
const folder = await prisma.folder.findUnique({
|
|
91
|
+
where: { id: currentId },
|
|
92
|
+
select: folderSelect,
|
|
93
|
+
});
|
|
94
|
+
if (!folder) return null;
|
|
95
|
+
if (folder.classId) return { kind: "CLASS", classId: folder.classId };
|
|
96
|
+
if (folder.schoolStorageId)
|
|
97
|
+
return { kind: "SCHOOL_STORAGE", schoolId: folder.schoolStorageId };
|
|
98
|
+
if (folder.personalOwnerId)
|
|
99
|
+
return { kind: "PERSONAL", userId: folder.personalOwnerId };
|
|
100
|
+
if (!folder.parentFolderId) return null;
|
|
101
|
+
currentId = folder.parentFolderId;
|
|
102
|
+
visits += 1;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const fileSelect = {
|
|
108
|
+
id: true,
|
|
109
|
+
name: true,
|
|
110
|
+
type: true,
|
|
111
|
+
size: true,
|
|
112
|
+
path: true,
|
|
113
|
+
uploadedAt: true,
|
|
114
|
+
user: { select: { id: true, username: true } },
|
|
115
|
+
} as const;
|
|
116
|
+
|
|
117
|
+
const childFolderSelect = {
|
|
118
|
+
id: true,
|
|
119
|
+
name: true,
|
|
120
|
+
color: true,
|
|
121
|
+
_count: { select: { files: true, childFolders: true } },
|
|
122
|
+
} as const;
|
|
123
|
+
|
|
124
|
+
export function findFolderWithContents(folderId: string) {
|
|
125
|
+
return prisma.folder.findFirst({
|
|
126
|
+
where: { id: folderId },
|
|
127
|
+
include: {
|
|
128
|
+
files: {
|
|
129
|
+
where: { uploadStatus: "COMPLETED" },
|
|
130
|
+
select: fileSelect,
|
|
131
|
+
orderBy: { uploadedAt: "desc" },
|
|
132
|
+
},
|
|
133
|
+
childFolders: {
|
|
134
|
+
select: childFolderSelect,
|
|
135
|
+
orderBy: { name: "asc" },
|
|
136
|
+
},
|
|
137
|
+
parentFolder: { select: { id: true, name: true } },
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function createSubFolder(data: {
|
|
143
|
+
name: string;
|
|
144
|
+
parentFolderId: string;
|
|
145
|
+
color?: string;
|
|
146
|
+
}) {
|
|
147
|
+
return prisma.folder.create({
|
|
148
|
+
data: {
|
|
149
|
+
name: data.name,
|
|
150
|
+
color: data.color,
|
|
151
|
+
parentFolder: { connect: { id: data.parentFolderId } },
|
|
152
|
+
},
|
|
153
|
+
select: folderSelect,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function findFolderById(folderId: string) {
|
|
158
|
+
return prisma.folder.findUnique({
|
|
159
|
+
where: { id: folderId },
|
|
160
|
+
select: folderSelect,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function deleteFolderById(folderId: string) {
|
|
165
|
+
return prisma.folder.delete({ where: { id: folderId } });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function renameFolder(folderId: string, name: string, color?: string) {
|
|
169
|
+
return prisma.folder.update({
|
|
170
|
+
where: { id: folderId },
|
|
171
|
+
data: {
|
|
172
|
+
name,
|
|
173
|
+
...(color ? { color } : {}),
|
|
174
|
+
},
|
|
175
|
+
select: folderSelect,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Build the breadcrumb chain for a folder.
|
|
181
|
+
*
|
|
182
|
+
* Without `viewerId`: walks all the way up to the scope root (owner-style
|
|
183
|
+
* view).
|
|
184
|
+
*
|
|
185
|
+
* With `viewerId`: walks up but stops at the highest folder the viewer is
|
|
186
|
+
* actually granted on, OR at the scope root if the viewer is a scope member.
|
|
187
|
+
* This is the Drive-style "Shared with me" behavior — a grantee never sees
|
|
188
|
+
* ancestor folders they weren't shared on. The result for a grantee browsing
|
|
189
|
+
* `Owner/PrivateA/SharedB/C` (granted on B) reads `SharedB / C` — the ancestor
|
|
190
|
+
* `PrivateA` is correctly hidden.
|
|
191
|
+
*/
|
|
192
|
+
export async function listFolderBreadcrumbs(
|
|
193
|
+
folderId: string,
|
|
194
|
+
viewerId?: string
|
|
195
|
+
) {
|
|
196
|
+
const breadcrumbs: { id: string; name: string }[] = [];
|
|
197
|
+
let currentId: string | null = folderId;
|
|
198
|
+
let visits = 0;
|
|
199
|
+
|
|
200
|
+
let viewerGrantedFolderIds: Set<string> | null = null;
|
|
201
|
+
let viewerScopeMember = false;
|
|
202
|
+
|
|
203
|
+
if (viewerId) {
|
|
204
|
+
const grants = await prisma.permission.findMany({
|
|
205
|
+
where: {
|
|
206
|
+
OR: [
|
|
207
|
+
{ principalType: "USER", userId: viewerId },
|
|
208
|
+
{
|
|
209
|
+
principalType: "TAG",
|
|
210
|
+
tag: { users: { some: { id: viewerId } } },
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
principalType: "CLASS",
|
|
214
|
+
class: {
|
|
215
|
+
OR: [
|
|
216
|
+
{ teachers: { some: { id: viewerId } } },
|
|
217
|
+
{ students: { some: { id: viewerId } } },
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
principalType: "CLASS_TEACHERS",
|
|
223
|
+
class: { teachers: { some: { id: viewerId } } },
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
principalType: "CLASS_STUDENTS",
|
|
227
|
+
class: { students: { some: { id: viewerId } } },
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
principalType: "DEPARTMENT",
|
|
231
|
+
department: { members: { some: { id: viewerId } } },
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
principalType: "SCHOOL",
|
|
235
|
+
school: {
|
|
236
|
+
memberships: { some: { userId: viewerId, status: "ACTIVE" } },
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
select: { folderId: true },
|
|
242
|
+
});
|
|
243
|
+
viewerGrantedFolderIds = new Set(
|
|
244
|
+
grants.map((g) => g.folderId).filter((x): x is string => Boolean(x))
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// Determine if the viewer is a member of the *target* folder's scope —
|
|
248
|
+
// if so, breadcrumbs go all the way to the scope root as normal.
|
|
249
|
+
const scope = await resolveFolderScope(folderId);
|
|
250
|
+
if (scope) {
|
|
251
|
+
switch (scope.kind) {
|
|
252
|
+
case "CLASS": {
|
|
253
|
+
const c = await prisma.class.findFirst({
|
|
254
|
+
where: {
|
|
255
|
+
id: scope.classId,
|
|
256
|
+
OR: [
|
|
257
|
+
{ teachers: { some: { id: viewerId } } },
|
|
258
|
+
{ students: { some: { id: viewerId } } },
|
|
259
|
+
],
|
|
260
|
+
},
|
|
261
|
+
select: { id: true },
|
|
262
|
+
});
|
|
263
|
+
viewerScopeMember = Boolean(c);
|
|
264
|
+
if (!viewerScopeMember && scope.classId) {
|
|
265
|
+
const klass = await prisma.class.findUnique({
|
|
266
|
+
where: { id: scope.classId },
|
|
267
|
+
select: { schoolId: true },
|
|
268
|
+
});
|
|
269
|
+
if (klass?.schoolId) {
|
|
270
|
+
const m = await prisma.schoolMembership.findFirst({
|
|
271
|
+
where: {
|
|
272
|
+
schoolId: klass.schoolId,
|
|
273
|
+
userId: viewerId,
|
|
274
|
+
role: "ADMIN",
|
|
275
|
+
status: "ACTIVE",
|
|
276
|
+
},
|
|
277
|
+
select: { id: true },
|
|
278
|
+
});
|
|
279
|
+
viewerScopeMember = Boolean(m);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
case "SCHOOL_STORAGE": {
|
|
285
|
+
const m = await prisma.schoolMembership.findFirst({
|
|
286
|
+
where: {
|
|
287
|
+
schoolId: scope.schoolId,
|
|
288
|
+
userId: viewerId,
|
|
289
|
+
status: "ACTIVE",
|
|
290
|
+
},
|
|
291
|
+
select: { id: true },
|
|
292
|
+
});
|
|
293
|
+
viewerScopeMember = Boolean(m);
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
case "PERSONAL":
|
|
297
|
+
viewerScopeMember = scope.userId === viewerId;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
while (currentId && visits < 64) {
|
|
304
|
+
const folder = await prisma.folder.findUnique({
|
|
305
|
+
where: { id: currentId },
|
|
306
|
+
select: { id: true, name: true, parentFolderId: true },
|
|
307
|
+
});
|
|
308
|
+
if (!folder) break;
|
|
309
|
+
breadcrumbs.unshift({ id: folder.id, name: folder.name });
|
|
310
|
+
|
|
311
|
+
// Viewer-aware truncation: when the viewer is *not* a scope member but
|
|
312
|
+
// is a grantee, stop at the highest granted ancestor (we walk up one
|
|
313
|
+
// more step into the granted folder, then stop there).
|
|
314
|
+
if (
|
|
315
|
+
viewerId &&
|
|
316
|
+
!viewerScopeMember &&
|
|
317
|
+
viewerGrantedFolderIds &&
|
|
318
|
+
viewerGrantedFolderIds.has(folder.id)
|
|
319
|
+
) {
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
currentId = folder.parentFolderId;
|
|
324
|
+
visits += 1;
|
|
325
|
+
}
|
|
326
|
+
return breadcrumbs;
|
|
327
|
+
}
|