@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
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Tag service — institution-scoped tag CRUD and assignment to taggable
3
+ * targets (users, classes, rubrics, boundaries).
4
+ */
5
+ import { TRPCError } from "@trpc/server";
6
+ import { prisma } from "../lib/prisma.js";
7
+ import {
8
+ attachTagToTarget,
9
+ createTag,
10
+ deleteTag,
11
+ detachTagFromTarget,
12
+ getTag,
13
+ listTagsAttachedTo,
14
+ listTagsForSchool,
15
+ type TagTarget,
16
+ updateTag,
17
+ } from "../models/tag.js";
18
+ import {
19
+ findActiveSchoolAdminMembership,
20
+ findSchoolMembership,
21
+ } from "../models/school.js";
22
+
23
+ async function requireAdmin(schoolId: string, userId: string) {
24
+ const membership = await findActiveSchoolAdminMembership(schoolId, userId);
25
+ if (!membership) {
26
+ throw new TRPCError({
27
+ code: "FORBIDDEN",
28
+ message: "Only school admins can manage tags",
29
+ });
30
+ }
31
+ }
32
+
33
+ async function requireMember(schoolId: string, userId: string) {
34
+ const membership = await findSchoolMembership(schoolId, userId);
35
+ if (!membership || membership.status !== "ACTIVE") {
36
+ throw new TRPCError({
37
+ code: "FORBIDDEN",
38
+ message: "You aren't a member of this institution",
39
+ });
40
+ }
41
+ return membership;
42
+ }
43
+
44
+ export async function listTags(
45
+ userId: string,
46
+ input: { schoolId: string; departmentId?: string | null }
47
+ ) {
48
+ await requireMember(input.schoolId, userId);
49
+ return listTagsForSchool(input.schoolId, {
50
+ departmentId: input.departmentId,
51
+ });
52
+ }
53
+
54
+ export async function createTagRecord(
55
+ userId: string,
56
+ input: {
57
+ schoolId: string;
58
+ departmentId?: string | null;
59
+ name: string;
60
+ color?: string;
61
+ }
62
+ ) {
63
+ await requireAdmin(input.schoolId, userId);
64
+ if (input.departmentId) {
65
+ const dept = await prisma.department.findUnique({
66
+ where: { id: input.departmentId },
67
+ select: { schoolId: true },
68
+ });
69
+ if (!dept || dept.schoolId !== input.schoolId) {
70
+ throw new TRPCError({
71
+ code: "BAD_REQUEST",
72
+ message: "Department does not belong to this institution",
73
+ });
74
+ }
75
+ }
76
+ return createTag(input);
77
+ }
78
+
79
+ export async function updateTagRecord(
80
+ userId: string,
81
+ input: {
82
+ tagId: string;
83
+ name?: string;
84
+ color?: string;
85
+ departmentId?: string | null;
86
+ }
87
+ ) {
88
+ const tag = await getTag(input.tagId);
89
+ if (!tag) {
90
+ throw new TRPCError({ code: "NOT_FOUND", message: "Tag not found" });
91
+ }
92
+ await requireAdmin(tag.schoolId, userId);
93
+ if (input.departmentId) {
94
+ const dept = await prisma.department.findUnique({
95
+ where: { id: input.departmentId },
96
+ select: { schoolId: true },
97
+ });
98
+ if (!dept || dept.schoolId !== tag.schoolId) {
99
+ throw new TRPCError({
100
+ code: "BAD_REQUEST",
101
+ message: "Department does not belong to this institution",
102
+ });
103
+ }
104
+ }
105
+ return updateTag(input.tagId, {
106
+ name: input.name,
107
+ color: input.color,
108
+ departmentId: input.departmentId,
109
+ });
110
+ }
111
+
112
+ export async function deleteTagRecord(userId: string, tagId: string) {
113
+ const tag = await getTag(tagId);
114
+ if (!tag) {
115
+ throw new TRPCError({ code: "NOT_FOUND", message: "Tag not found" });
116
+ }
117
+ await requireAdmin(tag.schoolId, userId);
118
+ await deleteTag(tagId);
119
+ return { success: true };
120
+ }
121
+
122
+ async function ensureTargetInScope(
123
+ schoolId: string,
124
+ target: TagTarget,
125
+ targetId: string
126
+ ) {
127
+ switch (target) {
128
+ case "USER": {
129
+ const inSchool = await prisma.schoolMembership.findFirst({
130
+ where: { schoolId, userId: targetId, status: "ACTIVE" },
131
+ select: { id: true },
132
+ });
133
+ if (!inSchool) {
134
+ throw new TRPCError({
135
+ code: "BAD_REQUEST",
136
+ message: "User is not in this institution",
137
+ });
138
+ }
139
+ return;
140
+ }
141
+ case "CLASS": {
142
+ const inSchool = await prisma.class.findFirst({
143
+ where: { id: targetId, schoolId },
144
+ select: { id: true },
145
+ });
146
+ if (!inSchool) {
147
+ throw new TRPCError({
148
+ code: "BAD_REQUEST",
149
+ message: "Class is not in this institution",
150
+ });
151
+ }
152
+ return;
153
+ }
154
+ case "RUBRIC": {
155
+ const inSchool = await prisma.markScheme.findFirst({
156
+ where: {
157
+ id: targetId,
158
+ OR: [
159
+ { class: { schoolId } },
160
+ { department: { schoolId } },
161
+ ],
162
+ },
163
+ select: { id: true },
164
+ });
165
+ if (!inSchool) {
166
+ throw new TRPCError({
167
+ code: "BAD_REQUEST",
168
+ message: "Rubric is not in this institution",
169
+ });
170
+ }
171
+ return;
172
+ }
173
+ case "BOUNDARY": {
174
+ const inSchool = await prisma.gradingBoundary.findFirst({
175
+ where: {
176
+ id: targetId,
177
+ OR: [
178
+ { class: { schoolId } },
179
+ { department: { schoolId } },
180
+ ],
181
+ },
182
+ select: { id: true },
183
+ });
184
+ if (!inSchool) {
185
+ throw new TRPCError({
186
+ code: "BAD_REQUEST",
187
+ message: "Boundary is not in this institution",
188
+ });
189
+ }
190
+ return;
191
+ }
192
+ }
193
+ }
194
+
195
+ export async function attachTag(
196
+ userId: string,
197
+ input: { tagId: string; target: TagTarget; targetId: string }
198
+ ) {
199
+ const tag = await getTag(input.tagId);
200
+ if (!tag) {
201
+ throw new TRPCError({ code: "NOT_FOUND", message: "Tag not found" });
202
+ }
203
+ await requireMember(tag.schoolId, userId);
204
+ await ensureTargetInScope(tag.schoolId, input.target, input.targetId);
205
+ return attachTagToTarget(input);
206
+ }
207
+
208
+ export async function detachTag(
209
+ userId: string,
210
+ input: { tagId: string; target: TagTarget; targetId: string }
211
+ ) {
212
+ const tag = await getTag(input.tagId);
213
+ if (!tag) {
214
+ throw new TRPCError({ code: "NOT_FOUND", message: "Tag not found" });
215
+ }
216
+ await requireMember(tag.schoolId, userId);
217
+ return detachTagFromTarget(input);
218
+ }
219
+
220
+ export async function getAttachedTags(
221
+ userId: string,
222
+ input: { target: TagTarget; targetId: string }
223
+ ) {
224
+ const tags = await listTagsAttachedTo(input.target, input.targetId);
225
+ if (tags[0]) {
226
+ await requireMember(tags[0].schoolId, userId);
227
+ }
228
+ return tags;
229
+ }
@@ -26,7 +26,6 @@ export async function ensureAIUserExists(): Promise<void> {
26
26
  email: 'ai@studious-lms.com',
27
27
  password: 'ai-system-user', // Not used for login
28
28
  verified: true,
29
- role: 'NONE', // Special role for AI
30
29
  profile: {
31
30
  create: {
32
31
  displayName: 'AI Assistant',
@@ -0,0 +1,463 @@
1
+ import { beforeEach, describe, expect, test, vi } from "vitest";
2
+ import { TRPCError } from "@trpc/server";
3
+
4
+ const { prismaMocks, resolveFolderScope } = vi.hoisted(() => ({
5
+ prismaMocks: {
6
+ user: { findUnique: vi.fn() },
7
+ class: { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn() },
8
+ department: { findMany: vi.fn(), findUnique: vi.fn() },
9
+ schoolMembership: { findMany: vi.fn(), findFirst: vi.fn() },
10
+ folder: { findUnique: vi.fn() },
11
+ file: { findUnique: vi.fn() },
12
+ tag: { findUnique: vi.fn() },
13
+ permission: {
14
+ findFirst: vi.fn(),
15
+ findMany: vi.fn(),
16
+ findUnique: vi.fn(),
17
+ create: vi.fn(),
18
+ update: vi.fn(),
19
+ delete: vi.fn(),
20
+ },
21
+ },
22
+ resolveFolderScope: vi.fn(),
23
+ }));
24
+
25
+ vi.mock("../../../src/lib/prisma.js", () => ({
26
+ prisma: prismaMocks,
27
+ }));
28
+
29
+ vi.mock("../../../src/models/cloudStorage.js", () => ({
30
+ resolveFolderScope,
31
+ }));
32
+
33
+ import {
34
+ expandUserPrincipals,
35
+ getScopeDefault,
36
+ grantPermission,
37
+ listSharedWithMe,
38
+ requireFileAccess,
39
+ requireFolderAccess,
40
+ } from "../../../src/services/permissions.js";
41
+
42
+ function setNoMemberships() {
43
+ prismaMocks.user.findUnique.mockResolvedValue({ tags: [] });
44
+ prismaMocks.class.findMany.mockResolvedValue([]);
45
+ prismaMocks.department.findMany.mockResolvedValue([]);
46
+ prismaMocks.schoolMembership.findMany.mockResolvedValue([]);
47
+ }
48
+
49
+ beforeEach(() => {
50
+ vi.clearAllMocks();
51
+ prismaMocks.permission.findFirst.mockResolvedValue(null);
52
+ prismaMocks.permission.findMany.mockResolvedValue([]);
53
+ });
54
+
55
+ describe("expandUserPrincipals", () => {
56
+ test("collects tags, classes (split by role), departments, and active school memberships", async () => {
57
+ prismaMocks.user.findUnique.mockResolvedValue({
58
+ tags: [{ id: "tag-a" }, { id: "tag-b" }],
59
+ });
60
+ prismaMocks.class.findMany
61
+ .mockResolvedValueOnce([{ id: "class-teach" }])
62
+ .mockResolvedValueOnce([
63
+ { id: "class-stud-1" },
64
+ { id: "class-stud-2" },
65
+ ]);
66
+ prismaMocks.department.findMany.mockResolvedValue([{ id: "dep-1" }]);
67
+ prismaMocks.schoolMembership.findMany.mockResolvedValue([
68
+ { schoolId: "school-1" },
69
+ ]);
70
+
71
+ const principals = await expandUserPrincipals("user-1");
72
+
73
+ expect(principals.tagIds).toEqual(["tag-a", "tag-b"]);
74
+ expect(principals.classTeacherIds).toEqual(["class-teach"]);
75
+ expect(principals.classStudentIds).toEqual(["class-stud-1", "class-stud-2"]);
76
+ expect(principals.classIds.sort()).toEqual(
77
+ ["class-teach", "class-stud-1", "class-stud-2"].sort()
78
+ );
79
+ expect(principals.departmentIds).toEqual(["dep-1"]);
80
+ expect(principals.schoolIds).toEqual(["school-1"]);
81
+ });
82
+
83
+ test("returns empty arrays when the user has no memberships", async () => {
84
+ setNoMemberships();
85
+ const principals = await expandUserPrincipals("user-1");
86
+ expect(principals.tagIds).toEqual([]);
87
+ expect(principals.classIds).toEqual([]);
88
+ expect(principals.departmentIds).toEqual([]);
89
+ expect(principals.schoolIds).toEqual([]);
90
+ });
91
+ });
92
+
93
+ describe("getScopeDefault", () => {
94
+ test("class teacher gets WRITE, student gets READ, outsider gets null", async () => {
95
+ prismaMocks.class.findFirst
96
+ .mockResolvedValueOnce({
97
+ id: "class-1",
98
+ schoolId: null,
99
+ teachers: [{ id: "user-1" }],
100
+ students: [],
101
+ })
102
+ .mockResolvedValueOnce({
103
+ id: "class-1",
104
+ schoolId: null,
105
+ teachers: [],
106
+ students: [{ id: "user-2" }],
107
+ })
108
+ .mockResolvedValueOnce({
109
+ id: "class-1",
110
+ schoolId: null,
111
+ teachers: [],
112
+ students: [],
113
+ });
114
+
115
+ expect(
116
+ await getScopeDefault("user-1", { kind: "CLASS", classId: "class-1" })
117
+ ).toBe("WRITE");
118
+ expect(
119
+ await getScopeDefault("user-2", { kind: "CLASS", classId: "class-1" })
120
+ ).toBe("READ");
121
+ expect(
122
+ await getScopeDefault("user-3", { kind: "CLASS", classId: "class-1" })
123
+ ).toBeNull();
124
+ });
125
+
126
+ test("school admin gets WRITE on class even without being teacher/student", async () => {
127
+ prismaMocks.class.findFirst.mockResolvedValueOnce({
128
+ id: "class-1",
129
+ schoolId: "school-1",
130
+ teachers: [],
131
+ students: [],
132
+ });
133
+ prismaMocks.schoolMembership.findFirst.mockResolvedValueOnce({
134
+ id: "membership-1",
135
+ });
136
+
137
+ expect(
138
+ await getScopeDefault("admin-1", { kind: "CLASS", classId: "class-1" })
139
+ ).toBe("WRITE");
140
+ });
141
+
142
+ test("school storage: ADMIN/TEACHER write, STUDENT reads, non-member denied", async () => {
143
+ prismaMocks.schoolMembership.findFirst
144
+ .mockResolvedValueOnce({ role: "TEACHER" })
145
+ .mockResolvedValueOnce({ role: "STUDENT" })
146
+ .mockResolvedValueOnce(null);
147
+
148
+ expect(
149
+ await getScopeDefault("u", {
150
+ kind: "SCHOOL_STORAGE",
151
+ schoolId: "school-1",
152
+ })
153
+ ).toBe("WRITE");
154
+ expect(
155
+ await getScopeDefault("u", {
156
+ kind: "SCHOOL_STORAGE",
157
+ schoolId: "school-1",
158
+ })
159
+ ).toBe("READ");
160
+ expect(
161
+ await getScopeDefault("u", {
162
+ kind: "SCHOOL_STORAGE",
163
+ schoolId: "school-1",
164
+ })
165
+ ).toBeNull();
166
+ });
167
+
168
+ test("personal scope only the owner has WRITE", async () => {
169
+ expect(
170
+ await getScopeDefault("user-1", { kind: "PERSONAL", userId: "user-1" })
171
+ ).toBe("WRITE");
172
+ expect(
173
+ await getScopeDefault("user-2", { kind: "PERSONAL", userId: "user-1" })
174
+ ).toBeNull();
175
+ });
176
+ });
177
+
178
+ describe("requireFolderAccess", () => {
179
+ beforeEach(() => {
180
+ setNoMemberships();
181
+ });
182
+
183
+ test("scope default grants access without consulting permissions", async () => {
184
+ resolveFolderScope.mockResolvedValue({
185
+ kind: "PERSONAL",
186
+ userId: "user-1",
187
+ });
188
+
189
+ const result = await requireFolderAccess("user-1", "folder-1", "READ");
190
+ expect(result.scopeDefault).toBe("WRITE");
191
+ expect(result.viaGrant).toBe(false);
192
+ expect(prismaMocks.permission.findFirst).not.toHaveBeenCalled();
193
+ });
194
+
195
+ test("ancestor inheritance: grant on parent folder lets a non-member access a child", async () => {
196
+ // user is in NO scope (PERSONAL of someone else => null default)
197
+ resolveFolderScope.mockResolvedValue({
198
+ kind: "PERSONAL",
199
+ userId: "owner-x",
200
+ });
201
+ // child folder has parent
202
+ prismaMocks.folder.findUnique
203
+ .mockResolvedValueOnce({ parentFolderId: "folder-parent" })
204
+ .mockResolvedValueOnce({ parentFolderId: null });
205
+ prismaMocks.permission.findFirst.mockResolvedValueOnce({
206
+ folderId: "folder-parent",
207
+ });
208
+
209
+ const result = await requireFolderAccess("user-1", "folder-child", "READ");
210
+ expect(result.viaGrant).toBe(true);
211
+ expect(result.scopeDefault).toBeNull();
212
+ });
213
+
214
+ test("direct grant on the folder itself grants access", async () => {
215
+ resolveFolderScope.mockResolvedValue({
216
+ kind: "PERSONAL",
217
+ userId: "owner-x",
218
+ });
219
+ prismaMocks.folder.findUnique.mockResolvedValueOnce({
220
+ parentFolderId: null,
221
+ });
222
+ prismaMocks.permission.findFirst.mockResolvedValueOnce({
223
+ folderId: "folder-1",
224
+ });
225
+
226
+ const result = await requireFolderAccess("user-1", "folder-1", "READ");
227
+ expect(result.viaGrant).toBe(true);
228
+ });
229
+
230
+ test("denies when neither scope default nor any ancestor grant matches", async () => {
231
+ resolveFolderScope.mockResolvedValue({
232
+ kind: "PERSONAL",
233
+ userId: "owner-x",
234
+ });
235
+ prismaMocks.folder.findUnique.mockResolvedValueOnce({
236
+ parentFolderId: null,
237
+ });
238
+ prismaMocks.permission.findFirst.mockResolvedValueOnce(null);
239
+
240
+ await expect(
241
+ requireFolderAccess("user-1", "folder-1", "READ")
242
+ ).rejects.toThrow(TRPCError);
243
+ });
244
+
245
+ test("WRITE denied when only a READ grant exists", async () => {
246
+ resolveFolderScope.mockResolvedValue({
247
+ kind: "PERSONAL",
248
+ userId: "owner-x",
249
+ });
250
+ prismaMocks.folder.findUnique.mockResolvedValueOnce({
251
+ parentFolderId: null,
252
+ });
253
+ // resolver query is filtered with `level: "WRITE"` so a READ grant
254
+ // simply doesn't match — returns null.
255
+ prismaMocks.permission.findFirst.mockResolvedValueOnce(null);
256
+
257
+ await expect(
258
+ requireFolderAccess("user-1", "folder-1", "WRITE")
259
+ ).rejects.toThrow(TRPCError);
260
+ });
261
+
262
+ test("Drive-style: mutatesSelf WRITE is denied via grant, allowed via scope default", async () => {
263
+ // Case A: grantee has WRITE via explicit grant, but the action targets
264
+ // the share root itself — must be rejected with the Drive-style message.
265
+ resolveFolderScope.mockResolvedValueOnce({
266
+ kind: "PERSONAL",
267
+ userId: "owner-x", // user-1 is NOT the owner -> no scope default
268
+ });
269
+
270
+ await expect(
271
+ requireFolderAccess("user-1", "folder-1", "WRITE", { mutatesSelf: true })
272
+ ).rejects.toThrow(/owner/i);
273
+ // We never even got to the grant lookup because mutatesSelf short-circuits.
274
+ expect(prismaMocks.permission.findFirst).not.toHaveBeenCalled();
275
+
276
+ // Case B: the actual scope owner can mutatesSelf without issue.
277
+ resolveFolderScope.mockResolvedValueOnce({
278
+ kind: "PERSONAL",
279
+ userId: "user-1",
280
+ });
281
+ const result = await requireFolderAccess(
282
+ "user-1",
283
+ "folder-1",
284
+ "WRITE",
285
+ { mutatesSelf: true }
286
+ );
287
+ expect(result.scopeDefault).toBe("WRITE");
288
+ expect(result.viaGrant).toBe(false);
289
+ });
290
+ });
291
+
292
+ describe("requireFileAccess", () => {
293
+ beforeEach(() => {
294
+ setNoMemberships();
295
+ });
296
+
297
+ test("delegates to folder access when the file lives in a folder", async () => {
298
+ prismaMocks.file.findUnique.mockResolvedValueOnce({
299
+ id: "file-1",
300
+ userId: null,
301
+ folderId: "folder-x",
302
+ });
303
+ resolveFolderScope.mockResolvedValue({
304
+ kind: "PERSONAL",
305
+ userId: "user-1",
306
+ });
307
+
308
+ const result = await requireFileAccess("user-1", "file-1", "READ");
309
+ expect(result.scopeDefault).toBe("WRITE");
310
+ });
311
+
312
+ test("unfiled files fall back to owner-only access", async () => {
313
+ prismaMocks.file.findUnique.mockResolvedValueOnce({
314
+ id: "file-1",
315
+ userId: "user-1",
316
+ folderId: null,
317
+ });
318
+
319
+ const result = await requireFileAccess("user-1", "file-1", "READ");
320
+ expect(result.scopeDefault).toBe("WRITE");
321
+ expect(result.viaGrant).toBe(false);
322
+ });
323
+
324
+ test("unfiled files with another owner deny access", async () => {
325
+ prismaMocks.file.findUnique.mockResolvedValueOnce({
326
+ id: "file-1",
327
+ userId: "owner-x",
328
+ folderId: null,
329
+ });
330
+
331
+ await expect(
332
+ requireFileAccess("user-1", "file-1", "READ")
333
+ ).rejects.toThrow(TRPCError);
334
+ });
335
+ });
336
+
337
+ describe("listSharedWithMe (topmost-grant computation)", () => {
338
+ beforeEach(() => {
339
+ setNoMemberships();
340
+ });
341
+
342
+ test("filters out grants whose ancestor is also granted (collapse to the topmost)", async () => {
343
+ prismaMocks.permission.findMany.mockResolvedValueOnce([
344
+ { id: "g-parent", folderId: "parent" },
345
+ { id: "g-child", folderId: "child" },
346
+ ]);
347
+
348
+ // Ancestor walks: child -> parent; parent -> root (no parent).
349
+ prismaMocks.folder.findUnique
350
+ // Walk for "parent" (ancestors)
351
+ .mockResolvedValueOnce({ parentFolderId: null })
352
+ // Folder details fetch for "parent"
353
+ .mockResolvedValueOnce({
354
+ id: "parent",
355
+ name: "Parent",
356
+ color: null,
357
+ })
358
+ // Walk for "child" (ancestors) — first hit child, then parent
359
+ .mockResolvedValueOnce({ parentFolderId: "parent" })
360
+ .mockResolvedValueOnce({ parentFolderId: null });
361
+
362
+ resolveFolderScope.mockResolvedValue(null);
363
+
364
+ const result = await listSharedWithMe("user-1");
365
+ expect(result.folders.map((f) => f.id)).toEqual(["parent"]);
366
+ expect(result.files).toEqual([]);
367
+ });
368
+
369
+ test("filters out grants already covered by the user's scope default", async () => {
370
+ prismaMocks.permission.findMany.mockResolvedValueOnce([
371
+ { id: "g-1", folderId: "folder-1" },
372
+ ]);
373
+ prismaMocks.folder.findUnique.mockResolvedValueOnce({
374
+ parentFolderId: null,
375
+ });
376
+ resolveFolderScope.mockResolvedValue({
377
+ kind: "PERSONAL",
378
+ userId: "user-1",
379
+ });
380
+ // user-1 IS the owner -> scope default WRITE -> filtered out
381
+
382
+ const result = await listSharedWithMe("user-1");
383
+ expect(result.folders).toEqual([]);
384
+ });
385
+ });
386
+
387
+ describe("grantPermission cross-school leak prevention", () => {
388
+ beforeEach(() => {
389
+ setNoMemberships();
390
+ });
391
+
392
+ test("rejects a TAG principal whose school differs from the target's scope school", async () => {
393
+ // Granter has WRITE via scope default (class teacher in school-A).
394
+ resolveFolderScope.mockResolvedValue({
395
+ kind: "CLASS",
396
+ classId: "class-A",
397
+ });
398
+ prismaMocks.class.findFirst.mockResolvedValueOnce({
399
+ id: "class-A",
400
+ schoolId: "school-A",
401
+ teachers: [{ id: "granter-1" }],
402
+ students: [],
403
+ });
404
+ // resolveTargetSchoolId(folder) -> walks scope -> school-A
405
+ prismaMocks.class.findUnique
406
+ .mockResolvedValueOnce({ schoolId: "school-A" })
407
+ // principalSchoolId(TAG: tag-X) -> school-B
408
+ .mockResolvedValue(null);
409
+ prismaMocks.tag.findUnique.mockResolvedValueOnce({ schoolId: "school-B" });
410
+
411
+ await expect(
412
+ grantPermission("granter-1", {
413
+ folderId: "folder-1",
414
+ principal: { kind: "TAG", tagId: "tag-X" },
415
+ level: "READ",
416
+ })
417
+ ).rejects.toThrow(/different school/);
418
+ });
419
+
420
+ test("rejects sharing a personal-scope folder with a non-USER principal", async () => {
421
+ resolveFolderScope.mockResolvedValue({
422
+ kind: "PERSONAL",
423
+ userId: "granter-1",
424
+ });
425
+ // resolveTargetSchoolId(folder) -> personal scope -> null
426
+ prismaMocks.tag.findUnique.mockResolvedValueOnce({ schoolId: "school-A" });
427
+
428
+ await expect(
429
+ grantPermission("granter-1", {
430
+ folderId: "folder-1",
431
+ principal: { kind: "TAG", tagId: "tag-X" },
432
+ level: "READ",
433
+ })
434
+ ).rejects.toThrow(/specific users/i);
435
+ });
436
+
437
+ test("re-granting the same (folder, principal) updates the existing row instead of erroring", async () => {
438
+ // Granter has WRITE via personal-scope ownership.
439
+ resolveFolderScope.mockResolvedValue({
440
+ kind: "PERSONAL",
441
+ userId: "granter-1",
442
+ });
443
+ // Existing READ grant for user-X on this folder.
444
+ prismaMocks.permission.findFirst.mockResolvedValueOnce({ id: "perm-1" });
445
+ prismaMocks.permission.update.mockResolvedValueOnce({
446
+ id: "perm-1",
447
+ level: "WRITE",
448
+ });
449
+
450
+ const result = await grantPermission("granter-1", {
451
+ folderId: "folder-1",
452
+ principal: { kind: "USER", userId: "user-X" },
453
+ level: "WRITE",
454
+ });
455
+
456
+ expect(prismaMocks.permission.update).toHaveBeenCalledWith({
457
+ where: { id: "perm-1" },
458
+ data: { level: "WRITE", grantedById: "granter-1" },
459
+ });
460
+ expect(prismaMocks.permission.create).not.toHaveBeenCalled();
461
+ expect(result.level).toBe("WRITE");
462
+ });
463
+ });