@studious-lms/server 1.7.0 → 1.8.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 (196) hide show
  1. package/dist/models/access.d.ts +21 -66
  2. package/dist/models/access.d.ts.map +1 -1
  3. package/dist/models/access.js +10 -26
  4. package/dist/models/access.js.map +1 -1
  5. package/dist/models/agenda.d.ts +2 -0
  6. package/dist/models/agenda.d.ts.map +1 -1
  7. package/dist/models/announcement.d.ts +2 -1
  8. package/dist/models/announcement.d.ts.map +1 -1
  9. package/dist/models/announcement.js +2 -3
  10. package/dist/models/announcement.js.map +1 -1
  11. package/dist/models/assignment.d.ts +7 -0
  12. package/dist/models/assignment.d.ts.map +1 -1
  13. package/dist/models/auth.d.ts +2 -4
  14. package/dist/models/auth.d.ts.map +1 -1
  15. package/dist/models/auth.js +2 -3
  16. package/dist/models/auth.js.map +1 -1
  17. package/dist/models/class.d.ts +32 -4
  18. package/dist/models/class.d.ts.map +1 -1
  19. package/dist/models/class.js +15 -2
  20. package/dist/models/class.js.map +1 -1
  21. package/dist/models/cloudStorage.d.ts +140 -0
  22. package/dist/models/cloudStorage.d.ts.map +1 -0
  23. package/dist/models/cloudStorage.js +175 -0
  24. package/dist/models/cloudStorage.js.map +1 -0
  25. package/dist/models/comment.d.ts +0 -24
  26. package/dist/models/comment.d.ts.map +1 -1
  27. package/dist/models/comment.js +2 -6
  28. package/dist/models/comment.js.map +1 -1
  29. package/dist/models/conversation.d.ts +22 -22
  30. package/dist/models/department.d.ts +154 -0
  31. package/dist/models/department.d.ts.map +1 -0
  32. package/dist/models/department.js +135 -0
  33. package/dist/models/department.js.map +1 -0
  34. package/dist/models/event.d.ts +7 -1
  35. package/dist/models/event.d.ts.map +1 -1
  36. package/dist/models/event.js +2 -5
  37. package/dist/models/event.js.map +1 -1
  38. package/dist/models/file.d.ts +25 -10
  39. package/dist/models/file.d.ts.map +1 -1
  40. package/dist/models/file.js +2 -5
  41. package/dist/models/file.js.map +1 -1
  42. package/dist/models/folder.d.ts +47 -0
  43. package/dist/models/folder.d.ts.map +1 -1
  44. package/dist/models/labChat.d.ts +5 -3
  45. package/dist/models/labChat.d.ts.map +1 -1
  46. package/dist/models/message.d.ts +46 -12
  47. package/dist/models/message.d.ts.map +1 -1
  48. package/dist/models/message.js +19 -10
  49. package/dist/models/message.js.map +1 -1
  50. package/dist/models/newtonChat.d.ts +2 -1
  51. package/dist/models/newtonChat.d.ts.map +1 -1
  52. package/dist/models/school.d.ts +45 -12
  53. package/dist/models/school.d.ts.map +1 -1
  54. package/dist/models/school.js +19 -2
  55. package/dist/models/school.js.map +1 -1
  56. package/dist/models/section.d.ts +1 -0
  57. package/dist/models/section.d.ts.map +1 -1
  58. package/dist/models/tag.d.ts +117 -0
  59. package/dist/models/tag.d.ts.map +1 -0
  60. package/dist/models/tag.js +113 -0
  61. package/dist/models/tag.js.map +1 -0
  62. package/dist/models/worksheet.d.ts +3 -0
  63. package/dist/models/worksheet.d.ts.map +1 -1
  64. package/dist/pipelines/aiLabChatContract.d.ts +8 -8
  65. package/dist/routers/_app.d.ts +3200 -1972
  66. package/dist/routers/_app.d.ts.map +1 -1
  67. package/dist/routers/_app.js +6 -2
  68. package/dist/routers/_app.js.map +1 -1
  69. package/dist/routers/agenda.d.ts +2 -0
  70. package/dist/routers/agenda.d.ts.map +1 -1
  71. package/dist/routers/announcement.d.ts.map +1 -1
  72. package/dist/routers/announcement.js +3 -5
  73. package/dist/routers/announcement.js.map +1 -1
  74. package/dist/routers/assignment.d.ts +18 -9
  75. package/dist/routers/assignment.d.ts.map +1 -1
  76. package/dist/routers/assignment.js +2 -3
  77. package/dist/routers/assignment.js.map +1 -1
  78. package/dist/routers/class.d.ts +87 -14
  79. package/dist/routers/class.d.ts.map +1 -1
  80. package/dist/routers/class.js +5 -2
  81. package/dist/routers/class.js.map +1 -1
  82. package/dist/routers/conversation.d.ts +5 -5
  83. package/dist/routers/department.d.ts +293 -0
  84. package/dist/routers/department.d.ts.map +1 -0
  85. package/dist/routers/department.js +93 -0
  86. package/dist/routers/department.js.map +1 -0
  87. package/dist/routers/event.d.ts +2 -1
  88. package/dist/routers/event.d.ts.map +1 -1
  89. package/dist/routers/file.d.ts +8 -6
  90. package/dist/routers/file.d.ts.map +1 -1
  91. package/dist/routers/file.js +26 -17
  92. package/dist/routers/file.js.map +1 -1
  93. package/dist/routers/folder.d.ts +139 -186
  94. package/dist/routers/folder.d.ts.map +1 -1
  95. package/dist/routers/folder.js +82 -90
  96. package/dist/routers/folder.js.map +1 -1
  97. package/dist/routers/labChat.d.ts +11 -2
  98. package/dist/routers/labChat.d.ts.map +1 -1
  99. package/dist/routers/message.d.ts +20 -2
  100. package/dist/routers/message.d.ts.map +1 -1
  101. package/dist/routers/newtonChat.d.ts +9 -0
  102. package/dist/routers/newtonChat.d.ts.map +1 -1
  103. package/dist/routers/school.d.ts +160 -17
  104. package/dist/routers/school.d.ts.map +1 -1
  105. package/dist/routers/school.js +46 -3
  106. package/dist/routers/school.js.map +1 -1
  107. package/dist/routers/tag.d.ts +119 -0
  108. package/dist/routers/tag.d.ts.map +1 -0
  109. package/dist/routers/tag.js +52 -0
  110. package/dist/routers/tag.js.map +1 -0
  111. package/dist/routers/worksheet.d.ts +1 -0
  112. package/dist/routers/worksheet.d.ts.map +1 -1
  113. package/dist/seedDatabase.d.ts +0 -1
  114. package/dist/seedDatabase.d.ts.map +1 -1
  115. package/dist/services/agenda.d.ts +2 -0
  116. package/dist/services/agenda.d.ts.map +1 -1
  117. package/dist/services/assignment.d.ts +18 -9
  118. package/dist/services/assignment.d.ts.map +1 -1
  119. package/dist/services/class.d.ts +87 -14
  120. package/dist/services/class.d.ts.map +1 -1
  121. package/dist/services/class.js +91 -4
  122. package/dist/services/class.js.map +1 -1
  123. package/dist/services/conversation.d.ts +5 -5
  124. package/dist/services/department.d.ts +200 -0
  125. package/dist/services/department.d.ts.map +1 -0
  126. package/dist/services/department.js +368 -0
  127. package/dist/services/department.js.map +1 -0
  128. package/dist/services/event.d.ts +2 -1
  129. package/dist/services/event.d.ts.map +1 -1
  130. package/dist/services/file.d.ts +85 -0
  131. package/dist/services/file.d.ts.map +1 -1
  132. package/dist/services/file.js +92 -2
  133. package/dist/services/file.js.map +1 -1
  134. package/dist/services/folder.d.ts +125 -149
  135. package/dist/services/folder.d.ts.map +1 -1
  136. package/dist/services/folder.js +258 -199
  137. package/dist/services/folder.js.map +1 -1
  138. package/dist/services/labChat.d.ts +11 -2
  139. package/dist/services/labChat.d.ts.map +1 -1
  140. package/dist/services/labChat.js +4 -3
  141. package/dist/services/labChat.js.map +1 -1
  142. package/dist/services/message.d.ts +20 -2
  143. package/dist/services/message.d.ts.map +1 -1
  144. package/dist/services/message.js +7 -23
  145. package/dist/services/message.js.map +1 -1
  146. package/dist/services/newtonChat.d.ts +9 -0
  147. package/dist/services/newtonChat.d.ts.map +1 -1
  148. package/dist/services/newtonChat.js +4 -3
  149. package/dist/services/newtonChat.js.map +1 -1
  150. package/dist/services/school.d.ts +142 -15
  151. package/dist/services/school.d.ts.map +1 -1
  152. package/dist/services/school.js +308 -54
  153. package/dist/services/school.js.map +1 -1
  154. package/dist/services/tag.d.ts +71 -0
  155. package/dist/services/tag.d.ts.map +1 -0
  156. package/dist/services/tag.js +178 -0
  157. package/dist/services/tag.js.map +1 -0
  158. package/dist/services/worksheet.d.ts +1 -0
  159. package/dist/services/worksheet.d.ts.map +1 -1
  160. package/dist/utils/aiUser.d.ts.map +1 -1
  161. package/dist/utils/aiUser.js +2 -3
  162. package/dist/utils/aiUser.js.map +1 -1
  163. package/package.json +1 -1
  164. package/prisma/migrations/20260509220000_institution_overhaul/migration.sql +184 -0
  165. package/prisma/schema.prisma +119 -35
  166. package/src/models/access.ts +8 -24
  167. package/src/models/announcement.ts +0 -1
  168. package/src/models/auth.ts +0 -1
  169. package/src/models/class.ts +13 -0
  170. package/src/models/cloudStorage.ts +194 -0
  171. package/src/models/comment.ts +0 -4
  172. package/src/models/department.ts +159 -0
  173. package/src/models/event.ts +0 -3
  174. package/src/models/file.ts +0 -3
  175. package/src/models/message.ts +19 -5
  176. package/src/models/school.ts +26 -0
  177. package/src/models/tag.ts +140 -0
  178. package/src/routers/_app.ts +4 -0
  179. package/src/routers/announcement.ts +1 -3
  180. package/src/routers/assignment.ts +0 -1
  181. package/src/routers/class.ts +3 -0
  182. package/src/routers/department.ts +174 -0
  183. package/src/routers/file.ts +29 -26
  184. package/src/routers/folder.ts +100 -121
  185. package/src/routers/school.ts +83 -0
  186. package/src/routers/tag.ts +78 -0
  187. package/src/services/class.ts +108 -3
  188. package/src/services/department.ts +478 -0
  189. package/src/services/file.ts +100 -0
  190. package/src/services/folder.ts +313 -240
  191. package/src/services/labChat.ts +2 -1
  192. package/src/services/message.ts +5 -21
  193. package/src/services/newtonChat.ts +2 -1
  194. package/src/services/school.ts +369 -57
  195. package/src/services/tag.ts +229 -0
  196. package/src/utils/aiUser.ts +0 -1
