@studious-lms/server 1.8.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 (71) 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/agenda.d.ts +2 -2
  5. package/dist/models/announcement.d.ts +2 -2
  6. package/dist/models/assignment.d.ts +33 -33
  7. package/dist/models/class.d.ts +2 -2
  8. package/dist/models/cloudStorage.d.ts +14 -1
  9. package/dist/models/cloudStorage.d.ts.map +1 -1
  10. package/dist/models/cloudStorage.js +123 -3
  11. package/dist/models/cloudStorage.js.map +1 -1
  12. package/dist/models/event.d.ts +3 -3
  13. package/dist/models/file.d.ts +27 -26
  14. package/dist/models/file.d.ts.map +1 -1
  15. package/dist/models/file.js +4 -39
  16. package/dist/models/file.js.map +1 -1
  17. package/dist/models/labChat.d.ts +6 -6
  18. package/dist/models/section.d.ts +1 -1
  19. package/dist/routers/_app.d.ts +354 -100
  20. package/dist/routers/_app.d.ts.map +1 -1
  21. package/dist/routers/_app.js +4 -2
  22. package/dist/routers/_app.js.map +1 -1
  23. package/dist/routers/agenda.d.ts +2 -2
  24. package/dist/routers/assignment.d.ts +31 -31
  25. package/dist/routers/class.d.ts +5 -5
  26. package/dist/routers/event.d.ts +2 -2
  27. package/dist/routers/file.d.ts +2 -2
  28. package/dist/routers/folder.d.ts +24 -0
  29. package/dist/routers/folder.d.ts.map +1 -1
  30. package/dist/routers/folder.js +27 -5
  31. package/dist/routers/folder.js.map +1 -1
  32. package/dist/routers/labChat.d.ts +2 -2
  33. package/dist/routers/newtonChat.d.ts +1 -1
  34. package/dist/routers/permissions.d.ts +120 -0
  35. package/dist/routers/permissions.d.ts.map +1 -0
  36. package/dist/routers/permissions.js +50 -0
  37. package/dist/routers/permissions.js.map +1 -0
  38. package/dist/routers/tag.d.ts +5 -5
  39. package/dist/services/agenda.d.ts +2 -2
  40. package/dist/services/assignment.d.ts +31 -31
  41. package/dist/services/class.d.ts +5 -5
  42. package/dist/services/class.js +3 -3
  43. package/dist/services/class.js.map +1 -1
  44. package/dist/services/event.d.ts +2 -2
  45. package/dist/services/file.d.ts +22 -5
  46. package/dist/services/file.d.ts.map +1 -1
  47. package/dist/services/file.js +81 -45
  48. package/dist/services/file.js.map +1 -1
  49. package/dist/services/folder.d.ts +97 -2
  50. package/dist/services/folder.d.ts.map +1 -1
  51. package/dist/services/folder.js +158 -87
  52. package/dist/services/folder.js.map +1 -1
  53. package/dist/services/labChat.d.ts +2 -2
  54. package/dist/services/permissions.d.ts +188 -0
  55. package/dist/services/permissions.d.ts.map +1 -0
  56. package/dist/services/permissions.js +661 -0
  57. package/dist/services/permissions.js.map +1 -0
  58. package/package.json +1 -1
  59. package/prisma/migrations/20260510170000_add_folder_permissions/migration.sql +93 -0
  60. package/prisma/schema.prisma +74 -0
  61. package/src/middleware/security.ts +1 -1
  62. package/src/models/cloudStorage.ts +134 -1
  63. package/src/models/file.ts +2 -37
  64. package/src/routers/_app.ts +2 -0
  65. package/src/routers/folder.ts +32 -2
  66. package/src/routers/permissions.ts +71 -0
  67. package/src/services/class.ts +1 -1
  68. package/src/services/file.ts +76 -47
  69. package/src/services/folder.ts +180 -91
  70. package/src/services/permissions.ts +778 -0
  71. package/tests/unit/services/permissions.test.ts +463 -0
