@studious-lms/server 1.16.1 → 1.17.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.
@@ -540,6 +540,237 @@ export async function getStudentTimeline(
540
540
  };
541
541
  }
542
542
 
543
+ /**
544
+ * Role-aware overview used by `/view/[userId]`.
545
+ *
546
+ * - For STUDENT members it returns the same timeline as `getStudentTimeline`
547
+ * (events, assignments, attendance) so teachers/admins can audit progress.
548
+ * - For TEACHER/ADMIN members it returns their teaching agenda: every event in
549
+ * a class they teach plus their personal events, alongside the classes they
550
+ * run with enrolment counts.
551
+ *
552
+ * Permissions:
553
+ * - School admins can view anyone in the institution.
554
+ * - A teacher can view a STUDENT they share a class with.
555
+ * - A teacher/admin can always view their own overview.
556
+ */
557
+ export async function getMemberTimeline(
558
+ userId: string,
559
+ input: {
560
+ schoolId: string;
561
+ memberId: string;
562
+ from?: Date;
563
+ to?: Date;
564
+ }
565
+ ) {
566
+ const callerIsAdmin = Boolean(
567
+ await findActiveSchoolAdminMembership(input.schoolId, userId)
568
+ );
569
+
570
+ const membership = await prisma.schoolMembership.findFirst({
571
+ where: {
572
+ schoolId: input.schoolId,
573
+ userId: input.memberId,
574
+ status: "ACTIVE",
575
+ },
576
+ select: {
577
+ role: true,
578
+ user: {
579
+ select: {
580
+ id: true,
581
+ username: true,
582
+ profile: { select: { displayName: true, profilePicture: true } },
583
+ },
584
+ },
585
+ },
586
+ });
587
+ if (!membership) {
588
+ throw new TRPCError({
589
+ code: "NOT_FOUND",
590
+ message: "This person is not in this institution",
591
+ });
592
+ }
593
+
594
+ const range = {
595
+ ...(input.from ? { gte: input.from } : {}),
596
+ ...(input.to ? { lte: input.to } : {}),
597
+ };
598
+ const hasRange = Object.keys(range).length > 0;
599
+
600
+ if (membership.role === "STUDENT") {
601
+ if (!callerIsAdmin) {
602
+ const sharedClass = await prisma.class.findFirst({
603
+ where: {
604
+ schoolId: input.schoolId,
605
+ teachers: { some: { id: userId } },
606
+ students: { some: { id: input.memberId } },
607
+ },
608
+ select: { id: true },
609
+ });
610
+ if (!sharedClass) {
611
+ throw new TRPCError({
612
+ code: "FORBIDDEN",
613
+ message: "You don't share a class with this student",
614
+ });
615
+ }
616
+ }
617
+
618
+ const [events, assignments, attendance] = await Promise.all([
619
+ prisma.event.findMany({
620
+ where: {
621
+ userId: input.memberId,
622
+ ...(hasRange ? { startTime: range } : {}),
623
+ },
624
+ select: {
625
+ id: true,
626
+ name: true,
627
+ startTime: true,
628
+ endTime: true,
629
+ location: true,
630
+ remarks: true,
631
+ color: true,
632
+ class: { select: { id: true, name: true, color: true } },
633
+ },
634
+ orderBy: { startTime: "asc" },
635
+ }),
636
+ prisma.assignment.findMany({
637
+ where: {
638
+ class: { students: { some: { id: input.memberId } } },
639
+ template: false,
640
+ ...(hasRange ? { dueDate: range } : {}),
641
+ },
642
+ select: {
643
+ id: true,
644
+ title: true,
645
+ dueDate: true,
646
+ type: true,
647
+ graded: true,
648
+ maxGrade: true,
649
+ weight: true,
650
+ class: {
651
+ select: {
652
+ id: true,
653
+ name: true,
654
+ section: true,
655
+ subject: true,
656
+ color: true,
657
+ },
658
+ },
659
+ submissions: {
660
+ where: { studentId: input.memberId },
661
+ select: {
662
+ id: true,
663
+ submitted: true,
664
+ submittedAt: true,
665
+ returned: true,
666
+ gradeReceived: true,
667
+ },
668
+ },
669
+ },
670
+ orderBy: { dueDate: "asc" },
671
+ }),
672
+ prisma.attendance.findMany({
673
+ where: {
674
+ OR: [
675
+ { present: { some: { id: input.memberId } } },
676
+ { late: { some: { id: input.memberId } } },
677
+ { absent: { some: { id: input.memberId } } },
678
+ ],
679
+ ...(hasRange ? { date: range } : {}),
680
+ },
681
+ select: {
682
+ id: true,
683
+ date: true,
684
+ class: { select: { id: true, name: true, color: true } },
685
+ present: { where: { id: input.memberId }, select: { id: true } },
686
+ late: { where: { id: input.memberId }, select: { id: true } },
687
+ absent: { where: { id: input.memberId }, select: { id: true } },
688
+ },
689
+ orderBy: { date: "asc" },
690
+ }),
691
+ ]);
692
+
693
+ const attendanceShaped = attendance.map((record) => ({
694
+ id: record.id,
695
+ date: record.date,
696
+ class: record.class,
697
+ status: record.present.length
698
+ ? ("PRESENT" as const)
699
+ : record.late.length
700
+ ? ("LATE" as const)
701
+ : ("ABSENT" as const),
702
+ }));
703
+
704
+ return {
705
+ role: "STUDENT" as const,
706
+ member: membership.user,
707
+ events,
708
+ assignments,
709
+ attendance: attendanceShaped,
710
+ };
711
+ }
712
+
713
+ // TEACHER or ADMIN: only admins (or the member themselves) may view.
714
+ if (!callerIsAdmin && userId !== input.memberId) {
715
+ throw new TRPCError({
716
+ code: "FORBIDDEN",
717
+ message: "Only school admins can view this member",
718
+ });
719
+ }
720
+
721
+ const [events, classes] = await Promise.all([
722
+ prisma.event.findMany({
723
+ where: {
724
+ OR: [
725
+ { userId: input.memberId },
726
+ { class: { teachers: { some: { id: input.memberId } } } },
727
+ ],
728
+ ...(hasRange ? { startTime: range } : {}),
729
+ },
730
+ select: {
731
+ id: true,
732
+ name: true,
733
+ startTime: true,
734
+ endTime: true,
735
+ location: true,
736
+ remarks: true,
737
+ color: true,
738
+ class: { select: { id: true, name: true, color: true } },
739
+ },
740
+ orderBy: { startTime: "asc" },
741
+ }),
742
+ prisma.class.findMany({
743
+ where: {
744
+ schoolId: input.schoolId,
745
+ teachers: { some: { id: input.memberId } },
746
+ },
747
+ select: {
748
+ id: true,
749
+ name: true,
750
+ section: true,
751
+ subject: true,
752
+ color: true,
753
+ _count: { select: { students: true } },
754
+ },
755
+ orderBy: { name: "asc" },
756
+ }),
757
+ ]);
758
+
759
+ return {
760
+ role: membership.role as "TEACHER" | "ADMIN",
761
+ member: membership.user,
762
+ events,
763
+ classes: classes.map((c) => ({
764
+ id: c.id,
765
+ name: c.name,
766
+ section: c.section,
767
+ subject: c.subject,
768
+ color: c.color,
769
+ studentCount: c._count.students,
770
+ })),
771
+ };
772
+ }
773
+
543
774
  export async function createInvite(
544
775
  userId: string,
545
776
  input: {