@studious-lms/server 1.6.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 +38 -22
- package/dist/models/conversation.d.ts.map +1 -1
- package/dist/models/conversation.js +50 -2
- package/dist/models/conversation.js.map +1 -1
- 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 +3556 -2298
- 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 +20 -5
- package/dist/routers/conversation.d.ts.map +1 -1
- package/dist/routers/conversation.js +6 -3
- package/dist/routers/conversation.js.map +1 -1
- 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/assignment.js +4 -4
- package/dist/services/assignment.js.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 +21 -5
- package/dist/services/conversation.d.ts.map +1 -1
- package/dist/services/conversation.js +22 -3
- package/dist/services/conversation.js.map +1 -1
- 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 +8 -7
- 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 +7 -6
- 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/conversation.ts +54 -0
- 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/conversation.ts +7 -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/assignment.ts +2 -3
- package/src/services/class.ts +108 -3
- package/src/services/conversation.ts +30 -0
- 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 +7 -6
- package/src/services/message.ts +5 -21
- package/src/services/newtonChat.ts +5 -5
- package/src/services/school.ts +369 -57
- package/src/services/tag.ts +229 -0
- package/src/utils/aiUser.ts +0 -1
- package/tests/integration/routers/conversation.test.ts +121 -2
package/src/services/message.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
findMessageByIdMinimal,
|
|
20
20
|
countUnreadMessages,
|
|
21
21
|
countUnreadMentions,
|
|
22
|
+
messageFileSelect,
|
|
22
23
|
} from "../models/message.js";
|
|
23
24
|
|
|
24
25
|
export function ensureMessageHasContentOrAttachments(
|
|
@@ -110,19 +111,6 @@ export async function confirmMessageUpload(
|
|
|
110
111
|
});
|
|
111
112
|
}
|
|
112
113
|
|
|
113
|
-
if (input.uploadSuccess) {
|
|
114
|
-
await prisma.$transaction(async (tx) => {
|
|
115
|
-
await tx.file.update({
|
|
116
|
-
where: { id: input.fileId },
|
|
117
|
-
data: { uploadStatus: "FAILED", uploadError: input.errorMessage },
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
throw new TRPCError({
|
|
121
|
-
code: "INTERNAL_SERVER_ERROR",
|
|
122
|
-
message: "Failed to confirm upload",
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
|
|
126
114
|
const file = await prisma.file.findFirst({
|
|
127
115
|
where: {
|
|
128
116
|
id: input.fileId,
|
|
@@ -193,11 +181,7 @@ export async function listMessages(
|
|
|
193
181
|
createdAt: message.createdAt,
|
|
194
182
|
sender: message.sender,
|
|
195
183
|
status: message.status,
|
|
196
|
-
attachments: message.attachments
|
|
197
|
-
id: a.id,
|
|
198
|
-
name: a.name,
|
|
199
|
-
type: a.type,
|
|
200
|
-
})),
|
|
184
|
+
attachments: message.attachments,
|
|
201
185
|
meta: message.meta as Record<string, unknown>,
|
|
202
186
|
mentions: message.mentions.map((m) => ({ user: m.user })),
|
|
203
187
|
mentionsMe: message.mentions.some((m) => m.userId === userId),
|
|
@@ -302,7 +286,7 @@ export async function sendMessage(
|
|
|
302
286
|
},
|
|
303
287
|
},
|
|
304
288
|
attachments: {
|
|
305
|
-
select:
|
|
289
|
+
select: messageFileSelect,
|
|
306
290
|
},
|
|
307
291
|
},
|
|
308
292
|
});
|
|
@@ -413,7 +397,7 @@ export async function updateMessage(
|
|
|
413
397
|
},
|
|
414
398
|
},
|
|
415
399
|
attachments: {
|
|
416
|
-
select:
|
|
400
|
+
select: messageFileSelect,
|
|
417
401
|
},
|
|
418
402
|
},
|
|
419
403
|
});
|
|
@@ -622,7 +606,7 @@ export async function markSuggestionCreated(
|
|
|
622
606
|
},
|
|
623
607
|
},
|
|
624
608
|
},
|
|
625
|
-
attachments: { select:
|
|
609
|
+
attachments: { select: messageFileSelect },
|
|
626
610
|
mentions: { select: { userId: true } },
|
|
627
611
|
},
|
|
628
612
|
});
|
|
@@ -7,11 +7,12 @@ import { logger } from "../utils/logger.js";
|
|
|
7
7
|
import { chatChannel, pusher } from "../lib/pusher.js";
|
|
8
8
|
import { isAIUser } from "../utils/aiUser.js";
|
|
9
9
|
import { createPendingAIMessage } from "../utils/inference.js";
|
|
10
|
-
import { enqueueRunJob
|
|
10
|
+
import { enqueueRunJob } from "../workers/queues.js";
|
|
11
11
|
import {
|
|
12
12
|
claimMessageAttachments,
|
|
13
13
|
ensureMessageHasContentOrAttachments,
|
|
14
14
|
} from "./message.js";
|
|
15
|
+
import { messageFileSelect } from "../models/message.js";
|
|
15
16
|
import {
|
|
16
17
|
findClassWithStudent,
|
|
17
18
|
findSubmissionByAssignmentAndStudent,
|
|
@@ -19,7 +20,6 @@ import {
|
|
|
19
20
|
findConversationMembersByUserIds,
|
|
20
21
|
} from "../models/newtonChat.js";
|
|
21
22
|
import { prisma } from "../lib/prisma.js";
|
|
22
|
-
import { RunJobType } from "@prisma/client";
|
|
23
23
|
|
|
24
24
|
/** Get or create Newton tutor conversation for a submission. Student-only. */
|
|
25
25
|
export async function getTutorConversation(
|
|
@@ -110,7 +110,7 @@ export async function getTutorConversation(
|
|
|
110
110
|
try {
|
|
111
111
|
const aiPlaceholder = await createPendingAIMessage(chat.conversationId);
|
|
112
112
|
await enqueueRunJob(
|
|
113
|
-
|
|
113
|
+
"AI_NEWTON_CHAT",
|
|
114
114
|
{ conversationId: chat.conversationId, aiMessageId: aiPlaceholder.id },
|
|
115
115
|
{ userId },
|
|
116
116
|
);
|
|
@@ -213,7 +213,7 @@ export async function postToNewtonChat(
|
|
|
213
213
|
},
|
|
214
214
|
},
|
|
215
215
|
attachments: {
|
|
216
|
-
select:
|
|
216
|
+
select: messageFileSelect,
|
|
217
217
|
},
|
|
218
218
|
},
|
|
219
219
|
});
|
|
@@ -246,7 +246,7 @@ export async function postToNewtonChat(
|
|
|
246
246
|
try {
|
|
247
247
|
const aiPlaceholder = await createPendingAIMessage(newtonChat.conversationId);
|
|
248
248
|
await enqueueRunJob(
|
|
249
|
-
|
|
249
|
+
"AI_NEWTON_CHAT",
|
|
250
250
|
{ conversationId: newtonChat.conversationId, aiMessageId: aiPlaceholder.id },
|
|
251
251
|
{ userId },
|
|
252
252
|
);
|
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(
|