@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.
Files changed (196) hide show
  1. package/dist/models/access.d.ts +21 -66
  2. package/dist/models/access.d.ts.map +1 -1
  3. package/dist/models/access.js +10 -26
  4. package/dist/models/access.js.map +1 -1
  5. package/dist/models/agenda.d.ts +2 -0
  6. package/dist/models/agenda.d.ts.map +1 -1
  7. package/dist/models/announcement.d.ts +2 -1
  8. package/dist/models/announcement.d.ts.map +1 -1
  9. package/dist/models/announcement.js +2 -3
  10. package/dist/models/announcement.js.map +1 -1
  11. package/dist/models/assignment.d.ts +7 -0
  12. package/dist/models/assignment.d.ts.map +1 -1
  13. package/dist/models/auth.d.ts +2 -4
  14. package/dist/models/auth.d.ts.map +1 -1
  15. package/dist/models/auth.js +2 -3
  16. package/dist/models/auth.js.map +1 -1
  17. package/dist/models/class.d.ts +32 -4
  18. package/dist/models/class.d.ts.map +1 -1
  19. package/dist/models/class.js +15 -2
  20. package/dist/models/class.js.map +1 -1
  21. package/dist/models/cloudStorage.d.ts +140 -0
  22. package/dist/models/cloudStorage.d.ts.map +1 -0
  23. package/dist/models/cloudStorage.js +175 -0
  24. package/dist/models/cloudStorage.js.map +1 -0
  25. package/dist/models/comment.d.ts +0 -24
  26. package/dist/models/comment.d.ts.map +1 -1
  27. package/dist/models/comment.js +2 -6
  28. package/dist/models/comment.js.map +1 -1
  29. package/dist/models/conversation.d.ts +22 -22
  30. package/dist/models/department.d.ts +154 -0
  31. package/dist/models/department.d.ts.map +1 -0
  32. package/dist/models/department.js +135 -0
  33. package/dist/models/department.js.map +1 -0
  34. package/dist/models/event.d.ts +7 -1
  35. package/dist/models/event.d.ts.map +1 -1
  36. package/dist/models/event.js +2 -5
  37. package/dist/models/event.js.map +1 -1
  38. package/dist/models/file.d.ts +25 -10
  39. package/dist/models/file.d.ts.map +1 -1
  40. package/dist/models/file.js +2 -5
  41. package/dist/models/file.js.map +1 -1
  42. package/dist/models/folder.d.ts +47 -0
  43. package/dist/models/folder.d.ts.map +1 -1
  44. package/dist/models/labChat.d.ts +5 -3
  45. package/dist/models/labChat.d.ts.map +1 -1
  46. package/dist/models/message.d.ts +46 -12
  47. package/dist/models/message.d.ts.map +1 -1
  48. package/dist/models/message.js +19 -10
  49. package/dist/models/message.js.map +1 -1
  50. package/dist/models/newtonChat.d.ts +2 -1
  51. package/dist/models/newtonChat.d.ts.map +1 -1
  52. package/dist/models/school.d.ts +45 -12
  53. package/dist/models/school.d.ts.map +1 -1
  54. package/dist/models/school.js +19 -2
  55. package/dist/models/school.js.map +1 -1
  56. package/dist/models/section.d.ts +1 -0
  57. package/dist/models/section.d.ts.map +1 -1
  58. package/dist/models/tag.d.ts +117 -0
  59. package/dist/models/tag.d.ts.map +1 -0
  60. package/dist/models/tag.js +113 -0
  61. package/dist/models/tag.js.map +1 -0
  62. package/dist/models/worksheet.d.ts +3 -0
  63. package/dist/models/worksheet.d.ts.map +1 -1
  64. package/dist/pipelines/aiLabChatContract.d.ts +8 -8
  65. package/dist/routers/_app.d.ts +3200 -1972
  66. package/dist/routers/_app.d.ts.map +1 -1
  67. package/dist/routers/_app.js +6 -2
  68. package/dist/routers/_app.js.map +1 -1
  69. package/dist/routers/agenda.d.ts +2 -0
  70. package/dist/routers/agenda.d.ts.map +1 -1
  71. package/dist/routers/announcement.d.ts.map +1 -1
  72. package/dist/routers/announcement.js +3 -5
  73. package/dist/routers/announcement.js.map +1 -1
  74. package/dist/routers/assignment.d.ts +18 -9
  75. package/dist/routers/assignment.d.ts.map +1 -1
  76. package/dist/routers/assignment.js +2 -3
  77. package/dist/routers/assignment.js.map +1 -1
  78. package/dist/routers/class.d.ts +87 -14
  79. package/dist/routers/class.d.ts.map +1 -1
  80. package/dist/routers/class.js +5 -2
  81. package/dist/routers/class.js.map +1 -1
  82. package/dist/routers/conversation.d.ts +5 -5
  83. package/dist/routers/department.d.ts +293 -0
  84. package/dist/routers/department.d.ts.map +1 -0
  85. package/dist/routers/department.js +93 -0
  86. package/dist/routers/department.js.map +1 -0
  87. package/dist/routers/event.d.ts +2 -1
  88. package/dist/routers/event.d.ts.map +1 -1
  89. package/dist/routers/file.d.ts +8 -6
  90. package/dist/routers/file.d.ts.map +1 -1
  91. package/dist/routers/file.js +26 -17
  92. package/dist/routers/file.js.map +1 -1
  93. package/dist/routers/folder.d.ts +139 -186
  94. package/dist/routers/folder.d.ts.map +1 -1
  95. package/dist/routers/folder.js +82 -90
  96. package/dist/routers/folder.js.map +1 -1
  97. package/dist/routers/labChat.d.ts +11 -2
  98. package/dist/routers/labChat.d.ts.map +1 -1
  99. package/dist/routers/message.d.ts +20 -2
  100. package/dist/routers/message.d.ts.map +1 -1
  101. package/dist/routers/newtonChat.d.ts +9 -0
  102. package/dist/routers/newtonChat.d.ts.map +1 -1
  103. package/dist/routers/school.d.ts +160 -17
  104. package/dist/routers/school.d.ts.map +1 -1
  105. package/dist/routers/school.js +46 -3
  106. package/dist/routers/school.js.map +1 -1
  107. package/dist/routers/tag.d.ts +119 -0
  108. package/dist/routers/tag.d.ts.map +1 -0
  109. package/dist/routers/tag.js +52 -0
  110. package/dist/routers/tag.js.map +1 -0
  111. package/dist/routers/worksheet.d.ts +1 -0
  112. package/dist/routers/worksheet.d.ts.map +1 -1
  113. package/dist/seedDatabase.d.ts +0 -1
  114. package/dist/seedDatabase.d.ts.map +1 -1
  115. package/dist/services/agenda.d.ts +2 -0
  116. package/dist/services/agenda.d.ts.map +1 -1
  117. package/dist/services/assignment.d.ts +18 -9
  118. package/dist/services/assignment.d.ts.map +1 -1
  119. package/dist/services/class.d.ts +87 -14
  120. package/dist/services/class.d.ts.map +1 -1
  121. package/dist/services/class.js +91 -4
  122. package/dist/services/class.js.map +1 -1
  123. package/dist/services/conversation.d.ts +5 -5
  124. package/dist/services/department.d.ts +200 -0
  125. package/dist/services/department.d.ts.map +1 -0
  126. package/dist/services/department.js +368 -0
  127. package/dist/services/department.js.map +1 -0
  128. package/dist/services/event.d.ts +2 -1
  129. package/dist/services/event.d.ts.map +1 -1
  130. package/dist/services/file.d.ts +85 -0
  131. package/dist/services/file.d.ts.map +1 -1
  132. package/dist/services/file.js +92 -2
  133. package/dist/services/file.js.map +1 -1
  134. package/dist/services/folder.d.ts +125 -149
  135. package/dist/services/folder.d.ts.map +1 -1
  136. package/dist/services/folder.js +258 -199
  137. package/dist/services/folder.js.map +1 -1
  138. package/dist/services/labChat.d.ts +11 -2
  139. package/dist/services/labChat.d.ts.map +1 -1
  140. package/dist/services/labChat.js +4 -3
  141. package/dist/services/labChat.js.map +1 -1
  142. package/dist/services/message.d.ts +20 -2
  143. package/dist/services/message.d.ts.map +1 -1
  144. package/dist/services/message.js +7 -23
  145. package/dist/services/message.js.map +1 -1
  146. package/dist/services/newtonChat.d.ts +9 -0
  147. package/dist/services/newtonChat.d.ts.map +1 -1
  148. package/dist/services/newtonChat.js +4 -3
  149. package/dist/services/newtonChat.js.map +1 -1
  150. package/dist/services/school.d.ts +142 -15
  151. package/dist/services/school.d.ts.map +1 -1
  152. package/dist/services/school.js +308 -54
  153. package/dist/services/school.js.map +1 -1
  154. package/dist/services/tag.d.ts +71 -0
  155. package/dist/services/tag.d.ts.map +1 -0
  156. package/dist/services/tag.js +178 -0
  157. package/dist/services/tag.js.map +1 -0
  158. package/dist/services/worksheet.d.ts +1 -0
  159. package/dist/services/worksheet.d.ts.map +1 -1
  160. package/dist/utils/aiUser.d.ts.map +1 -1
  161. package/dist/utils/aiUser.js +2 -3
  162. package/dist/utils/aiUser.js.map +1 -1
  163. package/package.json +1 -1
  164. package/prisma/migrations/20260509220000_institution_overhaul/migration.sql +184 -0
  165. package/prisma/schema.prisma +119 -35
  166. package/src/models/access.ts +8 -24
  167. package/src/models/announcement.ts +0 -1
  168. package/src/models/auth.ts +0 -1
  169. package/src/models/class.ts +13 -0
  170. package/src/models/cloudStorage.ts +194 -0
  171. package/src/models/comment.ts +0 -4
  172. package/src/models/department.ts +159 -0
  173. package/src/models/event.ts +0 -3
  174. package/src/models/file.ts +0 -3
  175. package/src/models/message.ts +19 -5
  176. package/src/models/school.ts +26 -0
  177. package/src/models/tag.ts +140 -0
  178. package/src/routers/_app.ts +4 -0
  179. package/src/routers/announcement.ts +1 -3
  180. package/src/routers/assignment.ts +0 -1
  181. package/src/routers/class.ts +3 -0
  182. package/src/routers/department.ts +174 -0
  183. package/src/routers/file.ts +29 -26
  184. package/src/routers/folder.ts +100 -121
  185. package/src/routers/school.ts +83 -0
  186. package/src/routers/tag.ts +78 -0
  187. package/src/services/class.ts +108 -3
  188. package/src/services/department.ts +478 -0
  189. package/src/services/file.ts +100 -0
  190. package/src/services/folder.ts +313 -240
  191. package/src/services/labChat.ts +2 -1
  192. package/src/services/message.ts +5 -21
  193. package/src/services/newtonChat.ts +2 -1
  194. package/src/services/school.ts +369 -57
  195. package/src/services/tag.ts +229 -0
  196. package/src/utils/aiUser.ts +0 -1
