@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
@@ -1,316 +1,478 @@
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";
31
+ import {
32
+ type AccessLevel,
33
+ getScopeDefault,
34
+ listSharedWithMe,
35
+ requireFolderAccess as requireFolderAccessResolved,
36
+ } from "./permissions.js";
37
+
38
+ interface AccessResult {
39
+ scope: CloudScope;
40
+ canWrite: boolean;
41
+ /**
42
+ * True when the viewer's access comes through an explicit grant rather than
43
+ * scope membership. Used by `browseFolder` to surface a "Shared with me"
44
+ * heading instead of the underlying scope's title — the grantee shouldn't
45
+ * see the folder labelled with the owner's scope.
46
+ */
47
+ viaGrant: boolean;
48
+ }
20
49
 
21
- /** Create a folder in a class. Teacher-only. */
22
- export async function createFolderRecord(
50
+ /**
51
+ * Scope-default-only check: used when there is no folder id yet (browsing or
52
+ * creating at the scope root). Does *not* consult explicit grants — those only
53
+ * apply to specific folders. Explicit-grant access to sub-folders surfaces via
54
+ * the "Shared with me" virtual scope, not by being a scope member.
55
+ */
56
+ async function requireScopeAccess(
23
57
  userId: string,
24
- input: {
25
- classId: string;
26
- name: string;
27
- parentFolderId?: string | null;
28
- color?: string;
58
+ scope: CloudScope,
59
+ level: AccessLevel
60
+ ): Promise<AccessResult> {
61
+ if (scope.kind === "CLASS") {
62
+ const classExists = await prisma.class.findFirst({
63
+ where: { id: scope.classId },
64
+ select: { id: true },
65
+ });
66
+ if (!classExists) {
67
+ throw new TRPCError({ code: "NOT_FOUND", message: "Class not found" });
68
+ }
29
69
  }
30
- ) {
31
- const classData = await findClassWithTeacher(input.classId, userId);
32
- if (!classData) {
70
+ const granted = await getScopeDefault(userId, scope);
71
+ if (!granted) {
33
72
  throw new TRPCError({
34
- code: "NOT_FOUND",
35
- message: "Class not found or you are not a teacher",
73
+ code: "FORBIDDEN",
74
+ message:
75
+ scope.kind === "CLASS"
76
+ ? "You don't have access to this class"
77
+ : scope.kind === "SCHOOL_STORAGE"
78
+ ? "You aren't a member of this institution"
79
+ : "You can only access your own personal storage",
36
80
  });
37
81
  }
38
-
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",
49
- });
50
- }
51
- }
52
-
53
- return createFolder({
54
- name: input.name,
55
- parentFolderId,
56
- color: input.color,
57
- });
58
- }
59
-
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) {
82
+ if (level === "WRITE" && granted !== "WRITE") {
64
83
  throw new TRPCError({
65
- code: "NOT_FOUND",
66
- message: "Folder not found",
84
+ code: "FORBIDDEN",
85
+ message:
86
+ scope.kind === "CLASS"
87
+ ? "Only class teachers can modify class files"
88
+ : "Only teachers and admins can modify institution storage",
67
89
  });
68
90
  }
69
- return folder;
70
- }
71
-
72
- export async function getChildFolders(classId: string) {
73
- const root = await findOrCreateRootFolder(classId);
74
- return findChildFolders(root.id);
91
+ // Scope-default-only path; access cannot be via a grant here.
92
+ return { scope, canWrite: granted === "WRITE", viaGrant: false };
75
93
  }
76
94
 
