@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.
Files changed (212) hide show
  1. package/dist/middleware/security.d.ts.map +1 -1
  2. package/dist/middleware/security.js +3 -3
  3. package/dist/middleware/security.js.map +1 -1
  4. package/dist/models/access.d.ts +21 -66
  5. package/dist/models/access.d.ts.map +1 -1
  6. package/dist/models/access.js +10 -26
  7. package/dist/models/access.js.map +1 -1
  8. package/dist/models/agenda.d.ts +4 -2
  9. package/dist/models/agenda.d.ts.map +1 -1
  10. package/dist/models/announcement.d.ts +4 -3
  11. package/dist/models/announcement.d.ts.map +1 -1
  12. package/dist/models/announcement.js +2 -3
  13. package/dist/models/announcement.js.map +1 -1
  14. package/dist/models/assignment.d.ts +40 -33
  15. package/dist/models/assignment.d.ts.map +1 -1
  16. package/dist/models/auth.d.ts +2 -4
  17. package/dist/models/auth.d.ts.map +1 -1
  18. package/dist/models/auth.js +2 -3
  19. package/dist/models/auth.js.map +1 -1
  20. package/dist/models/class.d.ts +34 -6
  21. package/dist/models/class.d.ts.map +1 -1
  22. package/dist/models/class.js +15 -2
  23. package/dist/models/class.js.map +1 -1
  24. package/dist/models/cloudStorage.d.ts +153 -0
  25. package/dist/models/cloudStorage.d.ts.map +1 -0
  26. package/dist/models/cloudStorage.js +295 -0
  27. package/dist/models/cloudStorage.js.map +1 -0
  28. package/dist/models/comment.d.ts +0 -24
  29. package/dist/models/comment.d.ts.map +1 -1
  30. package/dist/models/comment.js +2 -6
  31. package/dist/models/comment.js.map +1 -1
  32. package/dist/models/conversation.d.ts +22 -22
  33. package/dist/models/department.d.ts +154 -0
  34. package/dist/models/department.d.ts.map +1 -0
  35. package/dist/models/department.js +135 -0
  36. package/dist/models/department.js.map +1 -0
  37. package/dist/models/event.d.ts +10 -4
  38. package/dist/models/event.d.ts.map +1 -1
  39. package/dist/models/event.js +2 -5
  40. package/dist/models/event.js.map +1 -1
  41. package/dist/models/file.d.ts +52 -36
  42. package/dist/models/file.d.ts.map +1 -1
  43. package/dist/models/file.js +4 -42
  44. package/dist/models/file.js.map +1 -1
  45. package/dist/models/folder.d.ts +47 -0
  46. package/dist/models/folder.d.ts.map +1 -1
  47. package/dist/models/labChat.d.ts +11 -9
  48. package/dist/models/labChat.d.ts.map +1 -1
  49. package/dist/models/message.d.ts +46 -12
  50. package/dist/models/message.d.ts.map +1 -1
  51. package/dist/models/message.js +19 -10
  52. package/dist/models/message.js.map +1 -1
  53. package/dist/models/newtonChat.d.ts +2 -1
  54. package/dist/models/newtonChat.d.ts.map +1 -1
  55. package/dist/models/school.d.ts +45 -12
  56. package/dist/models/school.d.ts.map +1 -1
  57. package/dist/models/school.js +19 -2
  58. package/dist/models/school.js.map +1 -1
  59. package/dist/models/section.d.ts +2 -1
  60. package/dist/models/section.d.ts.map +1 -1
  61. package/dist/models/tag.d.ts +117 -0
  62. package/dist/models/tag.d.ts.map +1 -0
  63. package/dist/models/tag.js +113 -0
  64. package/dist/models/tag.js.map +1 -0
  65. package/dist/models/worksheet.d.ts +3 -0
  66. package/dist/models/worksheet.d.ts.map +1 -1
  67. package/dist/pipelines/aiLabChatContract.d.ts +8 -8
  68. package/dist/routers/_app.d.ts +6704 -5222
  69. package/dist/routers/_app.d.ts.map +1 -1
  70. package/dist/routers/_app.js +8 -2
  71. package/dist/routers/_app.js.map +1 -1
  72. package/dist/routers/agenda.d.ts +4 -2
  73. package/dist/routers/agenda.d.ts.map +1 -1
  74. package/dist/routers/announcement.d.ts.map +1 -1
  75. package/dist/routers/announcement.js +3 -5
  76. package/dist/routers/announcement.js.map +1 -1
  77. package/dist/routers/assignment.d.ts +49 -40
  78. package/dist/routers/assignment.d.ts.map +1 -1
  79. package/dist/routers/assignment.js +2 -3
  80. package/dist/routers/assignment.js.map +1 -1
  81. package/dist/routers/class.d.ts +92 -19
  82. package/dist/routers/class.d.ts.map +1 -1
  83. package/dist/routers/class.js +5 -2
  84. package/dist/routers/class.js.map +1 -1
  85. package/dist/routers/conversation.d.ts +5 -5
  86. package/dist/routers/department.d.ts +293 -0
  87. package/dist/routers/department.d.ts.map +1 -0
  88. package/dist/routers/department.js +93 -0
  89. package/dist/routers/department.js.map +1 -0
  90. package/dist/routers/event.d.ts +4 -3
  91. package/dist/routers/event.d.ts.map +1 -1
  92. package/dist/routers/file.d.ts +10 -8
  93. package/dist/routers/file.d.ts.map +1 -1
  94. package/dist/routers/file.js +26 -17
  95. package/dist/routers/file.js.map +1 -1
  96. package/dist/routers/folder.d.ts +163 -186
  97. package/dist/routers/folder.d.ts.map +1 -1
  98. package/dist/routers/folder.js +102 -88
  99. package/dist/routers/folder.js.map +1 -1
  100. package/dist/routers/labChat.d.ts +13 -4
  101. package/dist/routers/labChat.d.ts.map +1 -1
  102. package/dist/routers/message.d.ts +20 -2
  103. package/dist/routers/message.d.ts.map +1 -1
  104. package/dist/routers/newtonChat.d.ts +10 -1
  105. package/dist/routers/newtonChat.d.ts.map +1 -1
  106. package/dist/routers/permissions.d.ts +120 -0
  107. package/dist/routers/permissions.d.ts.map +1 -0
  108. package/dist/routers/permissions.js +50 -0
  109. package/dist/routers/permissions.js.map +1 -0
  110. package/dist/routers/school.d.ts +160 -17
  111. package/dist/routers/school.d.ts.map +1 -1
  112. package/dist/routers/school.js +46 -3
  113. package/dist/routers/school.js.map +1 -1
  114. package/dist/routers/tag.d.ts +119 -0
  115. package/dist/routers/tag.d.ts.map +1 -0
  116. package/dist/routers/tag.js +52 -0
  117. package/dist/routers/tag.js.map +1 -0
  118. package/dist/routers/worksheet.d.ts +1 -0
  119. package/dist/routers/worksheet.d.ts.map +1 -1
  120. package/dist/seedDatabase.d.ts +0 -1
  121. package/dist/seedDatabase.d.ts.map +1 -1
  122. package/dist/services/agenda.d.ts +4 -2
  123. package/dist/services/agenda.d.ts.map +1 -1
  124. package/dist/services/assignment.d.ts +49 -40
  125. package/dist/services/assignment.d.ts.map +1 -1
  126. package/dist/services/class.d.ts +92 -19
  127. package/dist/services/class.d.ts.map +1 -1
  128. package/dist/services/class.js +92 -5
  129. package/dist/services/class.js.map +1 -1
  130. package/dist/services/conversation.d.ts +5 -5
  131. package/dist/services/department.d.ts +200 -0
  132. package/dist/services/department.d.ts.map +1 -0
  133. package/dist/services/department.js +368 -0
  134. package/dist/services/department.js.map +1 -0
  135. package/dist/services/event.d.ts +4 -3
  136. package/dist/services/event.d.ts.map +1 -1
  137. package/dist/services/file.d.ts +105 -3
  138. package/dist/services/file.d.ts.map +1 -1
  139. package/dist/services/file.js +167 -41
  140. package/dist/services/file.js.map +1 -1
  141. package/dist/services/folder.d.ts +220 -149
  142. package/dist/services/folder.d.ts.map +1 -1
  143. package/dist/services/folder.js +326 -196
  144. package/dist/services/folder.js.map +1 -1
  145. package/dist/services/labChat.d.ts +13 -4
  146. package/dist/services/labChat.d.ts.map +1 -1
  147. package/dist/services/labChat.js +4 -3
  148. package/dist/services/labChat.js.map +1 -1
  149. package/dist/services/message.d.ts +20 -2
  150. package/dist/services/message.d.ts.map +1 -1
  151. package/dist/services/message.js +7 -23
  152. package/dist/services/message.js.map +1 -1
  153. package/dist/services/newtonChat.d.ts +9 -0
  154. package/dist/services/newtonChat.d.ts.map +1 -1
  155. package/dist/services/newtonChat.js +4 -3
  156. package/dist/services/newtonChat.js.map +1 -1
  157. package/dist/services/permissions.d.ts +188 -0
  158. package/dist/services/permissions.d.ts.map +1 -0
  159. package/dist/services/permissions.js +661 -0
  160. package/dist/services/permissions.js.map +1 -0
  161. package/dist/services/school.d.ts +142 -15
  162. package/dist/services/school.d.ts.map +1 -1
  163. package/dist/services/school.js +308 -54
  164. package/dist/services/school.js.map +1 -1
  165. package/dist/services/tag.d.ts +71 -0
  166. package/dist/services/tag.d.ts.map +1 -0
  167. package/dist/services/tag.js +178 -0
  168. package/dist/services/tag.js.map +1 -0
  169. package/dist/services/worksheet.d.ts +1 -0
  170. package/dist/services/worksheet.d.ts.map +1 -1
  171. package/dist/utils/aiUser.d.ts.map +1 -1
  172. package/dist/utils/aiUser.js +2 -3
  173. package/dist/utils/aiUser.js.map +1 -1
  174. package/package.json +1 -1
  175. package/prisma/migrations/20260509220000_institution_overhaul/migration.sql +184 -0
  176. package/prisma/migrations/20260510170000_add_folder_permissions/migration.sql +93 -0
  177. package/prisma/schema.prisma +193 -35
  178. package/src/middleware/security.ts +1 -1
  179. package/src/models/access.ts +8 -24
  180. package/src/models/announcement.ts +0 -1
  181. package/src/models/auth.ts +0 -1
  182. package/src/models/class.ts +13 -0
  183. package/src/models/cloudStorage.ts +327 -0
  184. package/src/models/comment.ts +0 -4
  185. package/src/models/department.ts +159 -0
  186. package/src/models/event.ts +0 -3
  187. package/src/models/file.ts +2 -40
  188. package/src/models/message.ts +19 -5
  189. package/src/models/school.ts +26 -0
  190. package/src/models/tag.ts +140 -0
  191. package/src/routers/_app.ts +6 -0
  192. package/src/routers/announcement.ts +1 -3
  193. package/src/routers/assignment.ts +0 -1
  194. package/src/routers/class.ts +3 -0
  195. package/src/routers/department.ts +174 -0
  196. package/src/routers/file.ts +29 -26
  197. package/src/routers/folder.ts +127 -118
  198. package/src/routers/permissions.ts +71 -0
  199. package/src/routers/school.ts +83 -0
  200. package/src/routers/tag.ts +78 -0
  201. package/src/services/class.ts +109 -4
  202. package/src/services/department.ts +478 -0
  203. package/src/services/file.ts +172 -43
  204. package/src/services/folder.ts +399 -237
  205. package/src/services/labChat.ts +2 -1
  206. package/src/services/message.ts +5 -21
  207. package/src/services/newtonChat.ts +2 -1
  208. package/src/services/permissions.ts +778 -0
  209. package/src/services/school.ts +369 -57
  210. package/src/services/tag.ts +229 -0
  211. package/src/utils/aiUser.ts +0 -1
  212. package/tests/unit/services/permissions.test.ts +463 -0