@@ -1,316 +1,389 @@
1
1
  /**
2
- * Folder service – CRUD for class folders, file uploads, and folder hierarchy.
2
+ * Folder service — single source of truth for folders across every scope.
3
+ *
4
+ * A `Folder` can live inside one of four scopes (see `models/cloudStorage.ts`):
5
+ * class files, school storage, school documents, or a user's personal storage.
6
+ * Permissions and ownership are derived from the scope by walking up the tree
7
+ * to the root, so callers can either pass a `scope` (when there is no folder
8
+ * id yet, e.g. browsing the root) or just a `folderId` (everything else).
9
+ *
10
+ * The class files page and the global cloud-storage view both consume these
11
+ * endpoints — no duplicate routers.
3
12
  */
4
13
  import { TRPCError } from "@trpc/server";
14
+ import { prisma } from "../lib/prisma.js";
5
15
  import {
6
- findClassWithTeacher,
16
+ CloudScope,
17
+ createSubFolder,
18
+ deleteFolderById,
7
19
  findFolderById,
8
- findOrCreateRootFolder,
9
- createFolder,
10
20
  findFolderWithContents,
11
- findChildFolders,
12
- findRootFolderWithContents,
13
- findFolderByIdWithCount,
14
- deleteFolderById,
15
- updateFolderParent,
16
- updateFolder,
17
- findFolderByIdForTraversal,
18
- } from "../models/folder.js";
19
- import { createDirectUploadFiles } from "../lib/fileUpload.js";
21
+ findOrCreateScopeRoot,
22
+ listFolderBreadcrumbs,
23
+ renameFolder,
24
+ resolveFolderScope,
25
+ } from "../models/cloudStorage.js";
26
+ import {
27
+ createDirectUploadFiles,
28
+ type DirectFileData,
29
+ confirmDirectUpload,
30
+ } from "../lib/fileUpload.js";
20
31
 
