@studious-lms/server 1.7.0 → 1.8.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/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 +2 -0
- package/dist/models/agenda.d.ts.map +1 -1
- package/dist/models/announcement.d.ts +2 -1
- 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 +7 -0
- 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 +32 -4
- 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 +140 -0
- package/dist/models/cloudStorage.d.ts.map +1 -0
- package/dist/models/cloudStorage.js +175 -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 +7 -1
- 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 +25 -10
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/file.js +2 -5
- 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 +5 -3
- 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 +1 -0
- 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 +3200 -1972
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +6 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/agenda.d.ts +2 -0
- 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 +18 -9
- 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 +87 -14
- 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 +2 -1
- package/dist/routers/event.d.ts.map +1 -1
- package/dist/routers/file.d.ts +8 -6
- 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 +139 -186
- package/dist/routers/folder.d.ts.map +1 -1
- package/dist/routers/folder.js +82 -90
- package/dist/routers/folder.js.map +1 -1
- package/dist/routers/labChat.d.ts +11 -2
- 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 +9 -0
- package/dist/routers/newtonChat.d.ts.map +1 -1
- 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 +2 -0
- package/dist/services/agenda.d.ts.map +1 -1
- package/dist/services/assignment.d.ts +18 -9
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/class.d.ts +87 -14
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +91 -4
- 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 +2 -1
- package/dist/services/event.d.ts.map +1 -1
- package/dist/services/file.d.ts +85 -0
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/file.js +92 -2
- package/dist/services/file.js.map +1 -1
- package/dist/services/folder.d.ts +125 -149
- package/dist/services/folder.d.ts.map +1 -1
- package/dist/services/folder.js +258 -199
- package/dist/services/folder.js.map +1 -1
- package/dist/services/labChat.d.ts +11 -2
- 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/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/schema.prisma +119 -35
- 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 +194 -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 +0 -3
- 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 +4 -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 +100 -121
- package/src/routers/school.ts +83 -0
- package/src/routers/tag.ts +78 -0
- package/src/services/class.ts +108 -3
- package/src/services/department.ts +478 -0
- package/src/services/file.ts +100 -0
- package/src/services/folder.ts +313 -240
- package/src/services/labChat.ts +2 -1
- package/src/services/message.ts +5 -21
- package/src/services/newtonChat.ts +2 -1
- package/src/services/school.ts +369 -57
- package/src/services/tag.ts +229 -0
- package/src/utils/aiUser.ts +0 -1
package/src/services/folder.ts
CHANGED
|
@@ -1,316 +1,389 @@
|
|
|
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
|
import { TRPCError } from "@trpc/server";
|
|
14
|
+
import { prisma } from "../lib/prisma.js";
|
|
5
15
|
import {
|
|
6
|
-
|
|
16
|
+
CloudScope,
|
|
17
|
+
createSubFolder,
|
|
18
|
+
deleteFolderById,
|
|
7
19
|
findFolderById,
|
|
8
|
-
findOrCreateRootFolder,
|
|
9
|
-
createFolder,
|
|
10
20
|
findFolderWithContents,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
findOrCreateScopeRoot,
|
|
22
|
+
listFolderBreadcrumbs,
|
|
23
|
+
renameFolder,
|
|
24
|
+
resolveFolderScope,
|
|
25
|
+
} from "../models/cloudStorage.js";
|
|
26
|
+
import {
|
|
27
|
+
createDirectUploadFiles,
|
|
28
|
+
type DirectFileData,
|
|
29
|
+
confirmDirectUpload,
|
|
30
|
+
} from "../lib/fileUpload.js";
|
|
20
31
|
|
|
21
|
-
|
|
22
|
-
export async function createFolderRecord(
|
|
23
|
-
userId: string,
|
|
24
|
-
input: {
|
|
25
|
-
classId: string;
|
|
26
|
-
name: string;
|
|
27
|
-
parentFolderId?: string | null;
|
|
28
|
-
color?: string;
|
|
29
|
-
}
|
|
30
|
-
) {
|
|
31
|
-
const classData = await findClassWithTeacher(input.classId, userId);
|
|
32
|
-
if (!classData) {
|
|
33
|
-
throw new TRPCError({
|
|
34
|
-
code: "NOT_FOUND",
|
|
35
|
-
message: "Class not found or you are not a teacher",
|
|
36
|
-
});
|
|
37
|
-
}
|
|
32
|
+
type AccessLevel = "READ" | "WRITE";
|
|
38
33
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
34
|
+
interface AccessResult {
|
|
35
|
+
scope: CloudScope;
|
|
36
|
+
canWrite: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function requireScopeAccess(
|
|
40
|
+
userId: string,
|
|
41
|
+
scope: CloudScope,
|
|
42
|
+
level: AccessLevel
|
|
43
|
+
): Promise<AccessResult> {
|
|
44
|
+
switch (scope.kind) {
|
|
45
|
+
case "CLASS": {
|
|
46
|
+
const classData = await prisma.class.findFirst({
|
|
47
|
+
where: { id: scope.classId },
|
|
48
|
+
select: {
|
|
49
|
+
id: true,
|
|
50
|
+
schoolId: true,
|
|
51
|
+
teachers: { where: { id: userId }, select: { id: true } },
|
|
52
|
+
students: { where: { id: userId }, select: { id: true } },
|
|
53
|
+
},
|
|
49
54
|
});
|
|
55
|
+
if (!classData) {
|
|
56
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Class not found" });
|
|
57
|
+
}
|
|
58
|
+
const isTeacher = classData.teachers.length > 0;
|
|
59
|
+
const isStudent = classData.students.length > 0;
|
|
60
|
+
const isSchoolAdmin = classData.schoolId
|
|
61
|
+
? Boolean(
|
|
62
|
+
await prisma.schoolMembership.findFirst({
|
|
63
|
+
where: {
|
|
64
|
+
schoolId: classData.schoolId,
|
|
65
|
+
userId,
|
|
66
|
+
role: "ADMIN",
|
|
67
|
+
status: "ACTIVE",
|
|
68
|
+
},
|
|
69
|
+
select: { id: true },
|
|
70
|
+
})
|
|
71
|
+
)
|
|
72
|
+
: false;
|
|
73
|
+
if (!isTeacher && !isStudent && !isSchoolAdmin) {
|
|
74
|
+
throw new TRPCError({
|
|
75
|
+
code: "FORBIDDEN",
|
|
76
|
+
message: "You don't have access to this class",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const canWrite = isTeacher || isSchoolAdmin;
|
|
80
|
+
if (level === "WRITE" && !canWrite) {
|
|
81
|
+
throw new TRPCError({
|
|
82
|
+
code: "FORBIDDEN",
|
|
83
|
+
message: "Only class teachers can modify class files",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return { scope, canWrite };
|
|
87
|
+
}
|
|
88
|
+
case "SCHOOL_STORAGE": {
|
|
89
|
+
const membership = await prisma.schoolMembership.findFirst({
|
|
90
|
+
where: { schoolId: scope.schoolId, userId, status: "ACTIVE" },
|
|
91
|
+
select: { role: true },
|
|
92
|
+
});
|
|
93
|
+
if (!membership) {
|
|
94
|
+
throw new TRPCError({
|
|
95
|
+
code: "FORBIDDEN",
|
|
96
|
+
message: "You aren't a member of this institution",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const canWrite =
|
|
100
|
+
membership.role === "ADMIN" || membership.role === "TEACHER";
|
|
101
|
+
if (level === "WRITE" && !canWrite) {
|
|
102
|
+
throw new TRPCError({
|
|
103
|
+
code: "FORBIDDEN",
|
|
104
|
+
message: "Only teachers and admins can modify institution storage",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return { scope, canWrite };
|
|
108
|
+
}
|
|
109
|
+
case "PERSONAL": {
|
|
110
|
+
if (scope.userId !== userId) {
|
|
111
|
+
throw new TRPCError({
|
|
112
|
+
code: "FORBIDDEN",
|
|
113
|
+
message: "You can only access your own personal storage",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return { scope, canWrite: true };
|
|
50
117
|
}
|
|
51
118
|
}
|
|
52
|
-
|
|
53
|
-
return createFolder({
|
|
54
|
-
name: input.name,
|
|
55
|
-
parentFolderId,
|
|
56
|
-
color: input.color,
|
|
57
|
-
});
|
|
58
119
|
}
|
|
59
120
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
});
|
|
121
|
+
export async function requireFolderAccess(
|
|
122
|
+
userId: string,
|
|
123
|
+
folderId: string,
|
|
124
|
+
level: AccessLevel
|
|
125
|
+
) {
|
|
126
|
+
const scope = await resolveFolderScope(folderId);
|
|
127
|
+
if (!scope) {
|
|
128
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Folder not found" });
|
|
68
129
|
}
|
|
69
|
-
return
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export async function getChildFolders(classId: string) {
|
|
73
|
-
const root = await findOrCreateRootFolder(classId);
|
|
74
|
-
return findChildFolders(root.id);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export async function getFolderChildren(folderId: string, classId: string) {
|
|
78
|
-
return findChildFolders(folderId);
|
|
130
|
+
return requireScopeAccess(userId, scope, level);
|
|
79
131
|
}
|
|
80
132
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
133
|
+
function scopesEqual(a: CloudScope, b: CloudScope): boolean {
|
|
134
|
+
if (a.kind !== b.kind) return false;
|
|
135
|
+
switch (a.kind) {
|
|
136
|
+
case "CLASS":
|
|
137
|
+
return a.classId === (b as typeof a).classId;
|
|
138
|
+
case "SCHOOL_STORAGE":
|
|
139
|
+
return a.schoolId === (b as typeof a).schoolId;
|
|
140
|
+
case "PERSONAL":
|
|
141
|
+
return a.userId === (b as typeof a).userId;
|
|
89
142
|
}
|
|
90
|
-
return rootWithContents;
|
|
91
143
|
}
|
|
92
144
|
|
|
93
|
-
|
|
145
|
+
/** Browse a folder. Pass `folderId` to drill into a specific folder, or a `scope` to land on its root. */
|
|
146
|
+
export async function browseFolder(
|
|
94
147
|
userId: string,
|
|
95
|
-
|
|
96
|
-
folderId: string,
|
|
97
|
-
files: { name: string; type: string; size: number }[]
|
|
148
|
+
input: { folderId: string } | { scope: CloudScope }
|
|
98
149
|
) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
150
|
+
let scope: CloudScope;
|
|
151
|
+
let folderId: string | undefined;
|
|
152
|
+
|
|
153
|
+
if ("folderId" in input) {
|
|
154
|
+
folderId = input.folderId;
|
|
155
|
+
const resolved = await resolveFolderScope(input.folderId);
|
|
156
|
+
if (!resolved) {
|
|
157
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Folder not found" });
|
|
158
|
+
}
|
|
159
|
+
scope = resolved;
|
|
160
|
+
} else {
|
|
161
|
+
scope = input.scope;
|
|
105
162
|
}
|
|
106
163
|
|
|
107
|
-
const
|
|
164
|
+
const access = await requireScopeAccess(userId, scope, "READ");
|
|
165
|
+
const targetId = folderId ?? (await findOrCreateScopeRoot(scope)).id;
|
|
166
|
+
|
|
167
|
+
const [folder, breadcrumbs] = await Promise.all([
|
|
168
|
+
findFolderWithContents(targetId),
|
|
169
|
+
listFolderBreadcrumbs(targetId),
|
|
170
|
+
]);
|
|
171
|
+
|
|
108
172
|
if (!folder) {
|
|
109
|
-
throw new TRPCError({
|
|
110
|
-
code: "NOT_FOUND",
|
|
111
|
-
message: "Folder not found",
|
|
112
|
-
});
|
|
173
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Folder not found" });
|
|
113
174
|
}
|
|
114
175
|
|
|
115
|
-
const uploadedFiles = await createDirectUploadFiles(files, userId, folderId);
|
|
116
176
|
return {
|
|
117
|
-
|
|
118
|
-
|
|
177
|
+
folder,
|
|
178
|
+
breadcrumbs,
|
|
179
|
+
canWrite: access.canWrite,
|
|
180
|
+
scope,
|
|
119
181
|
};
|
|
120
182
|
}
|
|
121
183
|
|
|
122
|
-
export async function
|
|
184
|
+
export async function getRootFolder(userId: string, scope: CloudScope) {
|
|
185
|
+
const result = await browseFolder(userId, { scope });
|
|
186
|
+
return result.folder;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function getFolder(userId: string, folderId: string) {
|
|
190
|
+
const result = await browseFolder(userId, { folderId });
|
|
191
|
+
return result.folder;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function getFolderBreadcrumbs(userId: string, folderId: string) {
|
|
195
|
+
await requireFolderAccess(userId, folderId, "READ");
|
|
196
|
+
return listFolderBreadcrumbs(folderId);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function getFolderParents(userId: string, folderId: string) {
|
|
200
|
+
// Permission: user can read this folder.
|
|
201
|
+
await requireFolderAccess(userId, folderId, "READ");
|
|
202
|
+
const breadcrumbs = await listFolderBreadcrumbs(folderId);
|
|
203
|
+
// Existing API returned full folder records — re-fetch them.
|
|
204
|
+
return Promise.all(
|
|
205
|
+
breadcrumbs.map((b) => findFolderById(b.id))
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function createFolderRecord(
|
|
123
210
|
userId: string,
|
|
124
|
-
|
|
125
|
-
|
|
211
|
+
input: {
|
|
212
|
+
name: string;
|
|
213
|
+
parentFolderId?: string | null;
|
|
214
|
+
color?: string;
|
|
215
|
+
scope?: CloudScope;
|
|
216
|
+
}
|
|
126
217
|
) {
|
|
127
|
-
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
218
|
+
let parentId: string;
|
|
219
|
+
if (input.parentFolderId) {
|
|
220
|
+
parentId = input.parentFolderId;
|
|
221
|
+
await requireFolderAccess(userId, parentId, "WRITE");
|
|
222
|
+
} else {
|
|
223
|
+
if (!input.scope) {
|
|
224
|
+
throw new TRPCError({
|
|
225
|
+
code: "BAD_REQUEST",
|
|
226
|
+
message: "scope is required when parentFolderId is omitted",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
await requireScopeAccess(userId, input.scope, "WRITE");
|
|
230
|
+
parentId = (await findOrCreateScopeRoot(input.scope)).id;
|
|
133
231
|
}
|
|
232
|
+
return createSubFolder({
|
|
233
|
+
name: input.name.trim(),
|
|
234
|
+
parentFolderId: parentId,
|
|
235
|
+
color: input.color,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
134
238
|
|
|
135
|
-
|
|
239
|
+
export async function updateFolderRecord(
|
|
240
|
+
userId: string,
|
|
241
|
+
input: { folderId: string; name?: string; color?: string }
|
|
242
|
+
) {
|
|
243
|
+
const folder = await findFolderById(input.folderId);
|
|
136
244
|
if (!folder) {
|
|
245
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Folder not found" });
|
|
246
|
+
}
|
|
247
|
+
if (!folder.parentFolderId) {
|
|
137
248
|
throw new TRPCError({
|
|
138
|
-
code: "
|
|
139
|
-
message: "
|
|
249
|
+
code: "BAD_REQUEST",
|
|
250
|
+
message: "Cannot rename a root folder",
|
|
140
251
|
});
|
|
141
252
|
}
|
|
253
|
+
await requireFolderAccess(userId, input.folderId, "WRITE");
|
|
254
|
+
return renameFolder(
|
|
255
|
+
input.folderId,
|
|
256
|
+
input.name?.trim() ?? folder.name,
|
|
257
|
+
input.color
|
|
258
|
+
);
|
|
259
|
+
}
|
|
142
260
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const current = await findFolderByIdForTraversal(currentId);
|
|
148
|
-
if (!current) break;
|
|
149
|
-
if (current.classId === classId) {
|
|
150
|
-
belongsToClass = true;
|
|
151
|
-
break;
|
|
152
|
-
}
|
|
153
|
-
currentId = current.parentFolderId;
|
|
261
|
+
export async function deleteFolder(userId: string, folderId: string) {
|
|
262
|
+
const folder = await findFolderById(folderId);
|
|
263
|
+
if (!folder) {
|
|
264
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Folder not found" });
|
|
154
265
|
}
|
|
155
|
-
if (!
|
|
266
|
+
if (!folder.parentFolderId) {
|
|
156
267
|
throw new TRPCError({
|
|
157
|
-
code: "
|
|
158
|
-
message: "
|
|
268
|
+
code: "BAD_REQUEST",
|
|
269
|
+
message: "Cannot delete a root folder",
|
|
159
270
|
});
|
|
160
271
|
}
|
|
161
|
-
|
|
272
|
+
await requireFolderAccess(userId, folderId, "WRITE");
|
|
162
273
|
await deleteFolderById(folderId);
|
|
163
|
-
return {
|
|
164
|
-
success: true,
|
|
165
|
-
deletedFiles: folder._count.files,
|
|
166
|
-
deletedFolders: folder._count.childFolders + 1,
|
|
167
|
-
};
|
|
274
|
+
return { success: true };
|
|
168
275
|
}
|
|
169
276
|
|
|
170
277
|
export async function moveFolder(
|
|
171
278
|
userId: string,
|
|
172
|
-
folderId: string
|
|
173
|
-
targetParentFolderId: string,
|
|
174
|
-
classId: string
|
|
279
|
+
input: { folderId: string; targetParentFolderId: string }
|
|
175
280
|
) {
|
|
176
|
-
|
|
177
|
-
if (!classData) {
|
|
281
|
+
if (input.folderId === input.targetParentFolderId) {
|
|
178
282
|
throw new TRPCError({
|
|
179
|
-
code: "
|
|
180
|
-
message: "
|
|
283
|
+
code: "BAD_REQUEST",
|
|
284
|
+
message: "Cannot move a folder into itself",
|
|
181
285
|
});
|
|
182
286
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
});
|
|
287
|
+
const [sourceScope, targetScope] = await Promise.all([
|
|
288
|
+
resolveFolderScope(input.folderId),
|
|
289
|
+
resolveFolderScope(input.targetParentFolderId),
|
|
290
|
+
]);
|
|
291
|
+
if (!sourceScope || !targetScope) {
|
|
292
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Folder not found" });
|
|
190
293
|
}
|
|
191
|
-
|
|
192
|
-
if (!folder.parentFolderId) {
|
|
294
|
+
if (!scopesEqual(sourceScope, targetScope)) {
|
|
193
295
|
throw new TRPCError({
|
|
194
296
|
code: "BAD_REQUEST",
|
|
195
|
-
message: "Cannot move
|
|
297
|
+
message: "Cannot move folders between scopes",
|
|
196
298
|
});
|
|
197
299
|
}
|
|
300
|
+
await requireScopeAccess(userId, sourceScope, "WRITE");
|
|
198
301
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
code: "NOT_FOUND",
|
|
204
|
-
message: "Target parent folder not found",
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (targetParentFolderId === folderId) {
|
|
302
|
+
// Don't let a folder land under one of its own descendants.
|
|
303
|
+
let cursor: string | null = input.targetParentFolderId;
|
|
304
|
+
while (cursor) {
|
|
305
|
+
if (cursor === input.folderId) {
|
|
209
306
|
throw new TRPCError({
|
|
210
307
|
code: "BAD_REQUEST",
|
|
211
|
-
message: "Cannot move a folder into
|
|
308
|
+
message: "Cannot move a folder into its own descendant",
|
|
212
309
|
});
|
|
213
310
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if (currentParent.parentFolderId === folderId) {
|
|
218
|
-
throw new TRPCError({
|
|
219
|
-
code: "BAD_REQUEST",
|
|
220
|
-
message: "Cannot move a folder into its descendant",
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
currentParent = await findFolderByIdForTraversal(currentParent.parentFolderId);
|
|
224
|
-
if (!currentParent) break;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
return updateFolderParent(folderId, targetParentFolderId);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export async function updateFolderRecord(
|
|
232
|
-
folderId: string,
|
|
233
|
-
name: string,
|
|
234
|
-
color: string | undefined,
|
|
235
|
-
classId: string
|
|
236
|
-
) {
|
|
237
|
-
const folder = await findFolderById(folderId);
|
|
238
|
-
if (!folder) {
|
|
239
|
-
throw new TRPCError({
|
|
240
|
-
code: "NOT_FOUND",
|
|
241
|
-
message: "Folder not found",
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
if (!name.trim()) {
|
|
246
|
-
throw new TRPCError({
|
|
247
|
-
code: "BAD_REQUEST",
|
|
248
|
-
message: "Folder name cannot be empty",
|
|
311
|
+
const node = await prisma.folder.findUnique({
|
|
312
|
+
where: { id: cursor },
|
|
313
|
+
select: { parentFolderId: true },
|
|
249
314
|
});
|
|
315
|
+
cursor = node?.parentFolderId ?? null;
|
|
250
316
|
}
|
|
251
317
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
318
|
+
await prisma.folder.update({
|
|
319
|
+
where: { id: input.folderId },
|
|
320
|
+
data: { parentFolderId: input.targetParentFolderId },
|
|
255
321
|
});
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
export async function getFolderParents(folderId: string) {
|
|
259
|
-
const parents: Awaited<ReturnType<typeof findFolderByIdForTraversal>>[] = [];
|
|
260
|
-
let currentId: string | null = folderId;
|
|
261
|
-
|
|
262
|
-
while (currentId) {
|
|
263
|
-
const folder = await findFolderByIdForTraversal(currentId);
|
|
264
|
-
if (!folder) break;
|
|
265
|
-
parents.push(folder);
|
|
266
|
-
currentId = folder.parentFolderId;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
return parents;
|
|
322
|
+
return { success: true };
|
|
270
323
|
}
|
|
271
324
|
|
|
272
325
|
export async function getFolderUploadUrls(
|
|
273
326
|
userId: string,
|
|
274
|
-
|
|
275
|
-
folderId: string,
|
|
276
|
-
files: { name: string; type: string; size: number }[]
|
|
327
|
+
input: { folderId: string; files: DirectFileData[] }
|
|
277
328
|
) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
code: "NOT_FOUND",
|
|
282
|
-
message: "Class not found or you are not a teacher",
|
|
283
|
-
});
|
|
284
|
-
}
|
|
329
|
+
await requireFolderAccess(userId, input.folderId, "WRITE");
|
|
330
|
+
return createDirectUploadFiles(input.files, userId, input.folderId);
|
|
331
|
+
}
|
|
285
332
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
333
|
+
export async function confirmFolderUpload(
|
|
334
|
+
_userId: string,
|
|
335
|
+
input: { fileId: string; uploadSuccess: boolean; errorMessage?: string }
|
|
336
|
+
) {
|
|
337
|
+
// Permission was already checked at upload-url issuance — the file record
|
|
338
|
+
// exists with PENDING status, and the user holds the session id needed to
|
|
339
|
+
// PUT bytes. Confirming flips status, so we just trust the call.
|
|
340
|
+
await confirmDirectUpload(
|
|
341
|
+
input.fileId,
|
|
342
|
+
input.uploadSuccess,
|
|
343
|
+
input.errorMessage
|
|
344
|
+
);
|
|
345
|
+
return { success: true };
|
|
346
|
+
}
|
|
293
347
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
348
|
+
/** What scopes can this user pick from in cloud-storage UIs? */
|
|
349
|
+
export async function listUserScopes(userId: string) {
|
|
350
|
+
const memberships = await prisma.schoolMembership.findMany({
|
|
351
|
+
where: { userId, status: "ACTIVE" },
|
|
352
|
+
select: {
|
|
353
|
+
role: true,
|
|
354
|
+
school: {
|
|
355
|
+
select: { id: true, name: true, logoId: true },
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
orderBy: { createdAt: "asc" },
|
|
359
|
+
});
|
|
307
360
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
361
|
+
const classes = await prisma.class.findMany({
|
|
362
|
+
where: {
|
|
363
|
+
OR: [
|
|
364
|
+
{ teachers: { some: { id: userId } } },
|
|
365
|
+
{ students: { some: { id: userId } } },
|
|
366
|
+
],
|
|
367
|
+
},
|
|
368
|
+
select: {
|
|
369
|
+
id: true,
|
|
370
|
+
name: true,
|
|
371
|
+
subject: true,
|
|
372
|
+
section: true,
|
|
373
|
+
color: true,
|
|
374
|
+
schoolId: true,
|
|
375
|
+
},
|
|
376
|
+
orderBy: { name: "asc" },
|
|
377
|
+
});
|
|
314
378
|
|
|
315
|
-
return
|
|
379
|
+
return {
|
|
380
|
+
personal: { kind: "PERSONAL" as const, userId },
|
|
381
|
+
schools: memberships.map((m) => ({
|
|
382
|
+
id: m.school.id,
|
|
383
|
+
name: m.school.name,
|
|
384
|
+
logoId: m.school.logoId,
|
|
385
|
+
role: m.role,
|
|
386
|
+
})),
|
|
387
|
+
classes,
|
|
388
|
+
};
|
|
316
389
|
}
|
package/src/services/labChat.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
claimMessageAttachments,
|
|
26
26
|
ensureMessageHasContentOrAttachments,
|
|
27
27
|
} from "./message.js";
|
|
28
|
+
import { messageFileSelect } from "../models/message.js";
|
|
28
29
|
|
|
29
30
|
/** Create a lab chat with conversation and teacher members. Broadcasts via Pusher. */
|
|
30
31
|
export async function createLabChat(
|
|
@@ -307,7 +308,7 @@ export async function postToLabChat(
|
|
|
307
308
|
},
|
|
308
309
|
},
|
|
309
310
|
attachments: {
|
|
310
|
-
select:
|
|
311
|
+
select: messageFileSelect,
|
|
311
312
|
},
|
|
312
313
|
},
|
|
313
314
|
});
|