@@ -111,18 +111,73 @@ enum SchoolInviteStatus {
111
111
  }
112
112
 
113
113
  model School {
114
- id String @id @default(uuid())
115
- name String
116
- logo File? @relation(references: [id], fields: [logoId])
117
- logoId String?
118
- subdomain String? @unique
119
- createdAt DateTime @default(now())
120
- updatedAt DateTime @updatedAt
121
-
122
- users User[]
123
- memberships SchoolMembership[]
124
- invites SchoolInvite[]
125
- classes Class[]
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 String @id @default(uuid())
254
- classId String
255
- // class Class[] @relation("ClassToMarkScheme")
256
- class Class? @relation(fields: [classId], references: [id], onDelete: Cascade)
257
- structured String
258
- assignments Assignment[]
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 String @id @default(uuid())
263
- classId String
264
- // class Class[] @relation("ClassToGradingBoundary")
265
- class Class? @relation(fields: [classId], references: [id], onDelete: Cascade)
266
- structured String
267
- assignments Assignment[]
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 @id @default(uuid())
357
+ id String @id @default(uuid())
272
358
  name String
273
- files File[] @relation("FolderFiles")
274
- childFolders Folder[] @relation("ParentChildFolders")
275
- parentFolder Folder? @relation("ParentChildFolders", fields: [parentFolderId], references: [id])
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? @default("#3B82F6")
278
- class Class? @relation("ClassFiles", fields: [classId], references: [id])
279
- classId String? @unique
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: 100, // Limit each IP to 100 requests per windowMs
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
@@ -3,33 +3,17 @@
3
3
  */
4
4
 
5
5
  export const schoolAdminForClassWhere = (userId: string) => ({
6
- OR: [
7
- {
8
- school: {
9
- is: {
10
- memberships: {
11
- some: {
12
- userId,
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) => ({
@@ -67,7 +67,6 @@ export function findAnnouncementWithClass(id: string) {
67
67
  school: {
68
68
  include: {
69
69
  users: {
70
- where: { role: "ADMIN" as const },
71
70
  select: { id: true },
72
71
  },
73
72
  memberships: {
@@ -61,7 +61,6 @@ export function findUserByEmail(email: string) {
61
61
  select: {
62
62
  id: true,
63
63
  email: true,
64
- role: true,
65
64
  profile: {
66
65
  select: {
67
66
  displayName: true,
@@ -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
+ }