@studious-lms/server 1.11.0 → 1.12.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/.env.example +3 -0
- package/dist/index.js +40 -4
- package/dist/index.js.map +1 -1
- package/dist/lib/config/cors.d.ts.map +1 -1
- package/dist/lib/config/cors.js +7 -2
- package/dist/lib/config/cors.js.map +1 -1
- package/dist/lib/config/env.d.ts +8 -0
- package/dist/lib/config/env.d.ts.map +1 -1
- package/dist/lib/config/env.js +5 -2
- package/dist/lib/config/env.js.map +1 -1
- package/dist/lib/pusher.d.ts +2 -1
- package/dist/lib/pusher.d.ts.map +1 -1
- package/dist/lib/pusher.js +4 -3
- package/dist/lib/pusher.js.map +1 -1
- package/dist/models/agenda.d.ts +6 -0
- package/dist/models/agenda.d.ts.map +1 -1
- package/dist/models/announcement.d.ts +3 -0
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/assignment.d.ts +61 -0
- package/dist/models/assignment.d.ts.map +1 -1
- package/dist/models/auth.d.ts +6 -0
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/class.d.ts +18 -0
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +5 -2
- package/dist/models/class.js.map +1 -1
- package/dist/models/comment.d.ts +6 -0
- package/dist/models/comment.d.ts.map +1 -1
- package/dist/models/event.d.ts +12 -0
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/file.d.ts +38 -0
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/folder.d.ts +6 -0
- package/dist/models/folder.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +6 -0
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/newtonChat.d.ts +3 -0
- package/dist/models/newtonChat.d.ts.map +1 -1
- package/dist/models/school.d.ts +3 -0
- package/dist/models/school.d.ts.map +1 -1
- package/dist/models/section.d.ts +3 -0
- package/dist/models/section.d.ts.map +1 -1
- package/dist/models/worksheet.d.ts +58 -0
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +26 -2
- package/dist/models/worksheet.js.map +1 -1
- package/dist/routers/_app.d.ts +4055 -1823
- 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/admin.d.ts +190 -0
- package/dist/routers/admin.d.ts.map +1 -0
- package/dist/routers/admin.js +50 -0
- package/dist/routers/admin.js.map +1 -0
- package/dist/routers/agenda.d.ts +6 -0
- package/dist/routers/agenda.d.ts.map +1 -1
- package/dist/routers/announcement.d.ts +2 -0
- package/dist/routers/announcement.d.ts.map +1 -1
- package/dist/routers/assignment.d.ts +50 -0
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/class.d.ts +31 -0
- 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/comment.d.ts +2 -0
- package/dist/routers/comment.d.ts.map +1 -1
- package/dist/routers/discussion.d.ts +823 -0
- package/dist/routers/discussion.d.ts.map +1 -0
- package/dist/routers/discussion.js +151 -0
- package/dist/routers/discussion.js.map +1 -0
- package/dist/routers/event.d.ts +3 -0
- package/dist/routers/event.d.ts.map +1 -1
- package/dist/routers/file.d.ts +4 -0
- package/dist/routers/file.d.ts.map +1 -1
- package/dist/routers/school.d.ts +6 -0
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/worksheet.d.ts +3 -0
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/seedDatabase.d.ts.map +1 -1
- package/dist/seedDatabase.js +1490 -1307
- package/dist/seedDatabase.js.map +1 -1
- package/dist/services/adminInstitution.d.ts +102 -0
- package/dist/services/adminInstitution.d.ts.map +1 -0
- package/dist/services/adminInstitution.js +117 -0
- package/dist/services/adminInstitution.js.map +1 -0
- package/dist/services/adminRunJob.d.ts +45 -0
- package/dist/services/adminRunJob.d.ts.map +1 -0
- package/dist/services/adminRunJob.js +41 -0
- package/dist/services/adminRunJob.js.map +1 -0
- package/dist/services/agenda.d.ts +6 -0
- package/dist/services/agenda.d.ts.map +1 -1
- package/dist/services/assignment.d.ts +50 -0
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +8 -3
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/class.d.ts +31 -0
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +14 -2
- package/dist/services/class.js.map +1 -1
- package/dist/services/comment.d.ts +2 -0
- package/dist/services/comment.d.ts.map +1 -1
- package/dist/services/discussion.d.ts +762 -0
- package/dist/services/discussion.d.ts.map +1 -0
- package/dist/services/discussion.js +625 -0
- package/dist/services/discussion.js.map +1 -0
- package/dist/services/event.d.ts +3 -0
- package/dist/services/event.d.ts.map +1 -1
- package/dist/services/file.d.ts +8 -0
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/school.d.ts +6 -0
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +27 -5
- package/dist/services/studentProgress.js.map +1 -1
- package/dist/services/studentProgressInferenceCache.d.ts +16 -0
- package/dist/services/studentProgressInferenceCache.d.ts.map +1 -0
- package/dist/services/studentProgressInferenceCache.js +43 -0
- package/dist/services/studentProgressInferenceCache.js.map +1 -0
- package/dist/services/worksheet.d.ts +3 -0
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +5 -5
- package/dist/services/worksheet.js.map +1 -1
- package/dist/trpc.d.ts +12 -0
- package/dist/trpc.d.ts.map +1 -1
- package/dist/trpc.js +16 -3
- package/dist/trpc.js.map +1 -1
- package/dist/utils/adminPanelAccess.d.ts +8 -0
- package/dist/utils/adminPanelAccess.d.ts.map +1 -0
- package/dist/utils/adminPanelAccess.js +44 -0
- package/dist/utils/adminPanelAccess.js.map +1 -0
- package/package.json +1 -1
- package/prisma/migrations/20260510183000_add_discussions/migration.sql +151 -0
- package/prisma/schema.prisma +97 -0
- package/src/index.ts +39 -2
- package/src/lib/config/cors.ts +5 -0
- package/src/lib/config/env.ts +3 -0
- package/src/lib/pusher.ts +2 -1
- package/src/models/class.ts +3 -0
- package/src/models/worksheet.ts +28 -0
- package/src/routers/_app.ts +4 -0
- package/src/routers/admin.ts +70 -0
- package/src/routers/class.ts +3 -0
- package/src/routers/discussion.ts +205 -0
- package/src/seedDatabase.ts +1713 -1361
- package/src/services/adminInstitution.ts +140 -0
- package/src/services/adminRunJob.ts +46 -0
- package/src/services/assignment.ts +11 -1
- package/src/services/class.ts +18 -0
- package/src/services/discussion.ts +701 -0
- package/src/services/studentProgress.ts +80 -3
- package/src/services/studentProgressInferenceCache.ts +64 -0
- package/src/services/worksheet.ts +4 -2
- package/src/trpc.ts +15 -0
- package/src/utils/adminPanelAccess.ts +46 -0
- package/tests/integration/routers/discussion.test.ts +253 -0
- package/tests/unit/services/discussion.test.ts +356 -0
|
@@ -8,6 +8,10 @@ import { prisma } from "../lib/prisma.js";
|
|
|
8
8
|
import { isTeacherInClass } from "../models/class.js";
|
|
9
9
|
import { inference } from "../utils/inference.js";
|
|
10
10
|
import { logger } from "../utils/logger.js";
|
|
11
|
+
import {
|
|
12
|
+
getStudentProgressInferenceCacheHit,
|
|
13
|
+
setStudentProgressInferenceCache,
|
|
14
|
+
} from "./studentProgressInferenceCache.js";
|
|
11
15
|
|
|
12
16
|
type ProgressChatMessage = {
|
|
13
17
|
role: "user" | "assistant";
|
|
@@ -681,23 +685,53 @@ function buildStructuredInferenceMessages(
|
|
|
681
685
|
];
|
|
682
686
|
}
|
|
683
687
|
|
|
688
|
+
type ProgressInferenceCacheRow<T> = {
|
|
689
|
+
epoch: number;
|
|
690
|
+
cachedAt: number;
|
|
691
|
+
value: T;
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
const studentProgressInsightsCache = new Map<
|
|
695
|
+
string,
|
|
696
|
+
ProgressInferenceCacheRow<StudentProgressInsights>
|
|
697
|
+
>();
|
|
698
|
+
const studentProgressForecastCache = new Map<
|
|
699
|
+
string,
|
|
700
|
+
ProgressInferenceCacheRow<StudentProgressForecast>
|
|
701
|
+
>();
|
|
702
|
+
const studentProgressFocusCache = new Map<
|
|
703
|
+
string,
|
|
704
|
+
ProgressInferenceCacheRow<StudentProgressFocus>
|
|
705
|
+
>();
|
|
706
|
+
|
|
684
707
|
export async function getStudentProgressInsights(
|
|
685
708
|
viewerId: string,
|
|
686
709
|
classId: string,
|
|
687
710
|
studentId: string,
|
|
688
711
|
) {
|
|
712
|
+
const cached = getStudentProgressInferenceCacheHit(
|
|
713
|
+
studentProgressInsightsCache,
|
|
714
|
+
classId,
|
|
715
|
+
studentId,
|
|
716
|
+
);
|
|
717
|
+
|
|
689
718
|
const { classData, student, submissions } = await loadStudentProgressContext(
|
|
690
719
|
viewerId,
|
|
691
720
|
classId,
|
|
692
721
|
studentId,
|
|
693
722
|
);
|
|
723
|
+
|
|
724
|
+
if (cached !== undefined) {
|
|
725
|
+
return cached;
|
|
726
|
+
}
|
|
727
|
+
|
|
694
728
|
const context = buildStructuredProgressContext(
|
|
695
729
|
classData,
|
|
696
730
|
student,
|
|
697
731
|
submissions,
|
|
698
732
|
);
|
|
699
733
|
|
|
700
|
-
|
|
734
|
+
const value = await inference<StudentProgressInsights>(
|
|
701
735
|
buildStructuredInferenceMessages(
|
|
702
736
|
[
|
|
703
737
|
"Generate an AI analytics summary for this one student's dashboard.",
|
|
@@ -713,6 +747,13 @@ export async function getStudentProgressInsights(
|
|
|
713
747
|
metadata: { viewerId, classId, studentId },
|
|
714
748
|
},
|
|
715
749
|
);
|
|
750
|
+
setStudentProgressInferenceCache(
|
|
751
|
+
studentProgressInsightsCache,
|
|
752
|
+
classId,
|
|
753
|
+
studentId,
|
|
754
|
+
value,
|
|
755
|
+
);
|
|
756
|
+
return value;
|
|
716
757
|
}
|
|
717
758
|
|
|
718
759
|
export async function getStudentProgressForecast(
|
|
@@ -720,18 +761,29 @@ export async function getStudentProgressForecast(
|
|
|
720
761
|
classId: string,
|
|
721
762
|
studentId: string,
|
|
722
763
|
) {
|
|
764
|
+
const cached = getStudentProgressInferenceCacheHit(
|
|
765
|
+
studentProgressForecastCache,
|
|
766
|
+
classId,
|
|
767
|
+
studentId,
|
|
768
|
+
);
|
|
769
|
+
|
|
723
770
|
const { classData, student, submissions } = await loadStudentProgressContext(
|
|
724
771
|
viewerId,
|
|
725
772
|
classId,
|
|
726
773
|
studentId,
|
|
727
774
|
);
|
|
775
|
+
|
|
776
|
+
if (cached !== undefined) {
|
|
777
|
+
return cached;
|
|
778
|
+
}
|
|
779
|
+
|
|
728
780
|
const context = buildStructuredProgressContext(
|
|
729
781
|
classData,
|
|
730
782
|
student,
|
|
731
783
|
submissions,
|
|
732
784
|
);
|
|
733
785
|
|
|
734
|
-
|
|
786
|
+
const value = await inference<StudentProgressForecast>(
|
|
735
787
|
buildStructuredInferenceMessages(
|
|
736
788
|
"Predict the student's next graded assignment percentage band and risk status. Base the forecast on trajectory, missing work, low scores, and assignment recency. Keep the reason to one sentence.",
|
|
737
789
|
context,
|
|
@@ -742,6 +794,13 @@ export async function getStudentProgressForecast(
|
|
|
742
794
|
metadata: { viewerId, classId, studentId },
|
|
743
795
|
},
|
|
744
796
|
);
|
|
797
|
+
setStudentProgressInferenceCache(
|
|
798
|
+
studentProgressForecastCache,
|
|
799
|
+
classId,
|
|
800
|
+
studentId,
|
|
801
|
+
value,
|
|
802
|
+
);
|
|
803
|
+
return value;
|
|
745
804
|
}
|
|
746
805
|
|
|
747
806
|
export async function getStudentProgressFocus(
|
|
@@ -749,18 +808,29 @@ export async function getStudentProgressFocus(
|
|
|
749
808
|
classId: string,
|
|
750
809
|
studentId: string,
|
|
751
810
|
) {
|
|
811
|
+
const cached = getStudentProgressInferenceCacheHit(
|
|
812
|
+
studentProgressFocusCache,
|
|
813
|
+
classId,
|
|
814
|
+
studentId,
|
|
815
|
+
);
|
|
816
|
+
|
|
752
817
|
const { classData, student, submissions } = await loadStudentProgressContext(
|
|
753
818
|
viewerId,
|
|
754
819
|
classId,
|
|
755
820
|
studentId,
|
|
756
821
|
);
|
|
822
|
+
|
|
823
|
+
if (cached !== undefined) {
|
|
824
|
+
return cached;
|
|
825
|
+
}
|
|
826
|
+
|
|
757
827
|
const context = buildStructuredProgressContext(
|
|
758
828
|
classData,
|
|
759
829
|
student,
|
|
760
830
|
submissions,
|
|
761
831
|
);
|
|
762
832
|
|
|
763
|
-
|
|
833
|
+
const value = await inference<StudentProgressFocus>(
|
|
764
834
|
buildStructuredInferenceMessages(
|
|
765
835
|
"Generate 3-5 student-facing weekly focus items. Each item should be specific, actionable, and grounded in the student's grade pattern or assignment titles. No numbering in the strings.",
|
|
766
836
|
context,
|
|
@@ -771,6 +841,13 @@ export async function getStudentProgressFocus(
|
|
|
771
841
|
metadata: { viewerId, classId, studentId },
|
|
772
842
|
},
|
|
773
843
|
);
|
|
844
|
+
setStudentProgressInferenceCache(
|
|
845
|
+
studentProgressFocusCache,
|
|
846
|
+
classId,
|
|
847
|
+
studentId,
|
|
848
|
+
value,
|
|
849
|
+
);
|
|
850
|
+
return value;
|
|
774
851
|
}
|
|
775
852
|
|
|
776
853
|
export async function chatAboutStudentProgress(
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process cache for student progress AI panels (insights / forecast / focus).
|
|
3
|
+
* Epoch bumps invalidate all three for a given class + student (e.g. after a grade update).
|
|
4
|
+
* Entries also expire after one week so stale AI summaries eventually refresh.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
const epochByScope = new Map<string, number>();
|
|
10
|
+
|
|
11
|
+
function scopeKey(classId: string, studentId: string) {
|
|
12
|
+
return `${classId}:${studentId}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function currentStudentProgressInferenceEpoch(
|
|
16
|
+
classId: string,
|
|
17
|
+
studentId: string,
|
|
18
|
+
) {
|
|
19
|
+
return epochByScope.get(scopeKey(classId, studentId)) ?? 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Call when a teacher updates `gradeReceived` for that student's work in this class. */
|
|
23
|
+
export function invalidateStudentProgressInferenceCache(
|
|
24
|
+
classId: string,
|
|
25
|
+
studentId: string,
|
|
26
|
+
) {
|
|
27
|
+
const key = scopeKey(classId, studentId);
|
|
28
|
+
epochByScope.set(key, (epochByScope.get(key) ?? 0) + 1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type CacheRow<T> = { epoch: number; cachedAt: number; value: T };
|
|
32
|
+
|
|
33
|
+
export function getStudentProgressInferenceCacheHit<T>(
|
|
34
|
+
store: Map<string, CacheRow<T>>,
|
|
35
|
+
classId: string,
|
|
36
|
+
studentId: string,
|
|
37
|
+
): T | undefined {
|
|
38
|
+
const key = scopeKey(classId, studentId);
|
|
39
|
+
const epoch = currentStudentProgressInferenceEpoch(classId, studentId);
|
|
40
|
+
const hit = store.get(key);
|
|
41
|
+
if (!hit) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
const fresh =
|
|
45
|
+
hit.epoch === epoch && Date.now() - hit.cachedAt < WEEK_MS;
|
|
46
|
+
if (!fresh) {
|
|
47
|
+
store.delete(key);
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
return hit.value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function setStudentProgressInferenceCache<T>(
|
|
54
|
+
store: Map<string, CacheRow<T>>,
|
|
55
|
+
classId: string,
|
|
56
|
+
studentId: string,
|
|
57
|
+
value: T,
|
|
58
|
+
) {
|
|
59
|
+
store.set(scopeKey(classId, studentId), {
|
|
60
|
+
epoch: currentStudentProgressInferenceEpoch(classId, studentId),
|
|
61
|
+
cachedAt: Date.now(),
|
|
62
|
+
value,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
createStudentQuestionProgressForGrading,
|
|
36
36
|
createComment,
|
|
37
37
|
findStudentQuestionProgressByIdForTeacher,
|
|
38
|
+
findStudentQuestionProgressByIdForStudentOrTeacher,
|
|
38
39
|
findStudentQuestionProgressByIdForTeacherAndResponse,
|
|
39
40
|
} from "../models/worksheet.js";
|
|
40
41
|
|
|
@@ -645,14 +646,15 @@ export async function addCommentToResponse(
|
|
|
645
646
|
comment: string,
|
|
646
647
|
authorId: string
|
|
647
648
|
) {
|
|
648
|
-
const progress = await
|
|
649
|
+
const progress = await findStudentQuestionProgressByIdForStudentOrTeacher(
|
|
649
650
|
responseId,
|
|
650
651
|
authorId
|
|
651
652
|
);
|
|
652
653
|
if (!progress) {
|
|
653
654
|
throw new TRPCError({
|
|
654
655
|
code: "FORBIDDEN",
|
|
655
|
-
message:
|
|
656
|
+
message:
|
|
657
|
+
"You can only comment on your own worksheet answers or on answers for classes you teach",
|
|
656
658
|
});
|
|
657
659
|
}
|
|
658
660
|
|
package/src/trpc.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { Request, Response } from 'express';
|
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { handlePrismaError, PrismaErrorInfo } from './utils/prismaErrorHandler.js';
|
|
9
9
|
import { findUserBySessionToken } from './models/auth.js';
|
|
10
|
+
import { assertAdminPanelUser } from './utils/adminPanelAccess.js';
|
|
10
11
|
|
|
11
12
|
interface CreateContextOptions {
|
|
12
13
|
req: Request;
|
|
@@ -88,6 +89,20 @@ export const publicProcedure = t.procedure.use(loggingMiddleware);
|
|
|
88
89
|
// Protected procedures
|
|
89
90
|
export const protectedProcedure = publicProcedure.use(isAuthed);
|
|
90
91
|
|
|
92
|
+
const adminPanelMiddleware = t.middleware(({ ctx, next }) => {
|
|
93
|
+
if (!ctx.user) {
|
|
94
|
+
throw new TRPCError({
|
|
95
|
+
code: 'UNAUTHORIZED',
|
|
96
|
+
message: 'Not authenticated',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
assertAdminPanelUser(ctx.user.id);
|
|
100
|
+
return next({ ctx });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/** Authenticated + env allowlist (see `assertAdminPanelUser`). */
|
|
104
|
+
export const adminProcedure = protectedProcedure.use(adminPanelMiddleware);
|
|
105
|
+
|
|
91
106
|
export const protectedClassMemberProcedure = protectedProcedure
|
|
92
107
|
.input(z.object({ classId: z.string() }).passthrough())
|
|
93
108
|
.use(isMemberInClass);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
|
2
|
+
import { env } from '../lib/config/env.js';
|
|
3
|
+
|
|
4
|
+
function parseAdminUserIds(raw: string | undefined): Set<string> {
|
|
5
|
+
if (!raw?.trim()) return new Set();
|
|
6
|
+
return new Set(
|
|
7
|
+
raw
|
|
8
|
+
.split(/[\s,]+/)
|
|
9
|
+
.map((s) => s.trim())
|
|
10
|
+
.filter(Boolean),
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Admin tRPC routes: in production `ADMIN_PANEL_USER_IDS` must be a non-empty
|
|
16
|
+
* comma/whitespace-separated list of user UUIDs. In non-production, an empty
|
|
17
|
+
* unset value allows any authenticated user (local ops); a non-empty list is
|
|
18
|
+
* still enforced.
|
|
19
|
+
*/
|
|
20
|
+
export function assertAdminPanelUser(userId: string): void {
|
|
21
|
+
const allowed = parseAdminUserIds(env.ADMIN_PANEL_USER_IDS);
|
|
22
|
+
|
|
23
|
+
if (env.NODE_ENV === 'production') {
|
|
24
|
+
if (allowed.size === 0) {
|
|
25
|
+
throw new TRPCError({
|
|
26
|
+
code: 'FORBIDDEN',
|
|
27
|
+
message:
|
|
28
|
+
'Admin API disabled: set ADMIN_PANEL_USER_IDS to a non-empty list of user ids.',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (!allowed.has(userId)) {
|
|
32
|
+
throw new TRPCError({
|
|
33
|
+
code: 'FORBIDDEN',
|
|
34
|
+
message: 'You are not allowed to use the admin API.',
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (allowed.size > 0 && !allowed.has(userId)) {
|
|
41
|
+
throw new TRPCError({
|
|
42
|
+
code: 'FORBIDDEN',
|
|
43
|
+
message: 'You are not allowed to use the admin API.',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { expectForbidden, expectUnauth } from '../helpers';
|
|
3
|
+
import { user1Caller, user2Caller, user3Caller } from '../setup';
|
|
4
|
+
|
|
5
|
+
vi.mock('../../../src/lib/pusher.js', async () => ({
|
|
6
|
+
pusher: { trigger: vi.fn().mockResolvedValue(undefined) },
|
|
7
|
+
chatChannel: (conversationId: string) => `private-conversation-${conversationId}`,
|
|
8
|
+
worksheetChannel: (worksheetResponseId: string) => `private-worksheet-${worksheetResponseId}`,
|
|
9
|
+
teacherChannel: (classId: string) => `private-teacher-${classId}`,
|
|
10
|
+
discussionChannel: (classId: string) => `private-class-${classId}-discussions`,
|
|
11
|
+
worksheetJobChannel: (worksheetId: string) => `private-worksheet-job-${worksheetId}`,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
describe('Discussion Router', () => {
|
|
15
|
+
let testClass: any;
|
|
16
|
+
let studentId: string;
|
|
17
|
+
let secondStudentId: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
testClass = await user1Caller.class.create({
|
|
21
|
+
name: `Discussion Test Class ${Date.now()}`,
|
|
22
|
+
subject: 'History',
|
|
23
|
+
section: '12th Grade',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const studentProfile = await user2Caller.user.getProfile();
|
|
27
|
+
const secondStudentProfile = await user3Caller.user.getProfile();
|
|
28
|
+
studentId = studentProfile.id;
|
|
29
|
+
secondStudentId = secondStudentProfile.id;
|
|
30
|
+
|
|
31
|
+
await user1Caller.class.addStudent({
|
|
32
|
+
classId: testClass.id,
|
|
33
|
+
studentId,
|
|
34
|
+
});
|
|
35
|
+
await user1Caller.class.addStudent({
|
|
36
|
+
classId: testClass.id,
|
|
37
|
+
studentId: secondStudentId,
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('creates, lists, searches, paginates, tags, and reacts to discussions', async () => {
|
|
42
|
+
const tag = await user1Caller.discussion.tags.create({
|
|
43
|
+
classId: testClass.id,
|
|
44
|
+
name: `Review ${Date.now()}`,
|
|
45
|
+
color: '#3B82F6',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const first = await user1Caller.discussion.create({
|
|
49
|
+
classId: testClass.id,
|
|
50
|
+
title: 'Primary source analysis',
|
|
51
|
+
body: 'Discuss the archive packet.',
|
|
52
|
+
type: 'DISCUSSION',
|
|
53
|
+
tagIds: [tag.id],
|
|
54
|
+
});
|
|
55
|
+
const question = await user1Caller.discussion.create({
|
|
56
|
+
classId: testClass.id,
|
|
57
|
+
title: 'Exam review question',
|
|
58
|
+
body: 'Where should we focus?',
|
|
59
|
+
type: 'QUESTION',
|
|
60
|
+
});
|
|
61
|
+
await user1Caller.discussion.create({
|
|
62
|
+
classId: testClass.id,
|
|
63
|
+
title: 'Reading logistics',
|
|
64
|
+
body: 'Bring the printed handout.',
|
|
65
|
+
type: 'ANNOUNCEMENT',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await user1Caller.discussion.pin({
|
|
69
|
+
discussionId: first.discussion.id,
|
|
70
|
+
isPinned: true,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await user2Caller.discussion.reply({
|
|
74
|
+
discussionId: first.discussion.id,
|
|
75
|
+
body: 'I found one strong quote.',
|
|
76
|
+
});
|
|
77
|
+
await user2Caller.discussion.reply({
|
|
78
|
+
discussionId: first.discussion.id,
|
|
79
|
+
body: 'A second point for ranking.',
|
|
80
|
+
});
|
|
81
|
+
await user2Caller.discussion.reply({
|
|
82
|
+
discussionId: question.discussion.id,
|
|
83
|
+
body: 'Focus on the essay question.',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const list = await user2Caller.discussion.list({
|
|
87
|
+
classId: testClass.id,
|
|
88
|
+
sort: 'top',
|
|
89
|
+
limit: 2,
|
|
90
|
+
});
|
|
91
|
+
expect(list.items).toHaveLength(2);
|
|
92
|
+
expect(list.nextCursor).toBeDefined();
|
|
93
|
+
expect(list.items[0].id).toBe(first.discussion.id);
|
|
94
|
+
expect(list.items[0].isPinned).toBe(true);
|
|
95
|
+
expect(list.items[0].replyCount).toBe(2);
|
|
96
|
+
|
|
97
|
+
const search = await user2Caller.discussion.list({
|
|
98
|
+
classId: testClass.id,
|
|
99
|
+
search: 'exam review',
|
|
100
|
+
sort: 'activity',
|
|
101
|
+
limit: 10,
|
|
102
|
+
});
|
|
103
|
+
expect(search.items.map((item: any) => item.id)).toContain(question.discussion.id);
|
|
104
|
+
|
|
105
|
+
const byTag = await user2Caller.discussion.list({
|
|
106
|
+
classId: testClass.id,
|
|
107
|
+
tagId: tag.id,
|
|
108
|
+
sort: 'activity',
|
|
109
|
+
limit: 10,
|
|
110
|
+
});
|
|
111
|
+
expect(byTag.items.map((item: any) => item.id)).toEqual([first.discussion.id]);
|
|
112
|
+
|
|
113
|
+
const reaction = await user2Caller.discussion.reactions.toggle({
|
|
114
|
+
discussionId: first.discussion.id,
|
|
115
|
+
type: 'HEART',
|
|
116
|
+
});
|
|
117
|
+
expect(reaction.reaction?.type).toBe('HEART');
|
|
118
|
+
|
|
119
|
+
const removedReaction = await user2Caller.discussion.reactions.toggle({
|
|
120
|
+
discussionId: first.discussion.id,
|
|
121
|
+
type: 'HEART',
|
|
122
|
+
});
|
|
123
|
+
expect(removedReaction.reaction).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('returns nested replies, preserves children after soft delete, and toggles accepted answers', async () => {
|
|
127
|
+
const created = await user1Caller.discussion.create({
|
|
128
|
+
classId: testClass.id,
|
|
129
|
+
title: 'Question thread',
|
|
130
|
+
body: 'Which answer is best?',
|
|
131
|
+
type: 'QUESTION',
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const parent = await user2Caller.discussion.reply({
|
|
135
|
+
discussionId: created.discussion.id,
|
|
136
|
+
body: 'This is my answer.',
|
|
137
|
+
});
|
|
138
|
+
const child = await user3Caller.discussion.reply({
|
|
139
|
+
discussionId: created.discussion.id,
|
|
140
|
+
parentReplyId: parent.reply.id,
|
|
141
|
+
body: 'Adding evidence under that answer.',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const accepted = await user1Caller.discussion.markAcceptedAnswer({
|
|
145
|
+
replyId: parent.reply.id,
|
|
146
|
+
accepted: true,
|
|
147
|
+
});
|
|
148
|
+
expect(accepted.reply.isAcceptedAnswer).toBe(true);
|
|
149
|
+
|
|
150
|
+
const deleted = await user2Caller.discussion.deleteReply({
|
|
151
|
+
replyId: parent.reply.id,
|
|
152
|
+
});
|
|
153
|
+
expect(deleted.reply.body).toBe('[deleted]');
|
|
154
|
+
expect(deleted.reply.deletedAt).toBeTruthy();
|
|
155
|
+
|
|
156
|
+
const thread = await user1Caller.discussion.get({
|
|
157
|
+
discussionId: created.discussion.id,
|
|
158
|
+
});
|
|
159
|
+
expect(thread.replies).toHaveLength(1);
|
|
160
|
+
expect(thread.replies[0].id).toBe(parent.reply.id);
|
|
161
|
+
expect(thread.replies[0].body).toBe('[deleted]');
|
|
162
|
+
expect(thread.replies[0].children[0].id).toBe(child.reply.id);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('computes unread counts after a reader marks a discussion read', async () => {
|
|
166
|
+
const created = await user1Caller.discussion.create({
|
|
167
|
+
classId: testClass.id,
|
|
168
|
+
title: 'Unread count thread',
|
|
169
|
+
body: 'Watch unread counts here.',
|
|
170
|
+
type: 'DISCUSSION',
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await user2Caller.discussion.get({ discussionId: created.discussion.id });
|
|
174
|
+
await user3Caller.discussion.reply({
|
|
175
|
+
discussionId: created.discussion.id,
|
|
176
|
+
body: 'This should be unread for the first student.',
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const counts = await user2Caller.discussion.getUnreadCounts({
|
|
180
|
+
classIds: [testClass.id],
|
|
181
|
+
});
|
|
182
|
+
expect(counts[testClass.id].total).toBe(1);
|
|
183
|
+
expect(counts[testClass.id].perDiscussion).toEqual([
|
|
184
|
+
{ discussionId: created.discussion.id, count: 1 },
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
await user2Caller.discussion.markRead({ discussionId: created.discussion.id });
|
|
188
|
+
const afterRead = await user2Caller.discussion.getUnreadCounts({
|
|
189
|
+
classIds: [testClass.id],
|
|
190
|
+
});
|
|
191
|
+
expect(afterRead[testClass.id].total).toBe(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('enforces discussion authorization branches', async () => {
|
|
195
|
+
await expectUnauth((caller) => caller.discussion.list({
|
|
196
|
+
classId: testClass.id,
|
|
197
|
+
sort: 'activity',
|
|
198
|
+
limit: 10,
|
|
199
|
+
}));
|
|
200
|
+
|
|
201
|
+
await expectForbidden(() => user2Caller.discussion.create({
|
|
202
|
+
classId: testClass.id,
|
|
203
|
+
title: 'Student announcement',
|
|
204
|
+
body: 'Students cannot make this type.',
|
|
205
|
+
type: 'ANNOUNCEMENT',
|
|
206
|
+
}));
|
|
207
|
+
|
|
208
|
+
await user1Caller.class.update({
|
|
209
|
+
classId: testClass.id,
|
|
210
|
+
studentsCanCreateDiscussions: false,
|
|
211
|
+
});
|
|
212
|
+
await expectForbidden(() => user2Caller.discussion.create({
|
|
213
|
+
classId: testClass.id,
|
|
214
|
+
title: 'Blocked student discussion',
|
|
215
|
+
body: 'This should not create.',
|
|
216
|
+
type: 'DISCUSSION',
|
|
217
|
+
}));
|
|
218
|
+
|
|
219
|
+
const created = await user1Caller.discussion.create({
|
|
220
|
+
classId: testClass.id,
|
|
221
|
+
title: 'Teacher managed thread',
|
|
222
|
+
body: 'Teacher owns this.',
|
|
223
|
+
type: 'DISCUSSION',
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await expectForbidden(() => user2Caller.discussion.pin({
|
|
227
|
+
discussionId: created.discussion.id,
|
|
228
|
+
isPinned: true,
|
|
229
|
+
}));
|
|
230
|
+
|
|
231
|
+
await user1Caller.discussion.lock({
|
|
232
|
+
discussionId: created.discussion.id,
|
|
233
|
+
isLocked: true,
|
|
234
|
+
});
|
|
235
|
+
await expectForbidden(() => user2Caller.discussion.reply({
|
|
236
|
+
discussionId: created.discussion.id,
|
|
237
|
+
body: 'Student cannot reply to locked thread.',
|
|
238
|
+
}));
|
|
239
|
+
|
|
240
|
+
await user1Caller.class.update({
|
|
241
|
+
classId: testClass.id,
|
|
242
|
+
discussionsEnabled: false,
|
|
243
|
+
});
|
|
244
|
+
await expectForbidden(() => user1Caller.discussion.list({
|
|
245
|
+
classId: testClass.id,
|
|
246
|
+
sort: 'activity',
|
|
247
|
+
limit: 10,
|
|
248
|
+
}));
|
|
249
|
+
await expectForbidden(() => user1Caller.discussion.get({
|
|
250
|
+
discussionId: created.discussion.id,
|
|
251
|
+
}));
|
|
252
|
+
});
|
|
253
|
+
});
|