@studious-lms/server 1.8.0 → 1.11.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/agenda.d.ts +2 -2
- package/dist/models/announcement.d.ts +2 -2
- package/dist/models/assignment.d.ts +33 -33
- package/dist/models/class.d.ts +2 -2
- package/dist/models/cloudStorage.d.ts +14 -1
- package/dist/models/cloudStorage.d.ts.map +1 -1
- package/dist/models/cloudStorage.js +123 -3
- package/dist/models/cloudStorage.js.map +1 -1
- package/dist/models/event.d.ts +3 -3
- package/dist/models/file.d.ts +27 -26
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/file.js +4 -39
- package/dist/models/file.js.map +1 -1
- package/dist/models/labChat.d.ts +6 -6
- package/dist/models/section.d.ts +1 -1
- package/dist/routers/_app.d.ts +444 -100
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +4 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/agenda.d.ts +2 -2
- package/dist/routers/assignment.d.ts +31 -31
- package/dist/routers/class.d.ts +5 -5
- package/dist/routers/event.d.ts +2 -2
- package/dist/routers/file.d.ts +2 -2
- package/dist/routers/folder.d.ts +24 -0
- package/dist/routers/folder.d.ts.map +1 -1
- package/dist/routers/folder.js +27 -5
- package/dist/routers/folder.js.map +1 -1
- package/dist/routers/labChat.d.ts +2 -2
- package/dist/routers/newtonChat.d.ts +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/studentProgress.d.ts +45 -0
- package/dist/routers/studentProgress.d.ts.map +1 -1
- package/dist/routers/studentProgress.js +12 -3
- package/dist/routers/studentProgress.js.map +1 -1
- package/dist/routers/tag.d.ts +5 -5
- package/dist/services/agenda.d.ts +2 -2
- package/dist/services/assignment.d.ts +31 -31
- package/dist/services/class.d.ts +5 -5
- package/dist/services/class.js +3 -3
- package/dist/services/class.js.map +1 -1
- package/dist/services/event.d.ts +2 -2
- package/dist/services/file.d.ts +22 -5
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/file.js +81 -45
- package/dist/services/file.js.map +1 -1
- package/dist/services/folder.d.ts +97 -2
- package/dist/services/folder.d.ts.map +1 -1
- package/dist/services/folder.js +158 -87
- package/dist/services/folder.js.map +1 -1
- package/dist/services/labChat.d.ts +2 -2
- 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/studentProgress.d.ts +94 -0
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +94 -3
- package/dist/services/studentProgress.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260510170000_add_folder_permissions/migration.sql +93 -0
- package/prisma/schema.prisma +74 -0
- package/src/middleware/security.ts +1 -1
- package/src/models/cloudStorage.ts +134 -1
- package/src/models/file.ts +2 -37
- package/src/routers/_app.ts +2 -0
- package/src/routers/folder.ts +32 -2
- package/src/routers/permissions.ts +71 -0
- package/src/routers/studentProgress.ts +21 -0
- package/src/services/class.ts +1 -1
- package/src/services/file.ts +76 -47
- package/src/services/folder.ts +180 -91
- package/src/services/permissions.ts +778 -0
- package/src/services/studentProgress.ts +197 -11
- package/tests/unit/services/permissions.test.ts +463 -0
- package/tests/unit/services/studentProgress.test.ts +168 -2
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
-- Add folder-level ACL permissions (v1: folder-only).
|
|
2
|
+
--
|
|
3
|
+
-- This migration introduces the Permission table and its supporting enums.
|
|
4
|
+
-- It is *purely additive* — no existing column or row is modified, so it can
|
|
5
|
+
-- be applied safely on a live database without backfill.
|
|
6
|
+
--
|
|
7
|
+
-- File-level grants are deliberately deferred. When/if added later, they can
|
|
8
|
+
-- be a non-breaking additive migration (new columns + back-relation).
|
|
9
|
+
|
|
10
|
+
-- CreateEnum
|
|
11
|
+
CREATE TYPE "PermissionPrincipalType" AS ENUM (
|
|
12
|
+
'USER',
|
|
13
|
+
'TAG',
|
|
14
|
+
'CLASS',
|
|
15
|
+
'CLASS_TEACHERS',
|
|
16
|
+
'CLASS_STUDENTS',
|
|
17
|
+
'DEPARTMENT',
|
|
18
|
+
'SCHOOL'
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
-- CreateEnum
|
|
22
|
+
CREATE TYPE "PermissionLevel" AS ENUM ('READ', 'WRITE');
|
|
23
|
+
|
|
24
|
+
-- CreateTable
|
|
25
|
+
CREATE TABLE "Permission" (
|
|
26
|
+
"id" TEXT NOT NULL DEFAULT gen_random_uuid()::text,
|
|
27
|
+
"folderId" TEXT NOT NULL,
|
|
28
|
+
"principalType" "PermissionPrincipalType" NOT NULL,
|
|
29
|
+
"userId" TEXT,
|
|
30
|
+
"tagId" TEXT,
|
|
31
|
+
"classId" TEXT,
|
|
32
|
+
"departmentId" TEXT,
|
|
33
|
+
"schoolId" TEXT,
|
|
34
|
+
"level" "PermissionLevel" NOT NULL,
|
|
35
|
+
"grantedById" TEXT NOT NULL,
|
|
36
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
37
|
+
|
|
38
|
+
CONSTRAINT "Permission_pkey" PRIMARY KEY ("id")
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
-- CreateIndex
|
|
42
|
+
CREATE INDEX "Permission_folderId_idx" ON "Permission"("folderId");
|
|
43
|
+
|
|
44
|
+
-- CreateIndex
|
|
45
|
+
CREATE INDEX "Permission_userId_idx" ON "Permission"("userId");
|
|
46
|
+
|
|
47
|
+
-- CreateIndex
|
|
48
|
+
CREATE INDEX "Permission_tagId_idx" ON "Permission"("tagId");
|
|
49
|
+
|
|
50
|
+
-- CreateIndex
|
|
51
|
+
CREATE INDEX "Permission_classId_idx" ON "Permission"("classId");
|
|
52
|
+
|
|
53
|
+
-- CreateIndex
|
|
54
|
+
CREATE INDEX "Permission_departmentId_idx" ON "Permission"("departmentId");
|
|
55
|
+
|
|
56
|
+
-- CreateIndex
|
|
57
|
+
CREATE INDEX "Permission_schoolId_idx" ON "Permission"("schoolId");
|
|
58
|
+
|
|
59
|
+
-- CreateIndex
|
|
60
|
+
-- One grant per (folder, principal). Re-granting upserts the level.
|
|
61
|
+
-- Postgres treats nulls as distinct in unique indexes, but the service layer
|
|
62
|
+
-- guarantees exactly one principal id is non-null per row, so this still
|
|
63
|
+
-- uniquely identifies the row.
|
|
64
|
+
CREATE UNIQUE INDEX "Permission_folderId_principalType_userId_tagId_classId_depa_key"
|
|
65
|
+
ON "Permission"("folderId", "principalType", "userId", "tagId", "classId", "departmentId", "schoolId");
|
|
66
|
+
|
|
67
|
+
-- AddForeignKey
|
|
68
|
+
ALTER TABLE "Permission" ADD CONSTRAINT "Permission_folderId_fkey"
|
|
69
|
+
FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
70
|
+
|
|
71
|
+
-- AddForeignKey
|
|
72
|
+
ALTER TABLE "Permission" ADD CONSTRAINT "Permission_userId_fkey"
|
|
73
|
+
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
74
|
+
|
|
75
|
+
-- AddForeignKey
|
|
76
|
+
ALTER TABLE "Permission" ADD CONSTRAINT "Permission_tagId_fkey"
|
|
77
|
+
FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
78
|
+
|
|
79
|
+
-- AddForeignKey
|
|
80
|
+
ALTER TABLE "Permission" ADD CONSTRAINT "Permission_classId_fkey"
|
|
81
|
+
FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
82
|
+
|
|
83
|
+
-- AddForeignKey
|
|
84
|
+
ALTER TABLE "Permission" ADD CONSTRAINT "Permission_departmentId_fkey"
|
|
85
|
+
FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
86
|
+
|
|
87
|
+
-- AddForeignKey
|
|
88
|
+
ALTER TABLE "Permission" ADD CONSTRAINT "Permission_schoolId_fkey"
|
|
89
|
+
FOREIGN KEY ("schoolId") REFERENCES "School"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
90
|
+
|
|
91
|
+
-- AddForeignKey
|
|
92
|
+
ALTER TABLE "Permission" ADD CONSTRAINT "Permission_grantedById_fkey"
|
|
93
|
+
FOREIGN KEY ("grantedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
package/prisma/schema.prisma
CHANGED
|
@@ -127,6 +127,7 @@ model School {
|
|
|
127
127
|
departments Department[]
|
|
128
128
|
tags Tag[]
|
|
129
129
|
storageRoot Folder? @relation("SchoolStorageRoot")
|
|
130
|
+
permissions Permission[] @relation("SchoolPermissions")
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
model Department {
|
|
@@ -144,6 +145,7 @@ model Department {
|
|
|
144
145
|
tags Tag[]
|
|
145
146
|
rubricTemplates MarkScheme[]
|
|
146
147
|
boundaryTemplates GradingBoundary[]
|
|
148
|
+
permissions Permission[] @relation("DepartmentPermissions")
|
|
147
149
|
|
|
148
150
|
@@unique([schoolId, name])
|
|
149
151
|
@@index([schoolId])
|
|
@@ -171,6 +173,7 @@ model Tag {
|
|
|
171
173
|
classes Class[] @relation("ClassTags")
|
|
172
174
|
rubrics MarkScheme[] @relation("MarkSchemeTags")
|
|
173
175
|
boundaries GradingBoundary[] @relation("GradingBoundaryTags")
|
|
176
|
+
permissions Permission[] @relation("TagPermissions")
|
|
174
177
|
|
|
175
178
|
@@unique([schoolId, name])
|
|
176
179
|
@@index([schoolId])
|
|
@@ -213,6 +216,10 @@ model User {
|
|
|
213
216
|
tags Tag[] @relation("UserTags")
|
|
214
217
|
personalStorage Folder? @relation("UserStorageRoot")
|
|
215
218
|
|
|
219
|
+
// Permission grants where this user is the principal vs. the granter.
|
|
220
|
+
userPermissions Permission[] @relation("UserPermissions")
|
|
221
|
+
grantedPermissions Permission[] @relation("GrantedPermissions")
|
|
222
|
+
|
|
216
223
|
// Chat relations
|
|
217
224
|
conversationMemberships ConversationMember[]
|
|
218
225
|
sentMessages Message[] @relation("SentMessages")
|
|
@@ -303,6 +310,7 @@ model Class {
|
|
|
303
310
|
department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull)
|
|
304
311
|
departmentId String?
|
|
305
312
|
tags Tag[] @relation("ClassTags")
|
|
313
|
+
permissions Permission[] @relation("ClassPermissions")
|
|
306
314
|
|
|
307
315
|
@@index([schoolId])
|
|
308
316
|
@@index([departmentId])
|
|
@@ -361,6 +369,8 @@ model Folder {
|
|
|
361
369
|
schoolStorageId String? @unique
|
|
362
370
|
personalOwner User? @relation("UserStorageRoot", fields: [personalOwnerId], references: [id], onDelete: Cascade)
|
|
363
371
|
personalOwnerId String? @unique
|
|
372
|
+
|
|
373
|
+
permissions Permission[] @relation("FolderPermissions")
|
|
364
374
|
}
|
|
365
375
|
|
|
366
376
|
model File {
|
|
@@ -898,3 +908,67 @@ model RunJob {
|
|
|
898
908
|
@@index([classId, status])
|
|
899
909
|
@@index([status, createdAt])
|
|
900
910
|
}
|
|
911
|
+
|
|
912
|
+
/// What kind of principal an explicit Permission row grants access to.
|
|
913
|
+
/// CLASS = everyone in the class (teachers + students). CLASS_TEACHERS /
|
|
914
|
+
/// CLASS_STUDENTS narrow that to one role. Stored alongside a single FK
|
|
915
|
+
/// (`classId`, `userId`, etc.) — see service-layer invariants.
|
|
916
|
+
enum PermissionPrincipalType {
|
|
917
|
+
USER
|
|
918
|
+
TAG
|
|
919
|
+
CLASS
|
|
920
|
+
CLASS_TEACHERS
|
|
921
|
+
CLASS_STUDENTS
|
|
922
|
+
DEPARTMENT
|
|
923
|
+
SCHOOL
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
enum PermissionLevel {
|
|
927
|
+
READ
|
|
928
|
+
WRITE
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/// Explicit ACL grant on a folder. Sits on top of (and is purely additive
|
|
932
|
+
/// to) the scope-derived defaults computed from FolderScope. Files inherit
|
|
933
|
+
/// from their parent folder — file-level overrides are deliberately deferred
|
|
934
|
+
/// to a future, additive migration that adds a `fileId` column + discriminator.
|
|
935
|
+
///
|
|
936
|
+
/// Schema invariant enforced in the service layer (Prisma can't express it):
|
|
937
|
+
/// - Exactly one of `userId` / `tagId` / `classId` / `departmentId` /
|
|
938
|
+
/// `schoolId` is set, matching `principalType`. CLASS, CLASS_TEACHERS,
|
|
939
|
+
/// CLASS_STUDENTS all share `classId`.
|
|
940
|
+
model Permission {
|
|
941
|
+
id String @id @default(uuid())
|
|
942
|
+
folderId String
|
|
943
|
+
folder Folder @relation("FolderPermissions", fields: [folderId], references: [id], onDelete: Cascade)
|
|
944
|
+
|
|
945
|
+
principalType PermissionPrincipalType
|
|
946
|
+
userId String?
|
|
947
|
+
tagId String?
|
|
948
|
+
classId String?
|
|
949
|
+
departmentId String?
|
|
950
|
+
schoolId String?
|
|
951
|
+
|
|
952
|
+
user User? @relation("UserPermissions", fields: [userId], references: [id], onDelete: Cascade)
|
|
953
|
+
tag Tag? @relation("TagPermissions", fields: [tagId], references: [id], onDelete: Cascade)
|
|
954
|
+
class Class? @relation("ClassPermissions", fields: [classId], references: [id], onDelete: Cascade)
|
|
955
|
+
department Department? @relation("DepartmentPermissions", fields: [departmentId], references: [id], onDelete: Cascade)
|
|
956
|
+
school School? @relation("SchoolPermissions", fields: [schoolId], references: [id], onDelete: Cascade)
|
|
957
|
+
|
|
958
|
+
level PermissionLevel
|
|
959
|
+
grantedById String
|
|
960
|
+
grantedBy User @relation("GrantedPermissions", fields: [grantedById], references: [id])
|
|
961
|
+
createdAt DateTime @default(now())
|
|
962
|
+
|
|
963
|
+
/// One grant per (folder, principal). Re-granting upserts the level.
|
|
964
|
+
/// Postgres treats nulls as distinct in unique indexes, but the service layer
|
|
965
|
+
/// guarantees exactly one principal id is non-null, so this still uniquely
|
|
966
|
+
/// identifies the row.
|
|
967
|
+
@@unique([folderId, principalType, userId, tagId, classId, departmentId, schoolId])
|
|
968
|
+
@@index([folderId])
|
|
969
|
+
@@index([userId])
|
|
970
|
+
@@index([tagId])
|
|
971
|
+
@@index([classId])
|
|
972
|
+
@@index([departmentId])
|
|
973
|
+
@@index([schoolId])
|
|
974
|
+
}
|
|
@@ -20,7 +20,7 @@ const rateLimitHandler = (req: Request, res: Response) => {
|
|
|
20
20
|
// General API rate limiter - applies to all routes
|
|
21
21
|
export const generalLimiter = rateLimit({
|
|
22
22
|
windowMs: 10 * 60 * 1000, // 10 minutes
|
|
23
|
-
max:
|
|
23
|
+
max: 1000, // Limit each IP to 100 requests per windowMs
|
|
24
24
|
message: 'Too many requests from this IP, please try again later.',
|
|
25
25
|
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
|
26
26
|
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
|
@@ -176,10 +176,130 @@ export function renameFolder(folderId: string, name: string, color?: string) {
|
|
|
176
176
|
});
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
|
|
179
|
+
/**
|
|
180
|
+
* Build the breadcrumb chain for a folder.
|
|
181
|
+
*
|
|
182
|
+
* Without `viewerId`: walks all the way up to the scope root (owner-style
|
|
183
|
+
* view).
|
|
184
|
+
*
|
|
185
|
+
* With `viewerId`: walks up but stops at the highest folder the viewer is
|
|
186
|
+
* actually granted on, OR at the scope root if the viewer is a scope member.
|
|
187
|
+
* This is the Drive-style "Shared with me" behavior — a grantee never sees
|
|
188
|
+
* ancestor folders they weren't shared on. The result for a grantee browsing
|
|
189
|
+
* `Owner/PrivateA/SharedB/C` (granted on B) reads `SharedB / C` — the ancestor
|
|
190
|
+
* `PrivateA` is correctly hidden.
|
|
191
|
+
*/
|
|
192
|
+
export async function listFolderBreadcrumbs(
|
|
193
|
+
folderId: string,
|
|
194
|
+
viewerId?: string
|
|
195
|
+
) {
|
|
180
196
|
const breadcrumbs: { id: string; name: string }[] = [];
|
|
181
197
|
let currentId: string | null = folderId;
|
|
182
198
|
let visits = 0;
|
|
199
|
+
|
|
200
|
+
let viewerGrantedFolderIds: Set<string> | null = null;
|
|
201
|
+
let viewerScopeMember = false;
|
|
202
|
+
|
|
203
|
+
if (viewerId) {
|
|
204
|
+
const grants = await prisma.permission.findMany({
|
|
205
|
+
where: {
|
|
206
|
+
OR: [
|
|
207
|
+
{ principalType: "USER", userId: viewerId },
|
|
208
|
+
{
|
|
209
|
+
principalType: "TAG",
|
|
210
|
+
tag: { users: { some: { id: viewerId } } },
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
principalType: "CLASS",
|
|
214
|
+
class: {
|
|
215
|
+
OR: [
|
|
216
|
+
{ teachers: { some: { id: viewerId } } },
|
|
217
|
+
{ students: { some: { id: viewerId } } },
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
principalType: "CLASS_TEACHERS",
|
|
223
|
+
class: { teachers: { some: { id: viewerId } } },
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
principalType: "CLASS_STUDENTS",
|
|
227
|
+
class: { students: { some: { id: viewerId } } },
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
principalType: "DEPARTMENT",
|
|
231
|
+
department: { members: { some: { id: viewerId } } },
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
principalType: "SCHOOL",
|
|
235
|
+
school: {
|
|
236
|
+
memberships: { some: { userId: viewerId, status: "ACTIVE" } },
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
select: { folderId: true },
|
|
242
|
+
});
|
|
243
|
+
viewerGrantedFolderIds = new Set(
|
|
244
|
+
grants.map((g) => g.folderId).filter((x): x is string => Boolean(x))
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// Determine if the viewer is a member of the *target* folder's scope —
|
|
248
|
+
// if so, breadcrumbs go all the way to the scope root as normal.
|
|
249
|
+
const scope = await resolveFolderScope(folderId);
|
|
250
|
+
if (scope) {
|
|
251
|
+
switch (scope.kind) {
|
|
252
|
+
case "CLASS": {
|
|
253
|
+
const c = await prisma.class.findFirst({
|
|
254
|
+
where: {
|
|
255
|
+
id: scope.classId,
|
|
256
|
+
OR: [
|
|
257
|
+
{ teachers: { some: { id: viewerId } } },
|
|
258
|
+
{ students: { some: { id: viewerId } } },
|
|
259
|
+
],
|
|
260
|
+
},
|
|
261
|
+
select: { id: true },
|
|
262
|
+
});
|
|
263
|
+
viewerScopeMember = Boolean(c);
|
|
264
|
+
if (!viewerScopeMember && scope.classId) {
|
|
265
|
+
const klass = await prisma.class.findUnique({
|
|
266
|
+
where: { id: scope.classId },
|
|
267
|
+
select: { schoolId: true },
|
|
268
|
+
});
|
|
269
|
+
if (klass?.schoolId) {
|
|
270
|
+
const m = await prisma.schoolMembership.findFirst({
|
|
271
|
+
where: {
|
|
272
|
+
schoolId: klass.schoolId,
|
|
273
|
+
userId: viewerId,
|
|
274
|
+
role: "ADMIN",
|
|
275
|
+
status: "ACTIVE",
|
|
276
|
+
},
|
|
277
|
+
select: { id: true },
|
|
278
|
+
});
|
|
279
|
+
viewerScopeMember = Boolean(m);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
case "SCHOOL_STORAGE": {
|
|
285
|
+
const m = await prisma.schoolMembership.findFirst({
|
|
286
|
+
where: {
|
|
287
|
+
schoolId: scope.schoolId,
|
|
288
|
+
userId: viewerId,
|
|
289
|
+
status: "ACTIVE",
|
|
290
|
+
},
|
|
291
|
+
select: { id: true },
|
|
292
|
+
});
|
|
293
|
+
viewerScopeMember = Boolean(m);
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
case "PERSONAL":
|
|
297
|
+
viewerScopeMember = scope.userId === viewerId;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
183
303
|
while (currentId && visits < 64) {
|
|
184
304
|
const folder = await prisma.folder.findUnique({
|
|
185
305
|
where: { id: currentId },
|
|
@@ -187,6 +307,19 @@ export async function listFolderBreadcrumbs(folderId: string) {
|
|
|
187
307
|
});
|
|
188
308
|
if (!folder) break;
|
|
189
309
|
breadcrumbs.unshift({ id: folder.id, name: folder.name });
|
|
310
|
+
|
|
311
|
+
// Viewer-aware truncation: when the viewer is *not* a scope member but
|
|
312
|
+
// is a grantee, stop at the highest granted ancestor (we walk up one
|
|
313
|
+
// more step into the granted folder, then stop there).
|
|
314
|
+
if (
|
|
315
|
+
viewerId &&
|
|
316
|
+
!viewerScopeMember &&
|
|
317
|
+
viewerGrantedFolderIds &&
|
|
318
|
+
viewerGrantedFolderIds.has(folder.id)
|
|
319
|
+
) {
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
|
|
190
323
|
currentId = folder.parentFolderId;
|
|
191
324
|
visits += 1;
|
|
192
325
|
}
|
package/src/models/file.ts
CHANGED
|
@@ -8,9 +8,6 @@ const classFileAccessInclude = {
|
|
|
8
8
|
teachers: true,
|
|
9
9
|
school: {
|
|
10
10
|
include: {
|
|
11
|
-
users: {
|
|
12
|
-
select: { id: true },
|
|
13
|
-
},
|
|
14
11
|
memberships: {
|
|
15
12
|
where: {
|
|
16
13
|
role: "ADMIN" as const,
|
|
@@ -40,23 +37,7 @@ export function findFileById(fileId: string) {
|
|
|
40
37
|
assignment: {
|
|
41
38
|
include: {
|
|
42
39
|
class: {
|
|
43
|
-
include:
|
|
44
|
-
teachers: true,
|
|
45
|
-
school: {
|
|
46
|
-
include: {
|
|
47
|
-
users: {
|
|
48
|
-
select: { id: true },
|
|
49
|
-
},
|
|
50
|
-
memberships: {
|
|
51
|
-
where: {
|
|
52
|
-
role: "ADMIN" as const,
|
|
53
|
-
status: "ACTIVE" as const,
|
|
54
|
-
},
|
|
55
|
-
select: { userId: true },
|
|
56
|
-
},
|
|
57
|
-
},
|
|
58
|
-
},
|
|
59
|
-
},
|
|
40
|
+
include: classFileAccessInclude,
|
|
60
41
|
},
|
|
61
42
|
},
|
|
62
43
|
},
|
|
@@ -68,23 +49,7 @@ export function findFileById(fileId: string) {
|
|
|
68
49
|
assignment: {
|
|
69
50
|
include: {
|
|
70
51
|
class: {
|
|
71
|
-
include:
|
|
72
|
-
teachers: true,
|
|
73
|
-
school: {
|
|
74
|
-
include: {
|
|
75
|
-
users: {
|
|
76
|
-
select: { id: true },
|
|
77
|
-
},
|
|
78
|
-
memberships: {
|
|
79
|
-
where: {
|
|
80
|
-
role: "ADMIN" as const,
|
|
81
|
-
status: "ACTIVE" as const,
|
|
82
|
-
},
|
|
83
|
-
select: { userId: true },
|
|
84
|
-
},
|
|
85
|
-
},
|
|
86
|
-
},
|
|
87
|
-
},
|
|
52
|
+
include: classFileAccessInclude,
|
|
88
53
|
},
|
|
89
54
|
},
|
|
90
55
|
},
|
package/src/routers/_app.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { searchRouter } from "./search.js";
|
|
|
25
25
|
import { schoolRouter } from "./school.js";
|
|
26
26
|
import { departmentRouter } from "./department.js";
|
|
27
27
|
import { tagRouter } from "./tag.js";
|
|
28
|
+
import { permissionsRouter } from "./permissions.js";
|
|
28
29
|
|
|
29
30
|
export const appRouter = createTRPCRouter({
|
|
30
31
|
class: classRouter,
|
|
@@ -51,6 +52,7 @@ export const appRouter = createTRPCRouter({
|
|
|
51
52
|
school: schoolRouter,
|
|
52
53
|
department: departmentRouter,
|
|
53
54
|
tag: tagRouter,
|
|
55
|
+
permissions: permissionsRouter,
|
|
54
56
|
});
|
|
55
57
|
|
|
56
58
|
// Export type router type definition
|
package/src/routers/folder.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "../trpc.js";
|
|
6
6
|
import {
|
|
7
7
|
browseFolder,
|
|
8
|
+
browseSharedWithMe,
|
|
8
9
|
confirmFolderUpload,
|
|
9
10
|
createFolderRecord,
|
|
10
11
|
deleteFolder,
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
moveFolder,
|
|
17
18
|
updateFolderRecord,
|
|
18
19
|
} from "../services/folder.js";
|
|
20
|
+
import { listSharedWithMe } from "../services/permissions.js";
|
|
19
21
|
|
|
20
22
|
const directFileSchema = z.object({
|
|
21
23
|
name: z.string(),
|
|
@@ -40,16 +42,44 @@ const cloudScopeSchema = z.discriminatedUnion("kind", [
|
|
|
40
42
|
z.object({ kind: z.literal("PERSONAL"), userId: z.string() }),
|
|
41
43
|
]);
|
|
42
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Extended scope schema for `browse` only. SHARED_WITH_ME is a *virtual*
|
|
47
|
+
* scope — it has no root row in the `Folder` table and you can't create
|
|
48
|
+
* folders inside it directly. It just collects every top-most explicit grant
|
|
49
|
+
* the user holds for "Shared with me"-style display.
|
|
50
|
+
*/
|
|
51
|
+
const browseScopeSchema = z.discriminatedUnion("kind", [
|
|
52
|
+
z.object({ kind: z.literal("CLASS"), classId: z.string() }),
|
|
53
|
+
z.object({ kind: z.literal("SCHOOL_STORAGE"), schoolId: z.string() }),
|
|
54
|
+
z.object({ kind: z.literal("PERSONAL"), userId: z.string() }),
|
|
55
|
+
z.object({ kind: z.literal("SHARED_WITH_ME") }),
|
|
56
|
+
]);
|
|
57
|
+
|
|
43
58
|
export const folderRouter = createTRPCRouter({
|
|
44
59
|
/** Browse a folder (or scope root) with breadcrumbs and access flag. */
|
|
45
60
|
browse: protectedProcedure
|
|
46
61
|
.input(
|
|
47
62
|
z.union([
|
|
48
63
|
z.object({ folderId: z.string() }),
|
|
49
|
-
z.object({ scope:
|
|
64
|
+
z.object({ scope: browseScopeSchema }),
|
|
50
65
|
])
|
|
51
66
|
)
|
|
52
|
-
.query(({ ctx, input }) =>
|
|
67
|
+
.query(({ ctx, input }) => {
|
|
68
|
+
if ("scope" in input && input.scope.kind === "SHARED_WITH_ME") {
|
|
69
|
+
return browseSharedWithMe(ctx.user!.id);
|
|
70
|
+
}
|
|
71
|
+
return browseFolder(
|
|
72
|
+
ctx.user!.id,
|
|
73
|
+
"scope" in input
|
|
74
|
+
? { scope: input.scope as Exclude<typeof input.scope, { kind: "SHARED_WITH_ME" }> }
|
|
75
|
+
: input
|
|
76
|
+
);
|
|
77
|
+
}),
|
|
78
|
+
|
|
79
|
+
/** Top-most folders/files explicitly shared with the caller (Drive-style). */
|
|
80
|
+
listSharedWithMe: protectedProcedure.query(({ ctx }) =>
|
|
81
|
+
listSharedWithMe(ctx.user!.id)
|
|
82
|
+
),
|
|
53
83
|
|
|
54
84
|
/** What scopes can this user pick from in the cloud-storage UI? */
|
|
55
85
|
listScopes: protectedProcedure.query(({ ctx }) =>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permissions router. Backs the "Share" dialog and the "Shared with me" view.
|
|
3
|
+
*
|
|
4
|
+
* Permission semantics live in `services/permissions.ts`; this router is just
|
|
5
|
+
* a thin Zod-validated tRPC surface over those helpers.
|
|
6
|
+
*
|
|
7
|
+
* v1 grants only on folders. Files inherit from their parent folder. Adding
|
|
8
|
+
* file-level grants later is a non-breaking, additive change.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
|
12
|
+
import {
|
|
13
|
+
grantPermission,
|
|
14
|
+
listPermissions,
|
|
15
|
+
listSharedWithMe,
|
|
16
|
+
revokePermission,
|
|
17
|
+
type PermissionPrincipalInput,
|
|
18
|
+
} from "../services/permissions.js";
|
|
19
|
+
|
|
20
|
+
const principalSchema: z.ZodType<PermissionPrincipalInput> = z.discriminatedUnion(
|
|
21
|
+
"kind",
|
|
22
|
+
[
|
|
23
|
+
z.object({ kind: z.literal("USER"), userId: z.string() }),
|
|
24
|
+
z.object({ kind: z.literal("TAG"), tagId: z.string() }),
|
|
25
|
+
z.object({ kind: z.literal("CLASS"), classId: z.string() }),
|
|
26
|
+
z.object({ kind: z.literal("CLASS_TEACHERS"), classId: z.string() }),
|
|
27
|
+
z.object({ kind: z.literal("CLASS_STUDENTS"), classId: z.string() }),
|
|
28
|
+
z.object({ kind: z.literal("DEPARTMENT"), departmentId: z.string() }),
|
|
29
|
+
z.object({ kind: z.literal("SCHOOL"), schoolId: z.string() }),
|
|
30
|
+
]
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const levelSchema = z.enum(["READ", "WRITE"]);
|
|
34
|
+
|
|
35
|
+
export const permissionsRouter = createTRPCRouter({
|
|
36
|
+
/** List all explicit grants on a folder. Caller must have WRITE. */
|
|
37
|
+
list: protectedProcedure
|
|
38
|
+
.input(z.object({ folderId: z.string() }))
|
|
39
|
+
.query(({ ctx, input }) =>
|
|
40
|
+
listPermissions(ctx.user!.id, input.folderId)
|
|
41
|
+
),
|
|
42
|
+
|
|
43
|
+
/** Create a new explicit grant on a folder. */
|
|
44
|
+
grant: protectedProcedure
|
|
45
|
+
.input(
|
|
46
|
+
z.object({
|
|
47
|
+
folderId: z.string(),
|
|
48
|
+
principal: principalSchema,
|
|
49
|
+
level: levelSchema,
|
|
50
|
+
})
|
|
51
|
+
)
|
|
52
|
+
.mutation(({ ctx, input }) =>
|
|
53
|
+
grantPermission(ctx.user!.id, {
|
|
54
|
+
folderId: input.folderId,
|
|
55
|
+
principal: input.principal,
|
|
56
|
+
level: input.level,
|
|
57
|
+
})
|
|
58
|
+
),
|
|
59
|
+
|
|
60
|
+
/** Remove an explicit grant. Allowed for the granter or anyone with WRITE on the folder. */
|
|
61
|
+
revoke: protectedProcedure
|
|
62
|
+
.input(z.object({ permissionId: z.string() }))
|
|
63
|
+
.mutation(({ ctx, input }) =>
|
|
64
|
+
revokePermission(ctx.user!.id, input.permissionId)
|
|
65
|
+
),
|
|
66
|
+
|
|
67
|
+
/** Drive-style "Shared with me" — top-most folders explicitly granted to the caller. */
|
|
68
|
+
listSharedWithMe: protectedProcedure.query(({ ctx }) =>
|
|
69
|
+
listSharedWithMe(ctx.user!.id)
|
|
70
|
+
),
|
|
71
|
+
});
|
|
@@ -8,6 +8,9 @@ import {
|
|
|
8
8
|
chatAboutStudentProgress,
|
|
9
9
|
dismissStudentRecommendation,
|
|
10
10
|
getClassProgressRecommendations,
|
|
11
|
+
getStudentProgressFocus,
|
|
12
|
+
getStudentProgressForecast,
|
|
13
|
+
getStudentProgressInsights,
|
|
11
14
|
getStudentProgressRecommendations,
|
|
12
15
|
} from "../services/studentProgress.js";
|
|
13
16
|
|
|
@@ -27,6 +30,24 @@ export const studentProgressRouter = createTRPCRouter({
|
|
|
27
30
|
),
|
|
28
31
|
),
|
|
29
32
|
|
|
33
|
+
getInsights: protectedClassMemberProcedure
|
|
34
|
+
.input(progressInputSchema)
|
|
35
|
+
.query(({ ctx, input }) =>
|
|
36
|
+
getStudentProgressInsights(ctx.user!.id, input.classId, input.studentId),
|
|
37
|
+
),
|
|
38
|
+
|
|
39
|
+
getForecast: protectedClassMemberProcedure
|
|
40
|
+
.input(progressInputSchema)
|
|
41
|
+
.query(({ ctx, input }) =>
|
|
42
|
+
getStudentProgressForecast(ctx.user!.id, input.classId, input.studentId),
|
|
43
|
+
),
|
|
44
|
+
|
|
45
|
+
getStudentFocus: protectedClassMemberProcedure
|
|
46
|
+
.input(progressInputSchema)
|
|
47
|
+
.query(({ ctx, input }) =>
|
|
48
|
+
getStudentProgressFocus(ctx.user!.id, input.classId, input.studentId),
|
|
49
|
+
),
|
|
50
|
+
|
|
30
51
|
getClassRecommendations: protectedTeacherProcedure
|
|
31
52
|
.input(z.object({ classId: z.string() }))
|
|
32
53
|
.query(({ ctx, input }) =>
|
package/src/services/class.ts
CHANGED
|
@@ -74,7 +74,7 @@ export async function getAllClasses(userId: string) {
|
|
|
74
74
|
schoolId: cls.schoolId ?? null,
|
|
75
75
|
school: cls.school ?? null,
|
|
76
76
|
department: cls.department ?? null,
|
|
77
|
-
})),
|
|
77
|
+
})).filter((cls) => cls.members.some((member) => member.id === userId)),
|
|
78
78
|
studentInClass: studentClasses.map((cls) => ({
|
|
79
79
|
id: cls.id,
|
|
80
80
|
name: cls.name,
|