@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.
- package/dist/middleware/security.d.ts.map +1 -1
- package/dist/middleware/security.js +3 -3
- package/dist/middleware/security.js.map +1 -1
- package/dist/models/access.d.ts +21 -66
- package/dist/models/access.d.ts.map +1 -1
- package/dist/models/access.js +10 -26
- package/dist/models/access.js.map +1 -1
- package/dist/models/agenda.d.ts +4 -2
- package/dist/models/agenda.d.ts.map +1 -1
- package/dist/models/announcement.d.ts +4 -3
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/announcement.js +2 -3
- package/dist/models/announcement.js.map +1 -1
- package/dist/models/assignment.d.ts +40 -33
- package/dist/models/assignment.d.ts.map +1 -1
- package/dist/models/auth.d.ts +2 -4
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/auth.js +2 -3
- package/dist/models/auth.js.map +1 -1
- package/dist/models/class.d.ts +34 -6
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +15 -2
- package/dist/models/class.js.map +1 -1
- package/dist/models/cloudStorage.d.ts +153 -0
- package/dist/models/cloudStorage.d.ts.map +1 -0
- package/dist/models/cloudStorage.js +295 -0
- package/dist/models/cloudStorage.js.map +1 -0
- package/dist/models/comment.d.ts +0 -24
- package/dist/models/comment.d.ts.map +1 -1
- package/dist/models/comment.js +2 -6
- package/dist/models/comment.js.map +1 -1
- package/dist/models/conversation.d.ts +22 -22
- package/dist/models/department.d.ts +154 -0
- package/dist/models/department.d.ts.map +1 -0
- package/dist/models/department.js +135 -0
- package/dist/models/department.js.map +1 -0
- package/dist/models/event.d.ts +10 -4
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/event.js +2 -5
- package/dist/models/event.js.map +1 -1
- package/dist/models/file.d.ts +52 -36
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/file.js +4 -42
- package/dist/models/file.js.map +1 -1
- package/dist/models/folder.d.ts +47 -0
- package/dist/models/folder.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +11 -9
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/message.d.ts +46 -12
- package/dist/models/message.d.ts.map +1 -1
- package/dist/models/message.js +19 -10
- package/dist/models/message.js.map +1 -1
- package/dist/models/newtonChat.d.ts +2 -1
- package/dist/models/newtonChat.d.ts.map +1 -1
- package/dist/models/school.d.ts +45 -12
- package/dist/models/school.d.ts.map +1 -1
- package/dist/models/school.js +19 -2
- package/dist/models/school.js.map +1 -1
- package/dist/models/section.d.ts +2 -1
- package/dist/models/section.d.ts.map +1 -1
- package/dist/models/tag.d.ts +117 -0
- package/dist/models/tag.d.ts.map +1 -0
- package/dist/models/tag.js +113 -0
- package/dist/models/tag.js.map +1 -0
- package/dist/models/worksheet.d.ts +3 -0
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +8 -8
- package/dist/routers/_app.d.ts +6704 -5222
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +8 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/agenda.d.ts +4 -2
- package/dist/routers/agenda.d.ts.map +1 -1
- package/dist/routers/announcement.d.ts.map +1 -1
- package/dist/routers/announcement.js +3 -5
- package/dist/routers/announcement.js.map +1 -1
- package/dist/routers/assignment.d.ts +49 -40
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/assignment.js +2 -3
- package/dist/routers/assignment.js.map +1 -1
- package/dist/routers/class.d.ts +92 -19
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +5 -2
- package/dist/routers/class.js.map +1 -1
- package/dist/routers/conversation.d.ts +5 -5
- package/dist/routers/department.d.ts +293 -0
- package/dist/routers/department.d.ts.map +1 -0
- package/dist/routers/department.js +93 -0
- package/dist/routers/department.js.map +1 -0
- package/dist/routers/event.d.ts +4 -3
- package/dist/routers/event.d.ts.map +1 -1
- package/dist/routers/file.d.ts +10 -8
- package/dist/routers/file.d.ts.map +1 -1
- package/dist/routers/file.js +26 -17
- package/dist/routers/file.js.map +1 -1
- package/dist/routers/folder.d.ts +163 -186
- package/dist/routers/folder.d.ts.map +1 -1
- package/dist/routers/folder.js +102 -88
- package/dist/routers/folder.js.map +1 -1
- package/dist/routers/labChat.d.ts +13 -4
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/message.d.ts +20 -2
- package/dist/routers/message.d.ts.map +1 -1
- package/dist/routers/newtonChat.d.ts +10 -1
- package/dist/routers/newtonChat.d.ts.map +1 -1
- package/dist/routers/permissions.d.ts +120 -0
- package/dist/routers/permissions.d.ts.map +1 -0
- package/dist/routers/permissions.js +50 -0
- package/dist/routers/permissions.js.map +1 -0
- package/dist/routers/school.d.ts +160 -17
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/school.js +46 -3
- package/dist/routers/school.js.map +1 -1
- package/dist/routers/tag.d.ts +119 -0
- package/dist/routers/tag.d.ts.map +1 -0
- package/dist/routers/tag.js +52 -0
- package/dist/routers/tag.js.map +1 -0
- package/dist/routers/worksheet.d.ts +1 -0
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/seedDatabase.d.ts +0 -1
- package/dist/seedDatabase.d.ts.map +1 -1
- package/dist/services/agenda.d.ts +4 -2
- package/dist/services/agenda.d.ts.map +1 -1
- package/dist/services/assignment.d.ts +49 -40
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/class.d.ts +92 -19
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +92 -5
- package/dist/services/class.js.map +1 -1
- package/dist/services/conversation.d.ts +5 -5
- package/dist/services/department.d.ts +200 -0
- package/dist/services/department.d.ts.map +1 -0
- package/dist/services/department.js +368 -0
- package/dist/services/department.js.map +1 -0
- package/dist/services/event.d.ts +4 -3
- package/dist/services/event.d.ts.map +1 -1
- package/dist/services/file.d.ts +105 -3
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/file.js +167 -41
- package/dist/services/file.js.map +1 -1
- package/dist/services/folder.d.ts +220 -149
- package/dist/services/folder.d.ts.map +1 -1
- package/dist/services/folder.js +326 -196
- package/dist/services/folder.js.map +1 -1
- package/dist/services/labChat.d.ts +13 -4
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +4 -3
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/message.d.ts +20 -2
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +7 -23
- package/dist/services/message.js.map +1 -1
- package/dist/services/newtonChat.d.ts +9 -0
- package/dist/services/newtonChat.d.ts.map +1 -1
- package/dist/services/newtonChat.js +4 -3
- package/dist/services/newtonChat.js.map +1 -1
- package/dist/services/permissions.d.ts +188 -0
- package/dist/services/permissions.d.ts.map +1 -0
- package/dist/services/permissions.js +661 -0
- package/dist/services/permissions.js.map +1 -0
- package/dist/services/school.d.ts +142 -15
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/school.js +308 -54
- package/dist/services/school.js.map +1 -1
- package/dist/services/tag.d.ts +71 -0
- package/dist/services/tag.d.ts.map +1 -0
- package/dist/services/tag.js +178 -0
- package/dist/services/tag.js.map +1 -0
- package/dist/services/worksheet.d.ts +1 -0
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/utils/aiUser.d.ts.map +1 -1
- package/dist/utils/aiUser.js +2 -3
- package/dist/utils/aiUser.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260509220000_institution_overhaul/migration.sql +184 -0
- package/prisma/migrations/20260510170000_add_folder_permissions/migration.sql +93 -0
- package/prisma/schema.prisma +193 -35
- package/src/middleware/security.ts +1 -1
- package/src/models/access.ts +8 -24
- package/src/models/announcement.ts +0 -1
- package/src/models/auth.ts +0 -1
- package/src/models/class.ts +13 -0
- package/src/models/cloudStorage.ts +327 -0
- package/src/models/comment.ts +0 -4
- package/src/models/department.ts +159 -0
- package/src/models/event.ts +0 -3
- package/src/models/file.ts +2 -40
- package/src/models/message.ts +19 -5
- package/src/models/school.ts +26 -0
- package/src/models/tag.ts +140 -0
- package/src/routers/_app.ts +6 -0
- package/src/routers/announcement.ts +1 -3
- package/src/routers/assignment.ts +0 -1
- package/src/routers/class.ts +3 -0
- package/src/routers/department.ts +174 -0
- package/src/routers/file.ts +29 -26
- package/src/routers/folder.ts +127 -118
- package/src/routers/permissions.ts +71 -0
- package/src/routers/school.ts +83 -0
- package/src/routers/tag.ts +78 -0
- package/src/services/class.ts +109 -4
- package/src/services/department.ts +478 -0
- package/src/services/file.ts +172 -43
- package/src/services/folder.ts +399 -237
- package/src/services/labChat.ts +2 -1
- package/src/services/message.ts +5 -21
- package/src/services/newtonChat.ts +2 -1
- package/src/services/permissions.ts +778 -0
- package/src/services/school.ts +369 -57
- package/src/services/tag.ts +229 -0
- package/src/utils/aiUser.ts +0 -1
- package/tests/unit/services/permissions.test.ts +463 -0
package/dist/services/folder.js
CHANGED
|
@@ -1,248 +1,378 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Folder service
|
|
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]="
|
|
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 {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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: "
|
|
44
|
-
message:
|
|
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
|
-
|
|
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: "
|
|
62
|
-
message:
|
|
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
|
-
|
|
55
|
+
// Scope-default-only path; access cannot be via a grant here.
|
|
56
|
+
return { scope, canWrite: granted === "WRITE", viaGrant: false };
|
|
66
57
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
if (!
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
};
|
|
88
|
+
else if (level === "WRITE") {
|
|
89
|
+
canWrite = true;
|
|
90
|
+
}
|
|
91
|
+
return { scope: result.scope, canWrite, viaGrant: result.viaGrant };
|
|
87
92
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
|
244
|
+
message: "Cannot rename a root folder",
|
|
148
245
|
});
|
|
149
246
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
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 (!
|
|
259
|
+
if (!folder.parentFolderId) {
|
|
188
260
|
throw new TRPCError({
|
|
189
261
|
code: "BAD_REQUEST",
|
|
190
|
-
message: "
|
|
262
|
+
message: "Cannot delete a root folder",
|
|
191
263
|
});
|
|
192
264
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
199
|
-
|
|
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: "
|
|
215
|
-
message: "
|
|
273
|
+
code: "BAD_REQUEST",
|
|
274
|
+
message: "Cannot move a folder into itself",
|
|
216
275
|
});
|
|
217
276
|
}
|
|
218
|
-
const
|
|
219
|
-
|
|
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: "
|
|
222
|
-
message: "
|
|
286
|
+
code: "BAD_REQUEST",
|
|
287
|
+
message: "Cannot move folders between scopes",
|
|
223
288
|
});
|
|
224
289
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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=
|
|
378
|
+
//# debugId=338c145a-d8eb-5931-9a42-2a9c7c9acc94
|