21
- /** Create a folder in a class. Teacher-only. */
22
- export async function createFolderRecord(
23
- userId: string,
24
- input: {
25
- classId: string;
26
- name: string;
27
- parentFolderId?: string | null;
28
- color?: string;
29
- }
30
- ) {
31
- const classData = await findClassWithTeacher(input.classId, userId);
32
- if (!classData) {
33
- throw new TRPCError({
34
- code: "NOT_FOUND",
35
- message: "Class not found or you are not a teacher",
36
- });
37
- }
32
+ type AccessLevel = "READ" | "WRITE";
38
33
 
39
- let parentFolderId = input.parentFolderId;
40
- if (!parentFolderId) {
41
- const root = await findOrCreateRootFolder(input.classId);
42
- parentFolderId = root.id;
43
- } else {
44
- const parentFolder = await findFolderById(parentFolderId);
45
- if (!parentFolder) {
46
- throw new TRPCError({
47
- code: "NOT_FOUND",
48
- message: "Parent folder not found",
34
+ interface AccessResult {
35
+ scope: CloudScope;
36
+ canWrite: boolean;
37
+ }
38
+
39
+ async function requireScopeAccess(
40
+ userId: string,
41
+ scope: CloudScope,
42
+ level: AccessLevel
43
+ ): Promise<AccessResult> {
44
+ switch (scope.kind) {
45
+ case "CLASS": {
46
+ const classData = await prisma.class.findFirst({
47
+ where: { id: scope.classId },
48
+ select: {
49
+ id: true,
50
+ schoolId: true,
51
+ teachers: { where: { id: userId }, select: { id: true } },
52
+ students: { where: { id: userId }, select: { id: true } },
53
+ },
49
54
  });
55
+ if (!classData) {
56
+ throw new TRPCError({ code: "NOT_FOUND", message: "Class not found" });
57
+ }
58
+ const isTeacher = classData.teachers.length > 0;
59
+ const isStudent = classData.students.length > 0;
60
+ const isSchoolAdmin = classData.schoolId
61
+ ? Boolean(
62
+ await prisma.schoolMembership.findFirst({
63
+ where: {
64
+ schoolId: classData.schoolId,
65
+ userId,
66
+ role: "ADMIN",
67
+ status: "ACTIVE",
68
+ },
69
+ select: { id: true },
70
+ })
71
+ )
72
+ : false;
73
+ if (!isTeacher && !isStudent && !isSchoolAdmin) {
74
+ throw new TRPCError({
75
+ code: "FORBIDDEN",
76
+ message: "You don't have access to this class",
77
+ });
78
+ }
79
+ const canWrite = isTeacher || isSchoolAdmin;
80
+ if (level === "WRITE" && !canWrite) {
81
+ throw new TRPCError({
82
+ code: "FORBIDDEN",
83
+ message: "Only class teachers can modify class files",
84
+ });
85
+ }
86
+ return { scope, canWrite };
87
+ }
88
+ case "SCHOOL_STORAGE": {
89
+ const membership = await prisma.schoolMembership.findFirst({
90
+ where: { schoolId: scope.schoolId, userId, status: "ACTIVE" },
91
+ select: { role: true },
92
+ });
93
+ if (!membership) {
94
+ throw new TRPCError({
95
+ code: "FORBIDDEN",
96
+ message: "You aren't a member of this institution",
97
+ });
98
+ }
99
+ const canWrite =
100
+ membership.role === "ADMIN" || membership.role === "TEACHER";
101
+ if (level === "WRITE" && !canWrite) {
102
+ throw new TRPCError({
103
+ code: "FORBIDDEN",
104
+ message: "Only teachers and admins can modify institution storage",
105
+ });
106
+ }
107
+ return { scope, canWrite };
108
+ }
109
+ case "PERSONAL": {
110
+ if (scope.userId !== userId) {
111
+ throw new TRPCError({
112
+ code: "FORBIDDEN",
113
+ message: "You can only access your own personal storage",
114
+ });
115
+ }
116
+ return { scope, canWrite: true };
50
117
  }
51
118
  }
52
-
53
- return createFolder({
54
- name: input.name,
55
- parentFolderId,
56
- color: input.color,
57
- });
58
119
  }
59
120
 
60
- /** Get folder with its contents (subfolders and files). */
61
- export async function getFolder(folderId: string) {
62
- const folder = await findFolderWithContents(folderId);
63
- if (!folder) {
64
- throw new TRPCError({
65
- code: "NOT_FOUND",
66
- message: "Folder not found",
67
- });
121
+ export async function requireFolderAccess(
122
+ userId: string,
123
+ folderId: string,
124
+ level: AccessLevel
125
+ ) {
126
+ const scope = await resolveFolderScope(folderId);
127
+ if (!scope) {
128
+ throw new TRPCError({ code: "NOT_FOUND", message: "Folder not found" });
68
129
  }
69
- return folder;
70
- }
71
-
72
- export async function getChildFolders(classId: string) {
73
- const root = await findOrCreateRootFolder(classId);
74
- return findChildFolders(root.id);
75
- }
76
-
77
- export async function getFolderChildren(folderId: string, classId: string) {
78
- return findChildFolders(folderId);
130
+ return requireScopeAccess(userId, scope, level);
79
131
  }
80
132
 
81
- export async function getRootFolder(classId: string) {
82
- const root = await findOrCreateRootFolder(classId);
83
- const rootWithContents = await findRootFolderWithContents(classId);
84
- if (!rootWithContents) {
85
- throw new TRPCError({
86
- code: "NOT_FOUND",
87
- message: "Root folder not found",
88
- });
133
+ function scopesEqual(a: CloudScope, b: CloudScope): boolean {
134
+ if (a.kind !== b.kind) return false;
135
+ switch (a.kind) {
136
+ case "CLASS":
137
+ return a.classId === (b as typeof a).classId;
138
+ case "SCHOOL_STORAGE":
139
+ return a.schoolId === (b as typeof a).schoolId;
140
+ case "PERSONAL":
141
+ return a.userId === (b as typeof a).userId;
89
142
  }
90
- return rootWithContents;
91
143
  }
92
144
 
93
- export async function uploadFilesToFolder(
145
+ /** Browse a folder. Pass `folderId` to drill into a specific folder, or a `scope` to land on its root. */
146
+ export async function browseFolder(
94
147
  userId: string,
95
- classId: string,
96
- folderId: string,
97
- files: { name: string; type: string; size: number }[]
148
+ input: { folderId: string } | { scope: CloudScope }
98
149
  ) {
99
- const classData = await findClassWithTeacher(classId, userId);
100
- if (!classData) {
101
- throw new TRPCError({
102
- code: "NOT_FOUND",
103
- message: "Class not found or you are not a teacher",
104
- });
150
+ let scope: CloudScope;
151
+ let folderId: string | undefined;
152
+
153
+ if ("folderId" in input) {
154
+ folderId = input.folderId;
155
+ const resolved = await resolveFolderScope(input.folderId);
156
+ if (!resolved) {
157
+ throw new TRPCError({ code: "NOT_FOUND", message: "Folder not found" });
158
+ }
159
+ scope = resolved;
160
+ } else {
161
+ scope = input.scope;
105
162
  }
106
163
 
107
- const folder = await findFolderById(folderId);
164
+ const access = await requireScopeAccess(userId, scope, "READ");
165
+ const targetId = folderId ?? (await findOrCreateScopeRoot(scope)).id;
166
+
167
+ const [folder, breadcrumbs] = await Promise.all([
168
+ findFolderWithContents(targetId),
169
+ listFolderBreadcrumbs(targetId),
170
+ ]);
171
+
108
172
  if (!folder) {
109
- throw new TRPCError({
110
- code: "NOT_FOUND",
111
- message: "Folder not found",
112
- });
173
+ throw new TRPCError({ code: "NOT_FOUND", message: "Folder not found" });
113
174
  }
114
175
 
115
- const uploadedFiles = await createDirectUploadFiles(files, userId, folderId);
116
176
  return {
117
- success: true,
118
- uploadedCount: uploadedFiles.length,
177
+ folder,
178
+ breadcrumbs,
179
+ canWrite: access.canWrite,
180
+ scope,
119
181
  };
120
182
  }
121
183
 
122
- export async function deleteFolderRecord(
184
+ export async function getRootFolder(userId: string, scope: CloudScope) {
185
+ const result = await browseFolder(userId, { scope });
186
+ return result.folder;
187
+ }
188
+
189
+ export async function getFolder(userId: string, folderId: string) {
190
+ const result = await browseFolder(userId, { folderId });
191
+ return result.folder;
192
+ }
193
+
194
+ export async function getFolderBreadcrumbs(userId: string, folderId: string) {
195
+ await requireFolderAccess(userId, folderId, "READ");
196
+ return listFolderBreadcrumbs(folderId);
197
+ }
198
+
199
+ export async function getFolderParents(userId: string, folderId: string) {
200
+ // Permission: user can read this folder.
201
+ await requireFolderAccess(userId, folderId, "READ");
202
+ const breadcrumbs = await listFolderBreadcrumbs(folderId);
203
+ // Existing API returned full folder records — re-fetch them.
204
+ return Promise.all(
205
+ breadcrumbs.map((b) => findFolderById(b.id))
206
+ );
207
+ }
208
+
209
+ export async function createFolderRecord(
123
210
  userId: string,
124
- classId: string,
125
- folderId: string
211
+ input: {
212
+ name: string;
213
+ parentFolderId?: string | null;
214
+ color?: string;
215
+ scope?: CloudScope;
216
+ }
126
217
  ) {
127
- const classData = await findClassWithTeacher(classId, userId);
128
- if (!classData) {
129
- throw new TRPCError({
130
- code: "FORBIDDEN",
131
- message: "Class not found or you are not a teacher",
132
- });
218
+ let parentId: string;
219
+ if (input.parentFolderId) {
220
+ parentId = input.parentFolderId;
221
+ await requireFolderAccess(userId, parentId, "WRITE");
222
+ } else {
223
+ if (!input.scope) {
224
+ throw new TRPCError({
225
+ code: "BAD_REQUEST",
226
+ message: "scope is required when parentFolderId is omitted",
227
+ });
228
+ }
229
+ await requireScopeAccess(userId, input.scope, "WRITE");
230
+ parentId = (await findOrCreateScopeRoot(input.scope)).id;
133
231
  }
232
+ return createSubFolder({
233
+ name: input.name.trim(),
234
+ parentFolderId: parentId,
235
+ color: input.color,
236
+ });
237
+ }
134
238
 
135
- const folder = await findFolderByIdWithCount(folderId);
239
+ export async function updateFolderRecord(
240
+ userId: string,
241
+ input: { folderId: string; name?: string; color?: string }
242
+ ) {
243
+ const folder = await findFolderById(input.folderId);
136
244
  if (!folder) {
245
+ throw new TRPCError({ code: "NOT_FOUND", message: "Folder not found" });
246
+ }
247
+ if (!folder.parentFolderId) {
137
248
  throw new TRPCError({
138
- code: "NOT_FOUND",
139
- message: "Folder not found",
249
+ code: "BAD_REQUEST",
250
+ message: "Cannot rename a root folder",
140
251
  });
141
252
  }
253
+ await requireFolderAccess(userId, input.folderId, "WRITE");
254
+ return renameFolder(
255
+ input.folderId,
256
+ input.name?.trim() ?? folder.name,
257
+ input.color
258
+ );
259
+ }
142
260
 
143
- // Verify folder belongs to class (traverse to root)
144
- let currentId: string | null = folderId;
145
- let belongsToClass = false;
146
- while (currentId) {
147
- const current = await findFolderByIdForTraversal(currentId);
148
- if (!current) break;
149
- if (current.classId === classId) {
150
- belongsToClass = true;
151
- break;
152
- }
153
- currentId = current.parentFolderId;
261
+ export async function deleteFolder(userId: string, folderId: string) {
262
+ const folder = await findFolderById(folderId);
263
+ if (!folder) {
264
+ throw new TRPCError({ code: "NOT_FOUND", message: "Folder not found" });
154
265
  }
155
- if (!belongsToClass) {
266
+ if (!folder.parentFolderId) {
156
267
  throw new TRPCError({
157
- code: "FORBIDDEN",
158
- message: "Folder does not belong to this class",
268
+ code: "BAD_REQUEST",
269
+ message: "Cannot delete a root folder",
159
270
  });
160
271
  }
161
-
272
+ await requireFolderAccess(userId, folderId, "WRITE");
162
273
  await deleteFolderById(folderId);
163
- return {
164
- success: true,
165
- deletedFiles: folder._count.files,
166
- deletedFolders: folder._count.childFolders + 1,
167
- };
274
+ return { success: true };
168
275
  }
169
276
 
170
277
  export async function moveFolder(
171
278
  userId: string,
172
- folderId: string,
173
- targetParentFolderId: string,
174
- classId: string
279
+ input: { folderId: string; targetParentFolderId: string }
175
280
  ) {
176
- const classData = await findClassWithTeacher(classId, userId);
177
- if (!classData) {
281
+ if (input.folderId === input.targetParentFolderId) {
178
282
  throw new TRPCError({
179
- code: "FORBIDDEN",
180
- message: "You must be a teacher of this class to move folders",
283
+ code: "BAD_REQUEST",
284
+ message: "Cannot move a folder into itself",
181
285
  });
182
286
  }
183
-
184
- const folder = await findFolderById(folderId);
185
- if (!folder) {
186
- throw new TRPCError({
187
- code: "NOT_FOUND",
188
- message: "Folder not found",
189
- });
287
+ const [sourceScope, targetScope] = await Promise.all([
288
+ resolveFolderScope(input.folderId),
289
+ resolveFolderScope(input.targetParentFolderId),
290
+ ]);
291
+ if (!sourceScope || !targetScope) {
292
+ throw new TRPCError({ code: "NOT_FOUND", message: "Folder not found" });
190
293
  }
191
-
192
- if (!folder.parentFolderId) {
294
+ if (!scopesEqual(sourceScope, targetScope)) {
193
295
  throw new TRPCError({
194
296
  code: "BAD_REQUEST",
195
- message: "Cannot move the root folder",
297
+ message: "Cannot move folders between scopes",
196
298
  });
197
299
  }
300
+ await requireScopeAccess(userId, sourceScope, "WRITE");
198
301
 
199
- if (targetParentFolderId) {
200
- const targetParent = await findFolderByIdForTraversal(targetParentFolderId);
201
- if (!targetParent) {
202
- throw new TRPCError({
203
- code: "NOT_FOUND",
204
- message: "Target parent folder not found",
205
- });
206
- }
207
-
208
- if (targetParentFolderId === folderId) {
302
+ // Don't let a folder land under one of its own descendants.
303
+ let cursor: string | null = input.targetParentFolderId;
304
+ while (cursor) {
305
+ if (cursor === input.folderId) {
209
306
  throw new TRPCError({
210
307
  code: "BAD_REQUEST",
211
- message: "Cannot move a folder into itself",
308
+ message: "Cannot move a folder into its own descendant",
212
309
  });
213
310
  }
214
-
215
- let currentParent: typeof targetParent | null = targetParent;
216
- while (currentParent?.parentFolderId) {
217
- if (currentParent.parentFolderId === folderId) {
218
- throw new TRPCError({
219
- code: "BAD_REQUEST",
220
- message: "Cannot move a folder into its descendant",
221
- });
222
- }
223
- currentParent = await findFolderByIdForTraversal(currentParent.parentFolderId);
224
- if (!currentParent) break;
225
- }
226
- }
227
-
228
- return updateFolderParent(folderId, targetParentFolderId);
229
- }
230
-
231
- export async function updateFolderRecord(
232
- folderId: string,
233
- name: string,
234
- color: string | undefined,
235
- classId: string
236
- ) {
237
- const folder = await findFolderById(folderId);
238
- if (!folder) {
239
- throw new TRPCError({
240
- code: "NOT_FOUND",
241
- message: "Folder not found",
242
- });
243
- }
244
-
245
- if (!name.trim()) {
246
- throw new TRPCError({
247
- code: "BAD_REQUEST",
248
- message: "Folder name cannot be empty",
311
+ const node = await prisma.folder.findUnique({
312
+ where: { id: cursor },
313
+ select: { parentFolderId: true },
249
314
  });
315
+ cursor = node?.parentFolderId ?? null;
250
316
  }
251
317
 
252
- return updateFolder(folderId, {
253
- name: name.trim(),
254
- ...(color && { color }),
318
+ await prisma.folder.update({
319
+ where: { id: input.folderId },
320
+ data: { parentFolderId: input.targetParentFolderId },
255
321
  });
256
- }
257
-
258
- export async function getFolderParents(folderId: string) {
259
- const parents: Awaited<ReturnType<typeof findFolderByIdForTraversal>>[] = [];
260
- let currentId: string | null = folderId;
261
-
262
- while (currentId) {
263
- const folder = await findFolderByIdForTraversal(currentId);
264
- if (!folder) break;
265
- parents.push(folder);
266
- currentId = folder.parentFolderId;
267
- }
268
-
269
- return parents;
322
+ return { success: true };
270
323
  }
271
324
 
272
325
  export async function getFolderUploadUrls(
273
326
  userId: string,
274
- classId: string,
275
- folderId: string,
276
- files: { name: string; type: string; size: number }[]
327
+ input: { folderId: string; files: DirectFileData[] }
277
328
  ) {
278
- const classData = await findClassWithTeacher(classId, userId);
279
- if (!classData) {
280
- throw new TRPCError({
281
- code: "NOT_FOUND",
282
- message: "Class not found or you are not a teacher",
283
- });
284
- }
329
+ await requireFolderAccess(userId, input.folderId, "WRITE");
330
+ return createDirectUploadFiles(input.files, userId, input.folderId);
331
+ }
285
332
 
286
- const folder = await findFolderById(folderId);
287
- if (!folder) {
288
- throw new TRPCError({
289
- code: "NOT_FOUND",
290
- message: "Folder not found",
291
- });
292
- }
333
+ export async function confirmFolderUpload(
334
+ _userId: string,
335
+ input: { fileId: string; uploadSuccess: boolean; errorMessage?: string }
336
+ ) {
337
+ // Permission was already checked at upload-url issuance — the file record
338
+ // exists with PENDING status, and the user holds the session id needed to
339
+ // PUT bytes. Confirming flips status, so we just trust the call.
340
+ await confirmDirectUpload(
341
+ input.fileId,
342
+ input.uploadSuccess,
343
+ input.errorMessage
344
+ );
345
+ return { success: true };
346
+ }
293
347
 
294
- let currentFolder: typeof folder | null = folder;
295
- let belongsToClass = false;
296
- while (currentFolder) {
297
- if (currentFolder.classId === classId) {
298
- belongsToClass = true;
299
- break;
300
- }
301
- if (currentFolder.parentFolderId) {
302
- currentFolder = await findFolderByIdForTraversal(currentFolder.parentFolderId);
303
- } else {
304
- break;
305
- }
306
- }
348
+ /** What scopes can this user pick from in cloud-storage UIs? */
349
+ export async function listUserScopes(userId: string) {
350
+ const memberships = await prisma.schoolMembership.findMany({
351
+ where: { userId, status: "ACTIVE" },
352
+ select: {
353
+ role: true,
354
+ school: {
355
+ select: { id: true, name: true, logoId: true },
356
+ },
357
+ },
358
+ orderBy: { createdAt: "asc" },
359
+ });
307
360
 
308
- if (!belongsToClass) {
309
- throw new TRPCError({
310
- code: "FORBIDDEN",
311
- message: "Folder does not belong to this class",
312
- });
313
- }
361
+ const classes = await prisma.class.findMany({
362
+ where: {
363
+ OR: [
364
+ { teachers: { some: { id: userId } } },
365
+ { students: { some: { id: userId } } },
366
+ ],
367
+ },
368
+ select: {
369
+ id: true,
370
+ name: true,
371
+ subject: true,
372
+ section: true,
373
+ color: true,
374
+ schoolId: true,
375
+ },
376
+ orderBy: { name: "asc" },
377
+ });
314
378
 
315
- return createDirectUploadFiles(files, userId, folderId);
379
+ return {
380
+ personal: { kind: "PERSONAL" as const, userId },
381
+ schools: memberships.map((m) => ({
382
+ id: m.school.id,
383
+ name: m.school.name,
384
+ logoId: m.school.logoId,
385
+ role: m.role,
386
+ })),
387
+ classes,
388
+ };
316
389
  }
@@ -25,6 +25,7 @@ import {
25
25
  claimMessageAttachments,
26
26
  ensureMessageHasContentOrAttachments,
27
27
  } from "./message.js";
28
+ import { messageFileSelect } from "../models/message.js";
28
29
 
29
30
  /** Create a lab chat with conversation and teacher members. Broadcasts via Pusher. */
30
31
  export async function createLabChat(
@@ -307,7 +308,7 @@ export async function postToLabChat(
307
308
  },
308
309
  },
309
310
  attachments: {
310
- select: { id: true, name: true, type: true },
311
+ select: messageFileSelect,
311
312
  },
312
313
  },
313
314
  });