@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/src/services/school.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
removeSchoolMembershipPreservingContent,
|
|
36
36
|
revokeSchoolInvite,
|
|
37
37
|
updateSchoolMembershipRole,
|
|
38
|
+
updateSchoolRecord,
|
|
38
39
|
upsertSchoolMembership,
|
|
39
40
|
} from "../models/school.js";
|
|
40
41
|
|
|
@@ -66,8 +67,22 @@ export async function getCurrentSchool(userId: string, schoolId?: string) {
|
|
|
66
67
|
? memberships.find((membership) => membership.schoolId === schoolId)
|
|
67
68
|
: memberships[0];
|
|
68
69
|
|
|
70
|
+
const activeSchool = activeMembership?.school
|
|
71
|
+
? {
|
|
72
|
+
id: activeMembership.school.id,
|
|
73
|
+
name: activeMembership.school.name,
|
|
74
|
+
description: activeMembership.school.description ?? null,
|
|
75
|
+
subdomain: activeMembership.school.subdomain ?? null,
|
|
76
|
+
logoId: activeMembership.school.logoId ?? null,
|
|
77
|
+
// The path is the GCS object key — the frontend resolves it to a
|
|
78
|
+
// signed URL through trpc.file.getSignedUrl when needed.
|
|
79
|
+
logoPath: activeMembership.school.logo?.path ?? null,
|
|
80
|
+
logoType: activeMembership.school.logo?.type ?? null,
|
|
81
|
+
}
|
|
82
|
+
: null;
|
|
83
|
+
|
|
69
84
|
return {
|
|
70
|
-
activeSchool
|
|
85
|
+
activeSchool,
|
|
71
86
|
activeRole: activeMembership?.role ?? null,
|
|
72
87
|
memberships,
|
|
73
88
|
};
|
|
@@ -75,7 +90,12 @@ export async function getCurrentSchool(userId: string, schoolId?: string) {
|
|
|
75
90
|
|
|
76
91
|
export async function createSchoolRecord(
|
|
77
92
|
userId: string,
|
|
78
|
-
input: {
|
|
93
|
+
input: {
|
|
94
|
+
name: string;
|
|
95
|
+
description?: string;
|
|
96
|
+
subdomain?: string;
|
|
97
|
+
logoId?: string;
|
|
98
|
+
}
|
|
79
99
|
) {
|
|
80
100
|
const school = await createSchoolWithAdmin({
|
|
81
101
|
userId,
|
|
@@ -84,9 +104,33 @@ export async function createSchoolRecord(
|
|
|
84
104
|
logoId: input.logoId,
|
|
85
105
|
});
|
|
86
106
|
|
|
107
|
+
if (input.description) {
|
|
108
|
+
await updateSchoolRecord(school.id, { description: input.description });
|
|
109
|
+
}
|
|
110
|
+
|
|
87
111
|
return { school };
|
|
88
112
|
}
|
|
89
113
|
|
|
114
|
+
export async function updateSchoolDetails(
|
|
115
|
+
userId: string,
|
|
116
|
+
input: {
|
|
117
|
+
schoolId: string;
|
|
118
|
+
name?: string;
|
|
119
|
+
description?: string | null;
|
|
120
|
+
subdomain?: string | null;
|
|
121
|
+
logoId?: string | null;
|
|
122
|
+
}
|
|
123
|
+
) {
|
|
124
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
125
|
+
const updated = await updateSchoolRecord(input.schoolId, {
|
|
126
|
+
name: input.name,
|
|
127
|
+
description: input.description,
|
|
128
|
+
subdomain: input.subdomain,
|
|
129
|
+
logoId: input.logoId,
|
|
130
|
+
});
|
|
131
|
+
return { school: updated };
|
|
132
|
+
}
|
|
133
|
+
|
|
90
134
|
export async function getAdminOverview(userId: string, schoolId: string) {
|
|
91
135
|
await requireSchoolAdmin(schoolId, userId);
|
|
92
136
|
const school = await getSchoolOverview(schoolId);
|
|
@@ -132,11 +176,26 @@ export async function createSchoolClass(
|
|
|
132
176
|
section: string;
|
|
133
177
|
subject: string;
|
|
134
178
|
color?: string;
|
|
179
|
+
departmentId?: string;
|
|
135
180
|
teachers?: string[];
|
|
136
181
|
students?: string[];
|
|
137
182
|
}
|
|
138
183
|
) {
|
|
139
184
|
await requireSchoolAdmin(input.schoolId, userId);
|
|
185
|
+
|
|
186
|
+
if (input.departmentId) {
|
|
187
|
+
const dept = await prisma.department.findUnique({
|
|
188
|
+
where: { id: input.departmentId },
|
|
189
|
+
select: { schoolId: true },
|
|
190
|
+
});
|
|
191
|
+
if (!dept || dept.schoolId !== input.schoolId) {
|
|
192
|
+
throw new TRPCError({
|
|
193
|
+
code: "BAD_REQUEST",
|
|
194
|
+
message: "Department does not belong to this institution",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
140
199
|
const createdClass = await prisma.class.create({
|
|
141
200
|
data: {
|
|
142
201
|
name: input.name,
|
|
@@ -144,6 +203,7 @@ export async function createSchoolClass(
|
|
|
144
203
|
subject: input.subject,
|
|
145
204
|
color: input.color,
|
|
146
205
|
schoolId: input.schoolId,
|
|
206
|
+
departmentId: input.departmentId,
|
|
147
207
|
teachers: input.teachers?.length
|
|
148
208
|
? { connect: input.teachers.map((id) => ({ id })) }
|
|
149
209
|
: undefined,
|
|
@@ -232,63 +292,319 @@ export async function listInvites(userId: string, schoolId: string) {
|
|
|
232
292
|
return { invites: await listSchoolInvites(schoolId) };
|
|
233
293
|
}
|
|
234
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Issue a direct-upload URL for a school-level asset (e.g. logo). The file
|
|
297
|
+
* is created unattached and is owned by the requester until they associate
|
|
298
|
+
* it via school.create or school.update.
|
|
299
|
+
*/
|
|
300
|
+
export async function getSchoolAssetUploadUrl(
|
|
301
|
+
userId: string,
|
|
302
|
+
input: { fileName: string; fileType: string; fileSize: number }
|
|
303
|
+
) {
|
|
304
|
+
const { createDirectUploadFiles } = await import("../lib/fileUpload.js");
|
|
305
|
+
const [uploadFile] = await createDirectUploadFiles(
|
|
306
|
+
[
|
|
307
|
+
{
|
|
308
|
+
name: input.fileName,
|
|
309
|
+
type: input.fileType,
|
|
310
|
+
size: input.fileSize,
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
userId
|
|
314
|
+
);
|
|
315
|
+
return uploadFile;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function confirmSchoolAssetUpload(
|
|
319
|
+
userId: string,
|
|
320
|
+
fileId: string,
|
|
321
|
+
uploadSuccess: boolean
|
|
322
|
+
) {
|
|
323
|
+
const { confirmDirectUpload } = await import("../lib/fileUpload.js");
|
|
324
|
+
await confirmDirectUpload(fileId, uploadSuccess);
|
|
325
|
+
return { success: true };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Lightweight ID-only listing used to mask external user names in the UI.
|
|
330
|
+
* Any active institution member can fetch this.
|
|
331
|
+
*/
|
|
332
|
+
export async function listMemberIds(userId: string, schoolId: string) {
|
|
333
|
+
await requireSchoolMember(schoolId, userId);
|
|
334
|
+
const rows = await prisma.schoolMembership.findMany({
|
|
335
|
+
where: { schoolId, status: "ACTIVE" },
|
|
336
|
+
select: { userId: true },
|
|
337
|
+
});
|
|
338
|
+
return rows.map((r) => r.userId);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Search active institution members by username, displayName, or email. */
|
|
342
|
+
export async function searchMembers(
|
|
343
|
+
userId: string,
|
|
344
|
+
schoolId: string,
|
|
345
|
+
query: string,
|
|
346
|
+
limit = 10,
|
|
347
|
+
role?: "ADMIN" | "TEACHER" | "STUDENT"
|
|
348
|
+
) {
|
|
349
|
+
await requireSchoolMember(schoolId, userId);
|
|
350
|
+
const trimmed = query.trim();
|
|
351
|
+
if (!trimmed) return [];
|
|
352
|
+
return prisma.user.findMany({
|
|
353
|
+
where: {
|
|
354
|
+
id: { not: userId },
|
|
355
|
+
schoolMemberships: {
|
|
356
|
+
some: {
|
|
357
|
+
schoolId,
|
|
358
|
+
status: "ACTIVE",
|
|
359
|
+
...(role ? { role } : {}),
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
OR: [
|
|
363
|
+
{ username: { contains: trimmed, mode: "insensitive" } },
|
|
364
|
+
{ email: { contains: trimmed, mode: "insensitive" } },
|
|
365
|
+
{
|
|
366
|
+
profile: {
|
|
367
|
+
displayName: { contains: trimmed, mode: "insensitive" },
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
],
|
|
371
|
+
},
|
|
372
|
+
take: limit,
|
|
373
|
+
select: {
|
|
374
|
+
id: true,
|
|
375
|
+
username: true,
|
|
376
|
+
profile: {
|
|
377
|
+
select: {
|
|
378
|
+
displayName: true,
|
|
379
|
+
profilePicture: true,
|
|
380
|
+
profilePictureThumbnail: true,
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
schoolMemberships: {
|
|
384
|
+
where: { schoolId, status: "ACTIVE" },
|
|
385
|
+
select: { role: true },
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
orderBy: { username: "asc" },
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Combined student timeline: calendar events + assignments + attendance.
|
|
394
|
+
* Visible to school admins (any student) and to teachers who share at least
|
|
395
|
+
* one class with the student.
|
|
396
|
+
*/
|
|
397
|
+
export async function getStudentTimeline(
|
|
398
|
+
userId: string,
|
|
399
|
+
input: {
|
|
400
|
+
schoolId: string;
|
|
401
|
+
studentId: string;
|
|
402
|
+
from?: Date;
|
|
403
|
+
to?: Date;
|
|
404
|
+
}
|
|
405
|
+
) {
|
|
406
|
+
const isAdmin = await findActiveSchoolAdminMembership(
|
|
407
|
+
input.schoolId,
|
|
408
|
+
userId
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
// Permission: admin OR shares a class.
|
|
412
|
+
if (!isAdmin) {
|
|
413
|
+
const sharedClass = await prisma.class.findFirst({
|
|
414
|
+
where: {
|
|
415
|
+
schoolId: input.schoolId,
|
|
416
|
+
teachers: { some: { id: userId } },
|
|
417
|
+
students: { some: { id: input.studentId } },
|
|
418
|
+
},
|
|
419
|
+
select: { id: true },
|
|
420
|
+
});
|
|
421
|
+
if (!sharedClass) {
|
|
422
|
+
throw new TRPCError({
|
|
423
|
+
code: "FORBIDDEN",
|
|
424
|
+
message: "You don't share a class with this student",
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Make sure student is in the institution.
|
|
430
|
+
const studentMembership = await prisma.schoolMembership.findFirst({
|
|
431
|
+
where: { schoolId: input.schoolId, userId: input.studentId, status: "ACTIVE" },
|
|
432
|
+
select: { role: true, user: { select: { id: true, username: true, profile: { select: { displayName: true, profilePicture: true } } } } },
|
|
433
|
+
});
|
|
434
|
+
if (!studentMembership) {
|
|
435
|
+
throw new TRPCError({
|
|
436
|
+
code: "NOT_FOUND",
|
|
437
|
+
message: "Student is not in this institution",
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const range = {
|
|
442
|
+
...(input.from ? { gte: input.from } : {}),
|
|
443
|
+
...(input.to ? { lte: input.to } : {}),
|
|
444
|
+
};
|
|
445
|
+
const hasRange = Object.keys(range).length > 0;
|
|
446
|
+
|
|
447
|
+
const [events, assignments, attendance] = await Promise.all([
|
|
448
|
+
prisma.event.findMany({
|
|
449
|
+
where: {
|
|
450
|
+
userId: input.studentId,
|
|
451
|
+
...(hasRange ? { startTime: range } : {}),
|
|
452
|
+
},
|
|
453
|
+
select: {
|
|
454
|
+
id: true,
|
|
455
|
+
name: true,
|
|
456
|
+
startTime: true,
|
|
457
|
+
endTime: true,
|
|
458
|
+
location: true,
|
|
459
|
+
remarks: true,
|
|
460
|
+
color: true,
|
|
461
|
+
class: { select: { id: true, name: true, color: true } },
|
|
462
|
+
},
|
|
463
|
+
orderBy: { startTime: "asc" },
|
|
464
|
+
}),
|
|
465
|
+
prisma.assignment.findMany({
|
|
466
|
+
where: {
|
|
467
|
+
class: {
|
|
468
|
+
students: { some: { id: input.studentId } },
|
|
469
|
+
},
|
|
470
|
+
template: false,
|
|
471
|
+
...(hasRange ? { dueDate: range } : {}),
|
|
472
|
+
},
|
|
473
|
+
select: {
|
|
474
|
+
id: true,
|
|
475
|
+
title: true,
|
|
476
|
+
dueDate: true,
|
|
477
|
+
type: true,
|
|
478
|
+
graded: true,
|
|
479
|
+
maxGrade: true,
|
|
480
|
+
weight: true,
|
|
481
|
+
class: {
|
|
482
|
+
select: {
|
|
483
|
+
id: true,
|
|
484
|
+
name: true,
|
|
485
|
+
section: true,
|
|
486
|
+
subject: true,
|
|
487
|
+
color: true,
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
submissions: {
|
|
491
|
+
where: { studentId: input.studentId },
|
|
492
|
+
select: {
|
|
493
|
+
id: true,
|
|
494
|
+
submitted: true,
|
|
495
|
+
submittedAt: true,
|
|
496
|
+
returned: true,
|
|
497
|
+
gradeReceived: true,
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
orderBy: { dueDate: "asc" },
|
|
502
|
+
}),
|
|
503
|
+
prisma.attendance.findMany({
|
|
504
|
+
where: {
|
|
505
|
+
OR: [
|
|
506
|
+
{ present: { some: { id: input.studentId } } },
|
|
507
|
+
{ late: { some: { id: input.studentId } } },
|
|
508
|
+
{ absent: { some: { id: input.studentId } } },
|
|
509
|
+
],
|
|
510
|
+
...(hasRange ? { date: range } : {}),
|
|
511
|
+
},
|
|
512
|
+
select: {
|
|
513
|
+
id: true,
|
|
514
|
+
date: true,
|
|
515
|
+
class: { select: { id: true, name: true, color: true } },
|
|
516
|
+
present: { where: { id: input.studentId }, select: { id: true } },
|
|
517
|
+
late: { where: { id: input.studentId }, select: { id: true } },
|
|
518
|
+
absent: { where: { id: input.studentId }, select: { id: true } },
|
|
519
|
+
},
|
|
520
|
+
orderBy: { date: "asc" },
|
|
521
|
+
}),
|
|
522
|
+
]);
|
|
523
|
+
|
|
524
|
+
const attendanceShaped = attendance.map((record) => ({
|
|
525
|
+
id: record.id,
|
|
526
|
+
date: record.date,
|
|
527
|
+
class: record.class,
|
|
528
|
+
status: record.present.length
|
|
529
|
+
? ("PRESENT" as const)
|
|
530
|
+
: record.late.length
|
|
531
|
+
? ("LATE" as const)
|
|
532
|
+
: ("ABSENT" as const),
|
|
533
|
+
}));
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
student: studentMembership.user,
|
|
537
|
+
events,
|
|
538
|
+
assignments,
|
|
539
|
+
attendance: attendanceShaped,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
235
543
|
export async function createInvite(
|
|
236
544
|
userId: string,
|
|
237
545
|
input: {
|
|
238
546
|
schoolId: string;
|
|
239
547
|
email: string;
|
|
240
548
|
role: SchoolMembershipRole;
|
|
241
|
-
|
|
549
|
+
/** Optional display name set by the inviting admin. If omitted the user
|
|
550
|
+
* picks their own when they complete the password reset flow. */
|
|
242
551
|
displayName?: string;
|
|
552
|
+
/** Optional username; if omitted we generate a friendly random one. */
|
|
553
|
+
username?: string;
|
|
243
554
|
}
|
|
244
555
|
) {
|
|
245
556
|
await requireSchoolAdmin(input.schoolId, userId);
|
|
246
557
|
|
|
558
|
+
// The institution invite flow is "new account only": if the email is
|
|
559
|
+
// already in use we reject. This keeps the join experience simple — every
|
|
560
|
+
// new institution member always lands on a password reset link.
|
|
247
561
|
const existingUser = await findUserByEmail(input.email);
|
|
562
|
+
if (existingUser) {
|
|
563
|
+
throw new TRPCError({
|
|
564
|
+
code: "CONFLICT",
|
|
565
|
+
message:
|
|
566
|
+
"An account with this email already exists. Ask them to log in and you can add them via search.",
|
|
567
|
+
});
|
|
568
|
+
}
|
|
248
569
|
|
|
249
|
-
let
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (taken) {
|
|
257
|
-
throw new TRPCError({
|
|
258
|
-
code: "CONFLICT",
|
|
259
|
-
message: "Username already taken",
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
username = input.username;
|
|
263
|
-
} else {
|
|
264
|
-
username = await generateUniqueUsername(async (candidate) => {
|
|
265
|
-
const existing = await findUserByUsername(candidate);
|
|
266
|
-
return Boolean(existing);
|
|
570
|
+
let username: string;
|
|
571
|
+
if (input.username) {
|
|
572
|
+
const taken = await findUserByUsername(input.username);
|
|
573
|
+
if (taken) {
|
|
574
|
+
throw new TRPCError({
|
|
575
|
+
code: "CONFLICT",
|
|
576
|
+
message: "Username already taken",
|
|
267
577
|
});
|
|
268
578
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
password: placeholderPassword,
|
|
275
|
-
verified: true,
|
|
579
|
+
username = input.username;
|
|
580
|
+
} else {
|
|
581
|
+
username = await generateUniqueUsername(async (candidate) => {
|
|
582
|
+
const existing = await findUserByUsername(candidate);
|
|
583
|
+
return Boolean(existing);
|
|
276
584
|
});
|
|
277
|
-
|
|
585
|
+
}
|
|
278
586
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
587
|
+
const placeholderPassword = await hash(uuidv4(), 10);
|
|
588
|
+
const user = await createUser({
|
|
589
|
+
username,
|
|
590
|
+
email: input.email,
|
|
591
|
+
password: placeholderPassword,
|
|
592
|
+
verified: true,
|
|
593
|
+
});
|
|
594
|
+
const createdUser = { id: user.id, username: user.username };
|
|
282
595
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
type: SessionType.PASSWORD_RESET,
|
|
286
|
-
userId: user.id,
|
|
287
|
-
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 14),
|
|
288
|
-
});
|
|
289
|
-
passwordResetToken = resetSession.id;
|
|
596
|
+
if (input.displayName) {
|
|
597
|
+
await upsertUserProfile(user.id, { displayName: input.displayName });
|
|
290
598
|
}
|
|
291
599
|
|
|
600
|
+
const resetSession = await createSession({
|
|
601
|
+
id: uuidv4(),
|
|
602
|
+
type: SessionType.PASSWORD_RESET,
|
|
603
|
+
userId: user.id,
|
|
604
|
+
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 14),
|
|
605
|
+
});
|
|
606
|
+
const passwordResetToken = resetSession.id;
|
|
607
|
+
|
|
292
608
|
const token = uuidv4();
|
|
293
609
|
const invite = await createSchoolInvite({
|
|
294
610
|
schoolId: input.schoolId,
|
|
@@ -299,27 +615,19 @@ export async function createInvite(
|
|
|
299
615
|
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 14),
|
|
300
616
|
});
|
|
301
617
|
|
|
302
|
-
|
|
303
|
-
await acceptSchoolInvite(invite.id, createdUser.id);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
const inviteUrl = `${env.NEXT_PUBLIC_APP_URL}/school/invite/${token}`;
|
|
307
|
-
const passwordResetUrl = passwordResetToken
|
|
308
|
-
? `${env.NEXT_PUBLIC_APP_URL}/reset-password/${passwordResetToken}`
|
|
309
|
-
: null;
|
|
618
|
+
await acceptSchoolInvite(invite.id, createdUser.id);
|
|
310
619
|
|
|
620
|
+
const passwordResetUrl = `${env.NEXT_PUBLIC_APP_URL}/reset-password/${passwordResetToken}`;
|
|
311
621
|
const emailLines = [
|
|
312
|
-
`You've been
|
|
622
|
+
`You've been added to ${invite.school.name} on Studious.`,
|
|
313
623
|
"",
|
|
624
|
+
`We created an account for you with the username "${createdUser.username}".`,
|
|
625
|
+
input.displayName
|
|
626
|
+
? `Your name is set to "${input.displayName}" — you can change it after signing in.`
|
|
627
|
+
: `You can set your name once you sign in for the first time.`,
|
|
628
|
+
"",
|
|
629
|
+
`Set your password to get started: ${passwordResetUrl}`,
|
|
314
630
|
];
|
|
315
|
-
if (createdUser && passwordResetUrl) {
|
|
316
|
-
emailLines.push(
|
|
317
|
-
`We created an account for you with the username "${createdUser.username}" and added you to ${invite.school.name}.`,
|
|
318
|
-
`Set your password to get started: ${passwordResetUrl}`
|
|
319
|
-
);
|
|
320
|
-
} else {
|
|
321
|
-
emailLines.push(`Click the link to confirm: ${inviteUrl}`);
|
|
322
|
-
}
|
|
323
631
|
|
|
324
632
|
try {
|
|
325
633
|
await sendMail({
|
|
@@ -336,7 +644,11 @@ export async function createInvite(
|
|
|
336
644
|
});
|
|
337
645
|
}
|
|
338
646
|
|
|
339
|
-
return {
|
|
647
|
+
return {
|
|
648
|
+
invite,
|
|
649
|
+
createdUser,
|
|
650
|
+
passwordResetUrl,
|
|
651
|
+
};
|
|
340
652
|
}
|
|
341
653
|
|
|
342
654
|
export async function revokeInvite(
|