77
- export async function getFolderChildren(folderId: string, classId: string) {
78
- return findChildFolders(folderId);
95
+ /**
96
+ * Folder-level access check. Delegates to the central resolver which consults
97
+ * explicit grants in addition to the scope default. Returns the scope and a
98
+ * canWrite flag indicating whether the user has WRITE here (used by the UI to
99
+ * gate the "New folder" / "Upload" buttons during a READ-level browse).
100
+ *
101
+ * Pass `mutatesSelf: true` for actions that target the folder itself (rename,
102
+ * delete, move-this-folder). Drive-style: explicit grants don't authorize
103
+ * those — only scope-default WRITE does. Mutations of children should NOT
104
+ * pass this flag.
105
+ */
106
+ export async function requireFolderAccess(
107
+ userId: string,
108
+ folderId: string,
109
+ level: AccessLevel,
110
+ opts: { mutatesSelf?: boolean } = {}
111
+ ): Promise<AccessResult> {
112
+ const result = await requireFolderAccessResolved(userId, folderId, level, opts);
113
+ if (!result.scope) {
114
+ // resolveFolderScope returns null only for missing folders; the resolver
115
+ // would have already thrown NOT_FOUND in that case.
116
+ throw new TRPCError({ code: "NOT_FOUND", message: "Folder not found" });
117
+ }
118
+ let canWrite = result.scopeDefault === "WRITE";
119
+ if (!canWrite && level !== "WRITE") {
120
+ // We came in with READ; check whether the user *also* has WRITE so the UI
121
+ // can show the right affordances. Cheaper to do this only when needed.
122
+ try {
123
+ await requireFolderAccessResolved(userId, folderId, "WRITE");
124
+ canWrite = true;
125
+ } catch {
126
+ canWrite = false;
127
+ }
128
+ } else if (level === "WRITE") {
129
+ canWrite = true;
130
+ }
131
+ return { scope: result.scope, canWrite, viaGrant: result.viaGrant };
79
132
  }
80
133
 
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
- });
134
+ function scopesEqual(a: CloudScope, b: CloudScope): boolean {
135
+ if (a.kind !== b.kind) return false;
136
+ switch (a.kind) {
137
+ case "CLASS":
138
+ return a.classId === (b as typeof a).classId;
139
+ case "SCHOOL_STORAGE":
140
+ return a.schoolId === (b as typeof a).schoolId;
141
+ case "PERSONAL":
142
+ return a.userId === (b as typeof a).userId;
89
143
  }
90
- return rootWithContents;
91
144
  }
92
145
 
93
- export async function uploadFilesToFolder(
146
+ /** Browse a folder. Pass `folderId` to drill into a specific folder, or a `scope` to land on its root. */
147
+ export async function browseFolder(
94
148
  userId: string,
95
- classId: string,
96
- folderId: string,
97
- files: { name: string; type: string; size: number }[]
149
+ input: { folderId: string } | { scope: CloudScope }
98
150
  ) {
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
- });
151
+ let scope: CloudScope;
152
+ let folderId: string | undefined;
153
+ let canWrite: boolean;
154
+ let viaGrant: boolean;
155
+
156
+ if ("folderId" in input) {
157
+ folderId = input.folderId;
158
+ // `requireFolderAccess` consults explicit grants in addition to the scope
159
+ // default, so users can drill into folders shared with them via "Shared
160
+ // with me" without being scope members.
161
+ const access = await requireFolderAccess(userId, folderId, "READ");
162
+ scope = access.scope;
163
+ canWrite = access.canWrite;
164
+ viaGrant = access.viaGrant;
165
+ } else {
166
+ scope = input.scope;
167
+ const access = await requireScopeAccess(userId, scope, "READ");
168
+ canWrite = access.canWrite;
169
+ viaGrant = false;
105
170
  }
106
171
 
107
- const folder = await findFolderById(folderId);
172
+ const targetId = folderId ?? (await findOrCreateScopeRoot(scope)).id;
173
+
174
+ const [folder, breadcrumbs] = await Promise.all([
175
+ findFolderWithContents(targetId),
176
+ listFolderBreadcrumbs(targetId, userId),
177
+ ]);
178
+
108
179
  if (!folder) {
109
- throw new TRPCError({
110
- code: "NOT_FOUND",
111
- message: "Folder not found",
112
- });
180
+ throw new TRPCError({ code: "NOT_FOUND", message: "Folder not found" });
113
181
  }
114
182
 
