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