@@ -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.map((a) => ({
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: { id: true, name: true, type: true },
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: { id: true, name: true, type: true },
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: { id: true, name: true, type: true } },
609
+ attachments: { select: messageFileSelect },
626
610
  mentions: { select: { userId: true } },
627
611
  },
628
612
  });
@@ -12,6 +12,7 @@ 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,
@@ -212,7 +213,7 @@ export async function postToNewtonChat(
212
213
  },
213
214
  },
214
215
  attachments: {
215
- select: { id: true, name: true, type: true },
216
+ select: messageFileSelect,
216
217
  },
217
218
  },
218
219
  });
@@ -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: activeMembership?.school ?? null,
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: { name: string; subdomain?: string; logoId?: string }
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
- username?: string;
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 createdUser: { id: string; username: string } | null = null;
250
- let passwordResetToken: string | null = null;
251
-
252
- if (!existingUser) {
253
- let username: string;
254
- if (input.username) {
255
- const taken = await findUserByUsername(input.username);
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
- const placeholderPassword = await hash(uuidv4(), 10);
271
- const user = await createUser({
272
- username,
273
- email: input.email,
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
- createdUser = { id: user.id, username: user.username };
585
+ }
278
586
 
279
- if (input.displayName) {
280
- await upsertUserProfile(user.id, { displayName: input.displayName });
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
- const resetSession = await createSession({
284
- id: uuidv4(),
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
- if (createdUser) {
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 invited to ${invite.school.name} on Studious.`,
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 { invite, createdUser };
647
+ return {
648
+ invite,
649
+ createdUser,
650
+ passwordResetUrl,
651
+ };
340
652
  }
341
653
 
342
654
  export async function revokeInvite(