115
- const uploadedFiles = await createDirectUploadFiles(files, userId, folderId);
116
183
  return {
117
- success: true,
118
- uploadedCount: uploadedFiles.length,
184
+ folder,
185
+ breadcrumbs,
186
+ canWrite,
187
+ scope,
188
+ /**
189
+ * When the viewer's access comes through an explicit grant (not scope
190
+ * membership), the UI should label the root as "Shared with me" rather
191
+ * than the underlying scope's title. The grantee may not even be a
192
+ * member of that scope — showing "My Files" or "Class Files" would be
193
+ * misleading. Null when the viewer is a regular scope member.
194
+ */
195
+ displayRoot: viaGrant
196
+ ? { kind: "SHARED_WITH_ME" as const, name: "Shared with me" }
197
+ : null,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Drive-style "Shared with me" — surfaces every top-most folder/file the user
203
+ * has an explicit grant on (filtering out anything already covered by their
204
+ * scope defaults). Shaped like a folder so the existing folder-browser UI can
205
+ * render it without special cases.
206
+ *
207
+ * The pseudo-folder has a synthetic id (`shared-with-me`) and the foreign-key
208
+ * columns set to null — the UI should never try to mutate it directly. The
209
+ * union shape matches `browseFolder`'s return type so the same client query
210
+ * can render either.
211
+ */
212
+ export async function browseSharedWithMe(userId: string) {
213
+ const { folders } = await listSharedWithMe(userId);
214
+ return {
215
+ folder: {
216
+ id: "shared-with-me",
217
+ name: "Shared with me",
218
+ color: null,
219
+ classId: null,
220
+ schoolStorageId: null,
221
+ personalOwnerId: null,
222
+ scope: "GENERIC" as const,
223
+ parentFolderId: null,
224
+ parentFolder: null,
225
+ childFolders: folders.map((f) => ({
226
+ id: f.id,
227
+ name: f.name,
228
+ color: f.color,
229
+ _count: { files: 0, childFolders: 0 },
230
+ })),
231
+ // No file-level grants in v1 — files always live under a granted folder.
232
+ files: [] as {
233
+ id: string;
234
+ name: string;
235
+ type: string;
236
+ size: number | null;
237
+ path: string;
238
+ uploadedAt: Date | null;
239
+ user: { id: string; username: string } | null;
240
+ }[],
241
+ },
242
+ breadcrumbs: [{ id: "shared-with-me", name: "Shared with me" }],
243
+ canWrite: false,
244
+ scope: { kind: "PERSONAL" as const, userId } satisfies CloudScope,
245
+ /** Same `displayRoot` shape as `browseFolder` so the client union is uniform. */
246
+ displayRoot: {
247
+ kind: "SHARED_WITH_ME" as const,
248
+ name: "Shared with me",
249
+ },
119
250
  };
120
251
  }
121
252
 
122
- export async function deleteFolderRecord(
253
+ export async function getRootFolder(userId: string, scope: CloudScope) {
254
+ const result = await browseFolder(userId, { scope });
255
+ return result.folder;
256
+ }
257
+
258
+ export async function getFolder(userId: string, folderId: string) {
259
+ const result = await browseFolder(userId, { folderId });
260
+ return result.folder;
261
+ }
262
+
263
+ export async function getFolderBreadcrumbs(userId: string, folderId: string) {
264
+ await requireFolderAccess(userId, folderId, "READ");
265
+ return listFolderBreadcrumbs(folderId);
266
+ }
267
+
268
+ export async function getFolderParents(userId: string, folderId: string) {
269
+ // Permission: user can read this folder.
270
+ await requireFolderAccess(userId, folderId, "READ");
271
+ const breadcrumbs = await listFolderBreadcrumbs(folderId);
272
+ // Existing API returned full folder records — re-fetch them.
273
+ return Promise.all(
274
+ breadcrumbs.map((b) => findFolderById(b.id))
275
+ );
276
+ }
277
+
278
+ export async function createFolderRecord(
123
279
  userId: string,
124
- classId: string,
125
- folderId: string
280
+ input: {
281
+ name: string;
282
+ parentFolderId?: string | null;
283
+ color?: string;
284
+ scope?: CloudScope;
285
+ }
126
286
  ) {
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
- });
287
+ let parentId: string;
288
+ if (input.parentFolderId) {
289
+ parentId = input.parentFolderId;
290
+ await requireFolderAccess(userId, parentId, "WRITE");
291
+ } else {
292
+ if (!input.scope) {
293
+ throw new TRPCError({
294
+ code: "BAD_REQUEST",
295
+ message: "scope is required when parentFolderId is omitted",
296
+ });
297
+ }
298
+ await requireScopeAccess(userId, input.scope, "WRITE");
299
+ parentId = (await findOrCreateScopeRoot(input.scope)).id;
133
300
  }
301
+ return createSubFolder({
302
+ name: input.name.trim(),
303
+ parentFolderId: parentId,
304
+ color: input.color,
305
+ });
306
+ }
134
307
 