@@ -0,0 +1,661 @@
1
+ /**
2
+ * Permissions resolver — single source of truth for "can user U do X to target T?"
3
+ *
4
+ * Sits on top of the existing scope-derived defaults (class members, school
5
+ * members, personal owner) computed in `getScopeDefault`. Explicit `Permission`
6
+ * rows are *additive* — they only ever grant more access, never less. There is
7
+ * no concept of a "deny" rule by design; revocation = delete the row.
8
+ *
9
+ * Resolution order for `requireFolderAccess(user, folder, level)`:
10
+ * 1. Scope default (class member / school member / personal owner)
11
+ * 2. Direct grant on the folder, OR inherited grant on any ancestor
12
+ * (walk up to scope root)
13
+ * First match wins; otherwise FORBIDDEN.
14
+ *
15
+ * `requireFileAccess` resolves the file's parent folder and delegates. Files
16
+ * inherit from their folder in v1; per-file grants are deliberately deferred
17
+ * (forward-compatible additive migration when needed).
18
+ */
19
+
20
+ !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]="2ac7304e-3502-58ed-af8e-db3b64af3009")}catch(e){}}();
21
+ import { TRPCError } from "@trpc/server";
22
+ import { prisma } from "../lib/prisma.js";
23
+ import { resolveFolderScope, } from "../models/cloudStorage.js";
24
+ /**
25
+ * Build the audience set for a user. Cheap (5 queries, all id-only) but worth
26
+ * caching per request — most permission checks in a single tRPC call hit this.
27
+ */
28
+ export async function expandUserPrincipals(userId) {
29
+ const [user, asTeacher, asStudent, departments, schoolMemberships] = await Promise.all([
30
+ prisma.user.findUnique({
31
+ where: { id: userId },
32
+ select: { tags: { select: { id: true } } },
33
+ }),
34
+ prisma.class.findMany({
35
+ where: { teachers: { some: { id: userId } } },
36
+ select: { id: true },
37
+ }),
38
+ prisma.class.findMany({
39
+ where: { students: { some: { id: userId } } },
40
+ select: { id: true },
41
+ }),
42
+ prisma.department.findMany({
43
+ where: { members: { some: { id: userId } } },
44
+ select: { id: true },
45
+ }),
46
+ prisma.schoolMembership.findMany({
47
+ where: { userId, status: "ACTIVE" },
48
+ select: { schoolId: true },
49
+ }),
50
+ ]);
51
+ const classTeacherIds = asTeacher.map((c) => c.id);
52
+ const classStudentIds = asStudent.map((c) => c.id);
53
+ return {
54
+ userId,
55
+ tagIds: user?.tags.map((t) => t.id) ?? [],
56
+ classTeacherIds,
57
+ classStudentIds,
58
+ classIds: Array.from(new Set([...classTeacherIds, ...classStudentIds])),
59
+ departmentIds: departments.map((d) => d.id),
60
+ schoolIds: schoolMemberships.map((m) => m.schoolId),
61
+ };
62
+ }
63
+ /**
64
+ * The principal-side `where` clause that matches any explicit grant whose
65
+ * principal applies to this user. CLASS / CLASS_TEACHERS / CLASS_STUDENTS all
66
+ * use `classId` but with different `principalType` values, so we have to fan
67
+ * those out into three OR branches.
68
+ */
69
+ function principalsWhere(principals) {
70
+ const ors = [
71
+ { principalType: "USER", userId: principals.userId },
72
+ ];
73
+ if (principals.tagIds.length) {
74
+ ors.push({ principalType: "TAG", tagId: { in: principals.tagIds } });
75
+ }
76
+ if (principals.classIds.length) {
77
+ ors.push({
78
+ principalType: "CLASS",
79
+ classId: { in: principals.classIds },
80
+ });
81
+ }
82
+ if (principals.classTeacherIds.length) {
83
+ ors.push({
84
+ principalType: "CLASS_TEACHERS",
85
+ classId: { in: principals.classTeacherIds },
86
+ });
87
+ }
88
+ if (principals.classStudentIds.length) {
89
+ ors.push({
90
+ principalType: "CLASS_STUDENTS",
91
+ classId: { in: principals.classStudentIds },
92
+ });
93
+ }
94
+ if (principals.departmentIds.length) {
95
+ ors.push({
96
+ principalType: "DEPARTMENT",
97
+ departmentId: { in: principals.departmentIds },
98
+ });
99
+ }
100
+ if (principals.schoolIds.length) {
101
+ ors.push({
102
+ principalType: "SCHOOL",
103
+ schoolId: { in: principals.schoolIds },
104
+ });
105
+ }
106
+ return { OR: ors };
107
+ }
108
+ function levelSatisfies(granted, required) {
109
+ if (required === "READ")
110
+ return granted === "READ" || granted === "WRITE";
111
+ return granted === "WRITE";
112
+ }
113
+ /** Walk up to the scope root, collecting folder ids visited along the way (inclusive). */
114
+ async function findAncestorIds(folderId) {
115
+ const ids = [];
116
+ let cursor = folderId;
117
+ let visits = 0;
118
+ while (cursor && visits < 64) {
119
+ ids.push(cursor);
120
+ const node = await prisma.folder.findUnique({
121
+ where: { id: cursor },
122
+ select: { parentFolderId: true },
123
+ });
124
+ if (!node)
125
+ break;
126
+ cursor = node.parentFolderId;
127
+ visits += 1;
128
+ }
129
+ return ids;
130
+ }
131
+ export { findAncestorIds };
132
+ /**
133
+ * Compute the scope default for a user — i.e. the access level the existing
134
+ * scope-derived rules grant, *before* any explicit ACL is consulted.
135
+ *
136
+ * Mirrors the logic that used to live in `requireScopeAccess` in folder.ts.
137
+ * Returns "WRITE" / "READ" / null.
138
+ */
139
+ export async function getScopeDefault(userId, scope) {
140
+ switch (scope.kind) {
141
+ case "CLASS": {
142
+ const classData = await prisma.class.findFirst({
143
+ where: { id: scope.classId },
144
+ select: {
145
+ id: true,
146
+ schoolId: true,
147
+ teachers: { where: { id: userId }, select: { id: true } },
148
+ students: { where: { id: userId }, select: { id: true } },
149
+ },
150
+ });
151
+ if (!classData)
152
+ return null;
153
+ if (classData.teachers.length > 0)
154
+ return "WRITE";
155
+ const isSchoolAdmin = classData.schoolId
156
+ ? Boolean(await prisma.schoolMembership.findFirst({
157
+ where: {
158
+ schoolId: classData.schoolId,
159
+ userId,
160
+ role: "ADMIN",
161
+ status: "ACTIVE",
162
+ },
163
+ select: { id: true },
164
+ }))
165
+ : false;
166
+ if (isSchoolAdmin)
167
+ return "WRITE";
168
+ if (classData.students.length > 0)
169
+ return "READ";
170
+ return null;
171
+ }
172
+ case "SCHOOL_STORAGE": {
173
+ const membership = await prisma.schoolMembership.findFirst({
174
+ where: { schoolId: scope.schoolId, userId, status: "ACTIVE" },
175
+ select: { role: true },
176
+ });
177
+ if (!membership)
178
+ return null;
179
+ if (membership.role === "ADMIN" || membership.role === "TEACHER")
180
+ return "WRITE";
181
+ return "READ";
182
+ }
183
+ case "PERSONAL": {
184
+ return scope.userId === userId ? "WRITE" : null;
185
+ }
186
+ }
187
+ }
188
+ /**
189
+ * Does any of the given folder ids carry a grant matching this user at >=
190
+ * `level`? Returns the matching folder id (highest-priority for caller use)
191
+ * or null.
192
+ */
193
+ async function findGrantOnFolders(folderIds, principals, level) {
194
+ if (!folderIds.length)
195
+ return null;
196
+ const grant = await prisma.permission.findFirst({
197
+ where: {
198
+ folderId: { in: folderIds },
199
+ ...principalsWhere(principals),
200
+ ...(level === "WRITE" ? { level: "WRITE" } : {}),
201
+ },
202
+ select: { folderId: true },
203
+ });
204
+ return grant?.folderId ?? null;
205
+ }
206
+ /**
207
+ * Permission gate for folder-scoped operations. Throws TRPCError on denial,
208
+ * returns the scope (and which path granted access) on success.
209
+ *
210
+ * `opts.mutatesSelf` (Drive-style guard): when true, the action targets the
211
+ * folder *itself* — rename, delete, move-this-folder. Explicit grants do NOT
212
+ * authorize these; only scope-default WRITE does. This means a teacher sharing
213
+ * "Math Resources" with the parents tag can let parents read/edit children of
214
+ * the folder, but parents cannot rename or delete the share root. Mutations
215
+ * targeting *children* of the folder (uploads, sub-folders, child renames)
216
+ * pass `mutatesSelf: false` and flow through the normal grant + ancestor +
217
+ * scope-default path.
218
+ */
219
+ export async function requireFolderAccess(userId, folderId, level, opts = {}) {
220
+ const scope = await resolveFolderScope(folderId);
221
+ if (!scope) {
222
+ throw new TRPCError({ code: "NOT_FOUND", message: "Folder not found" });
223
+ }
224
+ const scopeDefault = await getScopeDefault(userId, scope);
225
+ if (scopeDefault && levelSatisfies(scopeDefault, level)) {
226
+ return { scope, scopeDefault, viaGrant: false };
227
+ }
228
+ // Mutations of the share root itself are reserved for scope-default WRITE.
229
+ // Explicit grants don't authorize delete/rename/move-self even at WRITE
230
+ // level. We've already failed the scope-default branch above, so deny.
231
+ if (opts.mutatesSelf && level === "WRITE") {
232
+ throw new TRPCError({
233
+ code: "FORBIDDEN",
234
+ message: "Only the folder's owner can rename, move, or delete it. " +
235
+ "You can edit its contents.",
236
+ });
237
+ }
238
+ const principals = await expandUserPrincipals(userId);
239
+ const ancestors = await findAncestorIds(folderId);
240
+ const grantFolderId = await findGrantOnFolders(ancestors, principals, level);
241
+ if (grantFolderId) {
242
+ return { scope, scopeDefault, viaGrant: true };
243
+ }
244
+ throw new TRPCError({
245
+ code: "FORBIDDEN",
246
+ message: level === "WRITE"
247
+ ? "You don't have write access here"
248
+ : "You don't have access to this folder",
249
+ });
250
+ }
251
+ /**
252
+ * Per-class role check used by file-context handlers (assignment, submission,
253
+ * annotation, announcement, class-draft attachments). Single query.
254
+ */
255
+ async function userClassRole(userId, classId) {
256
+ const cls = await prisma.class.findFirst({
257
+ where: { id: classId },
258
+ select: {
259
+ schoolId: true,
260
+ teachers: { where: { id: userId }, select: { id: true } },
261
+ students: { where: { id: userId }, select: { id: true } },
262
+ },
263
+ });
264
+ if (!cls)
265
+ return null;
266
+ if (cls.teachers.length)
267
+ return "TEACHER";
268
+ if (cls.schoolId) {
269
+ const admin = await prisma.schoolMembership.findFirst({
270
+ where: {
271
+ schoolId: cls.schoolId,
272
+ userId,
273
+ role: "ADMIN",
274
+ status: "ACTIVE",
275
+ },
276
+ select: { id: true },
277
+ });
278
+ if (admin)
279
+ return "ADMIN";
280
+ }
281
+ if (cls.students.length)
282
+ return "STUDENT";
283
+ return null;
284
+ }
285
+ const fileGrant = (level) => ({
286
+ scope: null,
287
+ scopeDefault: level,
288
+ viaGrant: false,
289
+ });
290
+ /**
291
+ * Permission gate for file-scoped operations. Dispatches on whichever context
292
+ * relation the file carries, so every kind of file (cloud-storage, chat
293
+ * attachment, assignment / submission / annotation / announcement / class-draft
294
+ * attachment, school logo) gets authorized through one entry point.
295
+ *
296
+ * Resolution order — first match wins:
297
+ * 1. **Owner** — `file.userId === user`. Always WRITE.
298
+ * 2. **Folder-bound** — delegate to `requireFolderAccess` (handles scope
299
+ * defaults + explicit grants + ancestor inheritance).
300
+ * 3. **Conversation/message attachment** — any conversation member READs;
301
+ * only the sender (= owner, handled in step 1) WRITEs.
302
+ * 4. **Assignment attachment** — class teacher/admin RW, class student R.
303
+ * 5. **Submission file** — submission's student RW (until grading workflow
304
+ * locks it down), class teacher/admin R.
305
+ * 6. **Annotation file** — class teacher/admin RW, submission's student R.
306
+ * 7. **Announcement attachment** — class teacher/admin RW, class student R.
307
+ * 8. **Class-draft attachment** — class teacher/admin RW (drafts have no
308
+ * students yet in practice).
309
+ * 9. **School logo** — any active school member R, school admin W.
310
+ * 10. **Otherwise** (incl. SDP supporting docs) — owner-only fallback already
311
+ * covered by step 1; deny.
312
+ */
313
+ export async function requireFileAccess(userId, fileId, level) {
314
+ const file = await prisma.file.findUnique({
315
+ where: { id: fileId },
316
+ select: {
317
+ id: true,
318
+ userId: true,
319
+ folderId: true,
320
+ conversationId: true,
321
+ assignmentId: true,
322
+ classDraftId: true,
323
+ announcementId: true,
324
+ schoolDevelopementProgramId: true,
325
+ assignment: { select: { classId: true } },
326
+ submission: {
327
+ select: { studentId: true, assignment: { select: { classId: true } } },
328
+ },
329
+ annotations: {
330
+ select: { studentId: true, assignment: { select: { classId: true } } },
331
+ },
332
+ announcement: { select: { classId: true } },
333
+ schools: { select: { id: true } },
334
+ },
335
+ });
336
+ if (!file) {
337
+ throw new TRPCError({ code: "NOT_FOUND", message: "File not found" });
338
+ }
339
+ if (file.userId === userId) {
340
+ return { scope: null, scopeDefault: "WRITE", viaGrant: false };
341
+ }
342
+ if (file.folderId) {
343
+ return requireFolderAccess(userId, file.folderId, level);
344
+ }
345
+ if (file.conversationId) {
346
+ const member = await prisma.conversationMember.findFirst({
347
+ where: { conversationId: file.conversationId, userId },
348
+ select: { id: true },
349
+ });
350
+ if (member) {
351
+ // Non-owners can only read; only the sender (owner, already handled
352
+ // above) can mutate the attachment record.
353
+ if (level === "READ")
354
+ return fileGrant("READ");
355
+ }
356
+ }
357
+ if (file.assignment?.classId) {
358
+ const role = await userClassRole(userId, file.assignment.classId);
359
+ if (role === "TEACHER" || role === "ADMIN")
360
+ return fileGrant("WRITE");
361
+ if (role === "STUDENT" && level === "READ")
362
+ return fileGrant("READ");
363
+ }
364
+ if (file.submission?.assignment?.classId) {
365
+ if (file.submission.studentId === userId)
366
+ return fileGrant("WRITE");
367
+ const role = await userClassRole(userId, file.submission.assignment.classId);
368
+ if ((role === "TEACHER" || role === "ADMIN") && level === "READ") {
369
+ return fileGrant("READ");
370
+ }
371
+ }
372
+ if (file.annotations?.assignment?.classId) {
373
+ const role = await userClassRole(userId, file.annotations.assignment.classId);
374
+ if (role === "TEACHER" || role === "ADMIN")
375
+ return fileGrant("WRITE");
376
+ if (file.annotations.studentId === userId && level === "READ") {
377
+ return fileGrant("READ");
378
+ }
379
+ }
380
+ if (file.announcement?.classId) {
381
+ const role = await userClassRole(userId, file.announcement.classId);
382
+ if (role === "TEACHER" || role === "ADMIN")
383
+ return fileGrant("WRITE");
384
+ if (role === "STUDENT" && level === "READ")
385
+ return fileGrant("READ");
386
+ }
387
+ if (file.classDraftId) {
388
+ const role = await userClassRole(userId, file.classDraftId);
389
+ if (role === "TEACHER" || role === "ADMIN")
390
+ return fileGrant("WRITE");
391
+ }
392
+ if (file.schools.length) {
393
+ // Files referenced as `School.logo` — readable by any active member of
394
+ // any school using this file, writable only by an admin of one of them.
395
+ const schoolIds = file.schools.map((s) => s.id);
396
+ if (level === "WRITE") {
397
+ const adminMembership = await prisma.schoolMembership.findFirst({
398
+ where: {
399
+ schoolId: { in: schoolIds },
400
+ userId,
401
+ role: "ADMIN",
402
+ status: "ACTIVE",
403
+ },
404
+ select: { id: true },
405
+ });
406
+ if (adminMembership)
407
+ return fileGrant("WRITE");
408
+ }
409
+ else {
410
+ const membership = await prisma.schoolMembership.findFirst({
411
+ where: {
412
+ schoolId: { in: schoolIds },
413
+ userId,
414
+ status: "ACTIVE",
415
+ },
416
+ select: { id: true },
417
+ });
418
+ if (membership)
419
+ return fileGrant("READ");
420
+ }
421
+ }
422
+ throw new TRPCError({
423
+ code: "FORBIDDEN",
424
+ message: level === "WRITE"
425
+ ? "You don't have write access to this file"
426
+ : "You don't have access to this file",
427
+ });
428
+ }
429
+ /**
430
+ * Top-most-grant computation: surface every folder the user has an explicit
431
+ * grant on whose nearest permissioned ancestor the user does *not* also hold a
432
+ * grant on, AND whose scope default does not already grant access. That second
433
+ * filter avoids "Shared with me" duplicating things you already see in your
434
+ * normal class/school/personal scopes.
435
+ *
436
+ * Files are not surfaced here — file-level grants are deferred to a future
437
+ * feature; in v1 files inherit from their folder.
438
+ */
439
+ export async function listSharedWithMe(userId) {
440
+ const principals = await expandUserPrincipals(userId);
441
+ const grants = await prisma.permission.findMany({
442
+ where: principalsWhere(principals),
443
+ select: { id: true, folderId: true },
444
+ });
445
+ const grantedFolderIds = new Set(grants.map((g) => g.folderId).filter((x) => Boolean(x)));
446
+ const topFolders = [];
447
+ for (const folderId of grantedFolderIds) {
448
+ const ancestors = await findAncestorIds(folderId);
449
+ // Skip self; we want to know if any *strict* ancestor is also granted.
450
+ const strictAncestors = ancestors.slice(1);
451
+ const coveredByAncestor = strictAncestors.some((id) => grantedFolderIds.has(id));
452
+ if (coveredByAncestor)
453
+ continue;
454
+ const scope = await resolveFolderScope(folderId);
455
+ if (scope) {
456
+ const scopeDefault = await getScopeDefault(userId, scope);
457
+ if (scopeDefault)
458
+ continue;
459
+ }
460
+ const folder = await prisma.folder.findUnique({
461
+ where: { id: folderId },
462
+ select: { id: true, name: true, color: true },
463
+ });
464
+ if (folder)
465
+ topFolders.push(folder);
466
+ }
467
+ return { folders: topFolders, files: [] };
468
+ }
469
+ /** Resolve which school an explicit grant target belongs to (for cross-school checks). */
470
+ async function resolveTargetSchoolId(folderId) {
471
+ const scope = await resolveFolderScope(folderId);
472
+ return scopeSchoolId(scope);
473
+ }
474
+ async function scopeSchoolId(scope) {
475
+ if (!scope)
476
+ return null;
477
+ switch (scope.kind) {
478
+ case "CLASS": {
479
+ const c = await prisma.class.findUnique({
480
+ where: { id: scope.classId },
481
+ select: { schoolId: true },
482
+ });
483
+ return c?.schoolId ?? null;
484
+ }
485
+ case "SCHOOL_STORAGE":
486
+ return scope.schoolId;
487
+ case "PERSONAL":
488
+ return null;
489
+ }
490
+ }
491
+ /** The school a principal lives in (for cross-school leak prevention). */
492
+ async function principalSchoolId(principal) {
493
+ switch (principal.kind) {
494
+ case "USER": {
495
+ const u = await prisma.user.findUnique({
496
+ where: { id: principal.userId },
497
+ select: { schoolId: true },
498
+ });
499
+ return u?.schoolId ?? null;
500
+ }
501
+ case "TAG": {
502
+ const t = await prisma.tag.findUnique({
503
+ where: { id: principal.tagId },
504
+ select: { schoolId: true },
505
+ });
506
+ return t?.schoolId ?? null;
507
+ }
508
+ case "CLASS":
509
+ case "CLASS_TEACHERS":
510
+ case "CLASS_STUDENTS": {
511
+ const c = await prisma.class.findUnique({
512
+ where: { id: principal.classId },
513
+ select: { schoolId: true },
514
+ });
515
+ return c?.schoolId ?? null;
516
+ }
517
+ case "DEPARTMENT": {
518
+ const d = await prisma.department.findUnique({
519
+ where: { id: principal.departmentId },
520
+ select: { schoolId: true },
521
+ });
522
+ return d?.schoolId ?? null;
523
+ }
524
+ case "SCHOOL":
525
+ return principal.schoolId;
526
+ }
527
+ }
528
+ function principalToColumns(principal) {
529
+ const base = {
530
+ userId: null,
531
+ tagId: null,
532
+ classId: null,
533
+ departmentId: null,
534
+ schoolId: null,
535
+ };
536
+ switch (principal.kind) {
537
+ case "USER":
538
+ return { ...base, principalType: "USER", userId: principal.userId };
539
+ case "TAG":
540
+ return { ...base, principalType: "TAG", tagId: principal.tagId };
541
+ case "CLASS":
542
+ return { ...base, principalType: "CLASS", classId: principal.classId };
543
+ case "CLASS_TEACHERS":
544
+ return {
545
+ ...base,
546
+ principalType: "CLASS_TEACHERS",
547
+ classId: principal.classId,
548
+ };
549
+ case "CLASS_STUDENTS":
550
+ return {
551
+ ...base,
552
+ principalType: "CLASS_STUDENTS",
553
+ classId: principal.classId,
554
+ };
555
+ case "DEPARTMENT":
556
+ return {
557
+ ...base,
558
+ principalType: "DEPARTMENT",
559
+ departmentId: principal.departmentId,
560
+ };
561
+ case "SCHOOL":
562
+ return { ...base, principalType: "SCHOOL", schoolId: principal.schoolId };
563
+ }
564
+ }
565
+ /**
566
+ * Create a new explicit grant on a folder. Validates:
567
+ * - Granter has WRITE on the folder.
568
+ * - The principal is in the same school as the folder's scope (no
569
+ * cross-school leaks). Personal-scope folders cannot be granted to
570
+ * non-USER principals — there's no "school" to check membership against.
571
+ */
572
+ export async function grantPermission(grantedById, input) {
573
+ await requireFolderAccess(grantedById, input.folderId, "WRITE");
574
+ const [targetSchoolId, principalSchool] = await Promise.all([
575
+ resolveTargetSchoolId(input.folderId),
576
+ principalSchoolId(input.principal),
577
+ ]);
578
+ if (input.principal.kind !== "USER") {
579
+ if (!targetSchoolId) {
580
+ throw new TRPCError({
581
+ code: "BAD_REQUEST",
582
+ message: "Personal-scope items can only be shared with specific users, not groups",
583
+ });
584
+ }
585
+ if (principalSchool !== targetSchoolId) {
586
+ throw new TRPCError({
587
+ code: "BAD_REQUEST",
588
+ message: "Cannot share with a principal from a different school",
589
+ });
590
+ }
591
+ }
592
+ else if (targetSchoolId && principalSchool && principalSchool !== targetSchoolId) {
593
+ throw new TRPCError({
594
+ code: "BAD_REQUEST",
595
+ message: "Cannot share with a user from a different school",
596
+ });
597
+ }
598
+ // Re-granting the same (folder, principal) pair updates the existing row's
599
+ // level instead of throwing P2002 against the @@unique. Prisma's composite-
600
+ // unique-key types refuse nullable columns, so we go through findFirst +
601
+ // update/create rather than a single upsert.
602
+ const principalCols = principalToColumns(input.principal);
603
+ const existing = await prisma.permission.findFirst({
604
+ where: {
605
+ folderId: input.folderId,
606
+ principalType: principalCols.principalType,
607
+ userId: principalCols.userId,
608
+ tagId: principalCols.tagId,
609
+ classId: principalCols.classId,
610
+ departmentId: principalCols.departmentId,
611
+ schoolId: principalCols.schoolId,
612
+ },
613
+ select: { id: true },
614
+ });
615
+ if (existing) {
616
+ return prisma.permission.update({
617
+ where: { id: existing.id },
618
+ data: { level: input.level, grantedById },
619
+ });
620
+ }
621
+ return prisma.permission.create({
622
+ data: {
623
+ folderId: input.folderId,
624
+ ...principalCols,
625
+ level: input.level,
626
+ grantedById,
627
+ },
628
+ });
629
+ }
630
+ /** Revoke a grant. Allowed for the granter or anyone with WRITE on the folder. */
631
+ export async function revokePermission(userId, permissionId) {
632
+ const grant = await prisma.permission.findUnique({
633
+ where: { id: permissionId },
634
+ select: { id: true, grantedById: true, folderId: true },
635
+ });
636
+ if (!grant) {
637
+ throw new TRPCError({ code: "NOT_FOUND", message: "Permission not found" });
638
+ }
639
+ if (grant.grantedById !== userId) {
640
+ await requireFolderAccess(userId, grant.folderId, "WRITE");
641
+ }
642
+ await prisma.permission.delete({ where: { id: permissionId } });
643
+ return { success: true };
644
+ }
645
+ /** List all explicit grants on a folder. Caller must have WRITE on it. */
646
+ export async function listPermissions(userId, folderId) {
647
+ await requireFolderAccess(userId, folderId, "WRITE");
648
+ return prisma.permission.findMany({
649
+ where: { folderId },
650
+ include: {
651
+ user: { select: { id: true, username: true, email: true } },
652
+ tag: { select: { id: true, name: true, color: true } },
653
+ class: { select: { id: true, name: true, section: true } },
654
+ department: { select: { id: true, name: true, color: true } },
655
+ school: { select: { id: true, name: true } },
656
+ },
657
+ orderBy: { createdAt: "desc" },
658
+ });
659
+ }
660
+ //# sourceMappingURL=permissions.js.map
661
+ //# debugId=2ac7304e-3502-58ed-af8e-db3b64af3009