135
- const folder = await findFolderByIdWithCount(folderId);
308
+ export async function updateFolderRecord(
309
+ userId: string,
310
+ input: { folderId: string; name?: string; color?: string }
311
+ ) {
312
+ const folder = await findFolderById(input.folderId);
136
313
  if (!folder) {
314
+ throw new TRPCError({ code: "NOT_FOUND", message: "Folder not found" });
315
+ }
316
+ if (!folder.parentFolderId) {
137
317
  throw new TRPCError({
138
- code: "NOT_FOUND",
139
- message: "Folder not found",
318
+ code: "BAD_REQUEST",
319
+ message: "Cannot rename a root folder",
140
320
  });
141
321
  }
322
+ // Rename targets the folder itself — Drive-style guard reserves this for
323
+ // scope-default WRITE (explicit grants don't authorize renaming the share).
324
+ await requireFolderAccess(userId, input.folderId, "WRITE", {
325
+ mutatesSelf: true,
326
+ });
327
+ return renameFolder(
328
+ input.folderId,
329
+ input.name?.trim() ?? folder.name,
330
+ input.color
331
+ );
332
+ }
142
333
 
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;
334
+ export async function deleteFolder(userId: string, folderId: string) {
335
+ const folder = await findFolderById(folderId);
336
+ if (!folder) {
337
+ throw new TRPCError({ code: "NOT_FOUND", message: "Folder not found" });
154
338
  }
155
- if (!belongsToClass) {
339
+ if (!folder.parentFolderId) {
156
340
  throw new TRPCError({
157
- code: "FORBIDDEN",
158
- message: "Folder does not belong to this class",
341
+ code: "BAD_REQUEST",
342
+ message: "Cannot delete a root folder",
159
343
  });
160
344
  }
161
-
345
+ // Delete targets the folder itself — same Drive-style guard as rename.
346
+ await requireFolderAccess(userId, folderId, "WRITE", { mutatesSelf: true });
162
347
  await deleteFolderById(folderId);
163
- return {
164
- success: true,
165
- deletedFiles: folder._count.files,
166
- deletedFolders: folder._count.childFolders + 1,
167
- };
348
+ return { success: true };
168
349
  }
169
350
 
170
351
  export async function moveFolder(
171
352
  userId: string,
172
- folderId: string,
173
- targetParentFolderId: string,
174
- classId: string
353
+ input: { folderId: string; targetParentFolderId: string }
175
354
  ) {
176
- const classData = await findClassWithTeacher(classId, userId);
177
- if (!classData) {
355
+ if (input.folderId === input.targetParentFolderId) {
178
356
  throw new TRPCError({
179
- code: "FORBIDDEN",
180
- message: "You must be a teacher of this class to move folders",
357
+ code: "BAD_REQUEST",
358
+ message: "Cannot move a folder into itself",
181
359
  });
182
360
  }
183
-
184
- const folder = await findFolderById(folderId);
185
- if (!folder) {
186
- throw new TRPCError({
187
- code: "NOT_FOUND",
188
- message: "Folder not found",
189
- });
361
+ const [sourceScope, targetScope] = await Promise.all([
362
+ resolveFolderScope(input.folderId),
363
+ resolveFolderScope(input.targetParentFolderId),
364
+ ]);
365
+ if (!sourceScope || !targetScope) {
366
+ throw new TRPCError({ code: "NOT_FOUND", message: "Folder not found" });
190
367
  }
191
-
192
- if (!folder.parentFolderId) {
368
+ if (!scopesEqual(sourceScope, targetScope)) {
193
369
  throw new TRPCError({
194
370
  code: "BAD_REQUEST",
195
- message: "Cannot move the root folder",
371
+ message: "Cannot move folders between scopes",
196
372
  });
197
373
  }
374
+ // Moving the folder targets it (and the new parent's contents). Source-side
375
+ // is mutates-self; target-side is regular WRITE on the new parent.
376
+ await requireFolderAccess(userId, input.folderId, "WRITE", {
377
+ mutatesSelf: true,
378
+ });
379
+ await requireFolderAccess(userId, input.targetParentFolderId, "WRITE");
198
380
 
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) {
381
+ // Don't let a folder land under one of its own descendants.
382
+ let cursor: string | null = input.targetParentFolderId;
383
+ while (cursor) {
384
+ if (cursor === input.folderId) {
209
385
  throw new TRPCError({
210
386
  code: "BAD_REQUEST",
211
- message: "Cannot move a folder into itself",
387
+ message: "Cannot move a folder into its own descendant",
212
388
  });
213
389
  }
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",
390
+ const node = await prisma.folder.findUnique({
391
+ where: { id: cursor },
392
+ select: { parentFolderId: true },
249
393
  });
394
+ cursor = node?.parentFolderId ?? null;
250
395
  }
251
396
 
252
- return updateFolder(folderId, {
253
- name: name.trim(),
254
- ...(color && { color }),
397
+ await prisma.folder.update({
398
+ where: { id: input.folderId },
399
+ data: { parentFolderId: input.targetParentFolderId },
255
400
  });
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;
401
+ return { success: true };
270
402
  }
271
403
 
272
404
  export async function getFolderUploadUrls(
273
405
  userId: string,
274
- classId: string,
275
- folderId: string,
276
- files: { name: string; type: string; size: number }[]
406
+ input: { folderId: string; files: DirectFileData[] }
277
407
  ) {
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
- }
408
+ await requireFolderAccess(userId, input.folderId, "WRITE");
409
+ return createDirectUploadFiles(input.files, userId, input.folderId);
410
+ }
285
411
 
286
- const folder = await findFolderById(folderId);
287
- if (!folder) {
288
- throw new TRPCError({
289
- code: "NOT_FOUND",
290
- message: "Folder not found",
291
- });
292
- }
412
+ export async function confirmFolderUpload(
413
+ _userId: string,
414
+ input: { fileId: string; uploadSuccess: boolean; errorMessage?: string }
415
+ ) {
416
+ // Permission was already checked at upload-url issuance — the file record
417
+ // exists with PENDING status, and the user holds the session id needed to
418
+ // PUT bytes. Confirming flips status, so we just trust the call.
419
+ await confirmDirectUpload(
420
+ input.fileId,
421
+ input.uploadSuccess,
422
+ input.errorMessage
423
+ );
424
+ return { success: true };
425
+ }
293
426
 
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
- }
427
+ /** What scopes can this user pick from in cloud-storage UIs? */
428
+ export async function listUserScopes(userId: string) {
429
+ const memberships = await prisma.schoolMembership.findMany({
430
+ where: { userId, status: "ACTIVE" },
431
+ select: {
432
+ role: true,
433
+ school: {
434
+ select: { id: true, name: true, logoId: true },
435
+ },
436
+ },
437
+ orderBy: { createdAt: "asc" },
438
+ });
307
439
 
308
- if (!belongsToClass) {
309
- throw new TRPCError({
310
- code: "FORBIDDEN",
311
- message: "Folder does not belong to this class",
312
- });
313
- }
440
+ const classes = await prisma.class.findMany({
441
+ where: {
442
+ OR: [
443
+ { teachers: { some: { id: userId } } },
444
+ { students: { some: { id: userId } } },
445
+ ],
446
+ },
447
+ select: {
448
+ id: true,
449
+ name: true,
450
+ subject: true,
451
+ section: true,
452
+ color: true,
453
+ schoolId: true,
454
+ },
455
+ orderBy: { name: "asc" },
456
+ });
457
+
458
+ // Light counts for the "Shared with me" bucket so the UI can hide the entry
459
+ // when there's nothing to show. Cheap to compute — just counts top-most
460
+ // grants, not folder/file metadata.
461
+ const sharedWithMe = await listSharedWithMe(userId);
314
462
 
315
- return createDirectUploadFiles(files, userId, folderId);
463
+ return {
464
+ personal: { kind: "PERSONAL" as const, userId },
465
+ schools: memberships.map((m) => ({
466
+ id: m.school.id,
467
+ name: m.school.name,
468
+ logoId: m.school.logoId,
469
+ role: m.role,
470
+ })),
471
+ classes,
472
+ sharedWithMe: {
473
+ kind: "SHARED_WITH_ME" as const,
474
+ folderCount: sharedWithMe.folders.length,
475
+ fileCount: sharedWithMe.files.length,
476
+ },
477
+ };
316
478
  }