@studious-lms/server 1.26.0 → 1.28.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 -1
- package/.github/workflows/npm-publish.yml +1 -0
- package/AGENTS.md +84 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -4
- package/dist/index.js.map +1 -1
- package/dist/instrument.js +9 -4
- package/dist/instrument.js.map +1 -1
- package/dist/lib/fileUpload.d.ts +15 -0
- package/dist/lib/fileUpload.d.ts.map +1 -1
- package/dist/lib/fileUpload.js +43 -2
- package/dist/lib/fileUpload.js.map +1 -1
- package/dist/models/cloudStorage.d.ts +11 -0
- package/dist/models/cloudStorage.d.ts.map +1 -1
- package/dist/models/cloudStorage.js +18 -2
- package/dist/models/cloudStorage.js.map +1 -1
- package/dist/models/conversation.d.ts +1 -0
- package/dist/models/conversation.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +40 -0
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/labChat.js +32 -2
- package/dist/models/labChat.js.map +1 -1
- package/dist/models/message.d.ts +3 -0
- package/dist/models/message.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.d.ts +2 -0
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +60 -16
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +170 -2
- package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
- package/dist/pipelines/aiLabChatContract.js +30 -2
- package/dist/pipelines/aiLabChatContract.js.map +1 -1
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +30 -5
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- package/dist/pipelines/generateVisualization.d.ts +33 -0
- package/dist/pipelines/generateVisualization.d.ts.map +1 -0
- package/dist/pipelines/generateVisualization.js +79 -0
- package/dist/pipelines/generateVisualization.js.map +1 -0
- package/dist/pipelines/labChatPrompt.d.ts +1 -1
- package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
- package/dist/pipelines/labChatPrompt.js +13 -14
- package/dist/pipelines/labChatPrompt.js.map +1 -1
- package/dist/routers/_app.d.ts +1118 -70
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +4 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/admin.d.ts +297 -1
- package/dist/routers/admin.d.ts.map +1 -1
- package/dist/routers/admin.js +43 -2
- package/dist/routers/admin.js.map +1 -1
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +16 -3
- package/dist/routers/class.js.map +1 -1
- package/dist/routers/conversation.d.ts +1 -0
- package/dist/routers/conversation.d.ts.map +1 -1
- package/dist/routers/labChat.d.ts +10 -0
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/labChat.js +6 -3
- package/dist/routers/labChat.js.map +1 -1
- package/dist/routers/message.d.ts +37 -4
- package/dist/routers/message.d.ts.map +1 -1
- package/dist/routers/message.js +24 -7
- package/dist/routers/message.js.map +1 -1
- package/dist/routers/module.d.ts +1 -1
- package/dist/routers/parent.d.ts +18 -0
- package/dist/routers/parent.d.ts.map +1 -1
- package/dist/routers/parent.js +10 -3
- package/dist/routers/parent.js.map +1 -1
- package/dist/routers/studentProgress.d.ts +44 -0
- package/dist/routers/studentProgress.d.ts.map +1 -1
- package/dist/routers/studentProgress.js +12 -3
- package/dist/routers/studentProgress.js.map +1 -1
- package/dist/routers/visualization.d.ts +124 -0
- package/dist/routers/visualization.d.ts.map +1 -0
- package/dist/routers/visualization.js +41 -0
- package/dist/routers/visualization.js.map +1 -0
- package/dist/routes/aiStream.js +32 -2
- package/dist/routes/aiStream.js.map +1 -1
- package/dist/seedDatabase.d.ts.map +1 -1
- package/dist/seedDatabase.js +107 -3
- package/dist/seedDatabase.js.map +1 -1
- package/dist/services/adminClass.d.ts +153 -0
- package/dist/services/adminClass.d.ts.map +1 -0
- package/dist/services/adminClass.js +196 -0
- package/dist/services/adminClass.js.map +1 -0
- package/dist/services/adminInstitution.d.ts +2 -0
- package/dist/services/adminInstitution.d.ts.map +1 -1
- package/dist/services/adminInstitution.js +11 -4
- package/dist/services/adminInstitution.js.map +1 -1
- package/dist/services/adminMetrics.d.ts +8 -0
- package/dist/services/adminMetrics.d.ts.map +1 -1
- package/dist/services/adminMetrics.js +30 -3
- package/dist/services/adminMetrics.js.map +1 -1
- package/dist/services/adminSpam.d.ts +76 -0
- package/dist/services/adminSpam.d.ts.map +1 -0
- package/dist/services/adminSpam.js +163 -0
- package/dist/services/adminSpam.js.map +1 -0
- package/dist/services/adminSql.d.ts +36 -0
- package/dist/services/adminSql.d.ts.map +1 -0
- package/dist/services/adminSql.js +172 -0
- package/dist/services/adminSql.js.map +1 -0
- package/dist/services/adminTelemetry.d.ts +56 -0
- package/dist/services/adminTelemetry.d.ts.map +1 -0
- package/dist/services/adminTelemetry.js +147 -0
- package/dist/services/adminTelemetry.js.map +1 -0
- package/dist/services/adminUser.d.ts +45 -1
- package/dist/services/adminUser.d.ts.map +1 -1
- package/dist/services/adminUser.js +53 -13
- package/dist/services/adminUser.js.map +1 -1
- package/dist/services/aiChatStream.d.ts +15 -0
- package/dist/services/aiChatStream.d.ts.map +1 -1
- package/dist/services/aiChatStream.js +144 -74
- package/dist/services/aiChatStream.js.map +1 -1
- package/dist/services/aiGeneration.d.ts +4 -0
- package/dist/services/aiGeneration.d.ts.map +1 -0
- package/dist/services/aiGeneration.js +88 -0
- package/dist/services/aiGeneration.js.map +1 -0
- package/dist/services/aiProposalTools.d.ts +4 -1
- package/dist/services/aiProposalTools.d.ts.map +1 -1
- package/dist/services/aiProposalTools.js +128 -3
- package/dist/services/aiProposalTools.js.map +1 -1
- package/dist/services/aiProposals.d.ts +70 -0
- package/dist/services/aiProposals.d.ts.map +1 -0
- package/dist/services/aiProposals.js +363 -0
- package/dist/services/aiProposals.js.map +1 -0
- package/dist/services/auth.d.ts.map +1 -1
- package/dist/services/auth.js +10 -4
- package/dist/services/auth.js.map +1 -1
- package/dist/services/conversation.d.ts +1 -0
- package/dist/services/conversation.d.ts.map +1 -1
- package/dist/services/fees.d.ts.map +1 -1
- package/dist/services/fees.js +7 -3
- package/dist/services/fees.js.map +1 -1
- package/dist/services/labChat.d.ts +10 -0
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +52 -3
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/message.d.ts +24 -8
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +170 -117
- package/dist/services/message.js.map +1 -1
- package/dist/services/module.d.ts.map +1 -1
- package/dist/services/module.js +23 -4
- package/dist/services/module.js.map +1 -1
- package/dist/services/parentPortal.d.ts +14 -0
- package/dist/services/parentPortal.d.ts.map +1 -1
- package/dist/services/parentPortal.js +72 -2
- package/dist/services/parentPortal.js.map +1 -1
- package/dist/services/studentProgress.d.ts +50 -0
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +107 -2
- package/dist/services/studentProgress.js.map +1 -1
- package/dist/services/visualization.d.ts +72 -0
- package/dist/services/visualization.d.ts.map +1 -0
- package/dist/services/visualization.js +122 -0
- package/dist/services/visualization.js.map +1 -0
- package/dist/utils/inference.d.ts +10 -0
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +77 -2
- package/dist/utils/inference.js.map +1 -1
- package/dist/utils/sentryTrpc.d.ts +3 -0
- package/dist/utils/sentryTrpc.d.ts.map +1 -0
- package/dist/utils/sentryTrpc.js +26 -0
- package/dist/utils/sentryTrpc.js.map +1 -0
- package/dist/workers/processors/createRunJobWorker.js +17 -16
- package/dist/workers/processors/createRunJobWorker.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260811130000_add_ai_message_feedback/migration.sql +7 -0
- package/prisma/migrations/20261025120000_add_visualizations/migration.sql +27 -0
- package/prisma/schema.prisma +32 -0
- package/src/index.ts +30 -4
- package/src/instrument.ts +8 -2
- package/src/lib/fileUpload.ts +49 -0
- package/src/models/cloudStorage.ts +19 -0
- package/src/models/labChat.ts +34 -0
- package/src/pipelines/aiLabChat.ts +79 -18
- package/src/pipelines/aiLabChatContract.ts +35 -0
- package/src/pipelines/aiNewtonChat.ts +34 -2
- package/src/pipelines/generateVisualization.ts +107 -0
- package/src/pipelines/labChatPrompt.ts +11 -12
- package/src/routers/_app.ts +2 -0
- package/src/routers/admin.ts +72 -0
- package/src/routers/class.ts +14 -1
- package/src/routers/labChat.ts +5 -0
- package/src/routers/message.ts +34 -5
- package/src/routers/parent.ts +11 -0
- package/src/routers/studentProgress.ts +15 -0
- package/src/routers/visualization.ts +60 -0
- package/src/routes/aiStream.ts +32 -0
- package/src/seedDatabase.ts +107 -2
- package/src/services/adminClass.ts +222 -0
- package/src/services/adminInstitution.ts +9 -2
- package/src/services/adminMetrics.ts +35 -0
- package/src/services/adminSpam.ts +224 -0
- package/src/services/adminSql.ts +214 -0
- package/src/services/adminTelemetry.ts +194 -0
- package/src/services/adminUser.ts +66 -11
- package/src/services/aiChatStream.ts +83 -7
- package/src/services/aiGeneration.ts +92 -0
- package/src/services/aiProposalTools.ts +166 -1
- package/src/services/aiProposals.ts +428 -0
- package/src/services/auth.ts +8 -2
- package/src/services/fees.ts +5 -1
- package/src/services/labChat.ts +65 -0
- package/src/services/message.ts +205 -142
- package/src/services/module.ts +29 -2
- package/src/services/parentPortal.ts +85 -0
- package/src/services/studentProgress.ts +135 -0
- package/src/services/visualization.ts +147 -0
- package/src/utils/inference.ts +85 -0
- package/src/utils/sentryTrpc.ts +25 -0
- package/src/workers/processors/createRunJobWorker.ts +17 -17
- package/tests/unit/lib/fileUploadSession.test.ts +67 -0
- package/tests/unit/services/adminClass.test.ts +102 -0
- package/tests/unit/services/adminSpam.test.ts +108 -0
- package/tests/unit/services/adminSql.test.ts +56 -0
- package/tests/unit/services/adminTelemetry.test.ts +47 -0
- package/tests/unit/services/adminUser.test.ts +77 -0
- package/tests/unit/services/visualization.test.ts +130 -0
- package/tests/unit/utils/adminPanelAccess.test.ts +26 -0
- package/tests/unit/utils/sentryTrpc.test.ts +15 -0
package/src/services/message.ts
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { AppTransactionClient } from "../lib/prisma.js";
|
|
6
6
|
import { TRPCError } from "@trpc/server";
|
|
7
|
+
import { JobStatus, type AiMessageFeedback } from "@prisma/client";
|
|
8
|
+
import { isAIUser } from "../utils/aiUser.js";
|
|
9
|
+
import { enqueueRunJob } from "../workers/queues.js";
|
|
7
10
|
import { prisma } from "../lib/prisma.js";
|
|
8
11
|
import { chatChannel, pusher } from "../lib/pusher.js";
|
|
9
12
|
import {
|
|
@@ -181,6 +184,7 @@ export async function listMessages(
|
|
|
181
184
|
createdAt: message.createdAt,
|
|
182
185
|
sender: message.sender,
|
|
183
186
|
status: message.status,
|
|
187
|
+
aiFeedback: message.aiFeedback,
|
|
184
188
|
attachments: message.attachments,
|
|
185
189
|
meta: message.meta as Record<string, unknown>,
|
|
186
190
|
mentions: message.mentions.map((m) => ({ user: m.user })),
|
|
@@ -512,148 +516,6 @@ export async function deleteMessage(userId: string, messageId: string) {
|
|
|
512
516
|
return { success: true, messageId };
|
|
513
517
|
}
|
|
514
518
|
|
|
515
|
-
const CREATED_INDICES_KEY: Record<"assignment" | "worksheet" | "section", string> = {
|
|
516
|
-
assignment: "assignments",
|
|
517
|
-
worksheet: "worksheets",
|
|
518
|
-
section: "sections",
|
|
519
|
-
};
|
|
520
|
-
|
|
521
|
-
const MARK_SUGGESTION_MAX_RETRIES = 5;
|
|
522
|
-
|
|
523
|
-
/** Mark an AI-suggested item as created. Stores in message meta for fast reads. */
|
|
524
|
-
export async function markSuggestionCreated(
|
|
525
|
-
userId: string,
|
|
526
|
-
input: {
|
|
527
|
-
messageId: string;
|
|
528
|
-
type: "assignment" | "worksheet" | "section";
|
|
529
|
-
index: number;
|
|
530
|
-
}
|
|
531
|
-
) {
|
|
532
|
-
const { messageId, type, index } = input;
|
|
533
|
-
|
|
534
|
-
const existingMessage = await findMessageByIdMinimal(messageId);
|
|
535
|
-
if (!existingMessage) {
|
|
536
|
-
throw new TRPCError({
|
|
537
|
-
code: "NOT_FOUND",
|
|
538
|
-
message: "Message not found",
|
|
539
|
-
});
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
const membership = await findConversationMembership(
|
|
543
|
-
existingMessage.conversationId,
|
|
544
|
-
userId
|
|
545
|
-
);
|
|
546
|
-
if (!membership) {
|
|
547
|
-
throw new TRPCError({
|
|
548
|
-
code: "FORBIDDEN",
|
|
549
|
-
message: "Not a member of this conversation",
|
|
550
|
-
});
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
const conversationId = existingMessage.conversationId;
|
|
554
|
-
let lastError: Error | null = null;
|
|
555
|
-
|
|
556
|
-
for (let attempt = 0; attempt < MARK_SUGGESTION_MAX_RETRIES; attempt++) {
|
|
557
|
-
const msg = await prisma.message.findUnique({
|
|
558
|
-
where: { id: messageId },
|
|
559
|
-
select: { meta: true },
|
|
560
|
-
});
|
|
561
|
-
if (!msg) {
|
|
562
|
-
throw new TRPCError({
|
|
563
|
-
code: "NOT_FOUND",
|
|
564
|
-
message: "Message not found",
|
|
565
|
-
});
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
const currentMeta = (msg.meta as Record<string, unknown>) ?? {};
|
|
569
|
-
const createdIndices = (currentMeta.createdIndices as Record<string, number[]>) ?? {};
|
|
570
|
-
const typeIndices = createdIndices[CREATED_INDICES_KEY[type]] ?? [];
|
|
571
|
-
if (typeIndices.includes(index)) return { success: true };
|
|
572
|
-
|
|
573
|
-
const newTypeIndices = [...typeIndices, index].sort((a, b) => a - b);
|
|
574
|
-
const newMeta = {
|
|
575
|
-
...currentMeta,
|
|
576
|
-
createdIndices: {
|
|
577
|
-
...createdIndices,
|
|
578
|
-
[CREATED_INDICES_KEY[type]]: newTypeIndices,
|
|
579
|
-
},
|
|
580
|
-
};
|
|
581
|
-
|
|
582
|
-
const newMetaJson = JSON.stringify(newMeta);
|
|
583
|
-
const currentMetaJson = msg.meta === null ? null : JSON.stringify(msg.meta);
|
|
584
|
-
|
|
585
|
-
const updatedCount =
|
|
586
|
-
currentMetaJson === null
|
|
587
|
-
? await prisma.$executeRaw`
|
|
588
|
-
UPDATE "Message" SET meta = ${newMetaJson}::jsonb
|
|
589
|
-
WHERE id = ${messageId} AND meta IS NULL
|
|
590
|
-
`
|
|
591
|
-
: await prisma.$executeRaw`
|
|
592
|
-
UPDATE "Message" SET meta = ${newMetaJson}::jsonb
|
|
593
|
-
WHERE id = ${messageId} AND meta = ${currentMetaJson}::jsonb
|
|
594
|
-
`;
|
|
595
|
-
|
|
596
|
-
if (Number(updatedCount) > 0) {
|
|
597
|
-
const updated = await prisma.message.findUnique({
|
|
598
|
-
where: { id: messageId },
|
|
599
|
-
include: {
|
|
600
|
-
sender: {
|
|
601
|
-
select: {
|
|
602
|
-
id: true,
|
|
603
|
-
username: true,
|
|
604
|
-
profile: {
|
|
605
|
-
select: { displayName: true, profilePicture: true },
|
|
606
|
-
},
|
|
607
|
-
},
|
|
608
|
-
},
|
|
609
|
-
attachments: { select: messageFileSelect },
|
|
610
|
-
mentions: { select: { userId: true } },
|
|
611
|
-
},
|
|
612
|
-
});
|
|
613
|
-
|
|
614
|
-
if (updated) {
|
|
615
|
-
const mentionedUserIds = updated.mentions.map((m) => m.userId);
|
|
616
|
-
try {
|
|
617
|
-
await pusher.trigger(
|
|
618
|
-
chatChannel(conversationId),
|
|
619
|
-
"message-updated",
|
|
620
|
-
{
|
|
621
|
-
id: updated.id,
|
|
622
|
-
content: updated.content,
|
|
623
|
-
senderId: updated.senderId,
|
|
624
|
-
conversationId: updated.conversationId,
|
|
625
|
-
status: updated.status,
|
|
626
|
-
createdAt: updated.createdAt,
|
|
627
|
-
sender: updated.sender,
|
|
628
|
-
attachments: updated.attachments ?? [],
|
|
629
|
-
meta: newMeta,
|
|
630
|
-
mentionedUserIds,
|
|
631
|
-
}
|
|
632
|
-
);
|
|
633
|
-
} catch (error) {
|
|
634
|
-
logger.error("Failed to broadcast suggestion status via Pusher", {
|
|
635
|
-
error,
|
|
636
|
-
messageId,
|
|
637
|
-
});
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
return { success: true };
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
lastError = new Error("Concurrent update conflict");
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
logger.error("markSuggestionCreated failed after retries", {
|
|
647
|
-
messageId,
|
|
648
|
-
type,
|
|
649
|
-
index,
|
|
650
|
-
error: lastError,
|
|
651
|
-
});
|
|
652
|
-
throw new TRPCError({
|
|
653
|
-
code: "CONFLICT",
|
|
654
|
-
message: "Failed to update suggestion status after retries due to concurrent updates",
|
|
655
|
-
});
|
|
656
|
-
}
|
|
657
519
|
|
|
658
520
|
export async function markAsRead(userId: string, conversationId: string) {
|
|
659
521
|
const membership = await findConversationMembership(conversationId, userId);
|
|
@@ -757,3 +619,204 @@ export async function getUnreadCount(
|
|
|
757
619
|
|
|
758
620
|
return { unreadCount, unreadMentionCount };
|
|
759
621
|
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Regenerate an AI response in a lab or Newton chat.
|
|
625
|
+
*
|
|
626
|
+
* The existing AI bubble is reused (not deleted): the row flips back to
|
|
627
|
+
* PENDING with cleared content/meta/feedback so the message id stays stable
|
|
628
|
+
* for the client, then the matching pipeline refills it. Only the latest
|
|
629
|
+
* message in the conversation can be regenerated — the pipelines build
|
|
630
|
+
* their context from the messages preceding the placeholder.
|
|
631
|
+
*/
|
|
632
|
+
export async function regenerateAIResponse(userId: string, messageId: string) {
|
|
633
|
+
const message = await prisma.message.findUnique({
|
|
634
|
+
where: { id: messageId },
|
|
635
|
+
select: {
|
|
636
|
+
id: true,
|
|
637
|
+
senderId: true,
|
|
638
|
+
conversationId: true,
|
|
639
|
+
status: true,
|
|
640
|
+
conversation: {
|
|
641
|
+
select: {
|
|
642
|
+
labChat: { select: { id: true, classId: true } },
|
|
643
|
+
newtonChat: { select: { id: true } },
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
if (!message) {
|
|
649
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Message not found" });
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const membership = await findConversationMembership(
|
|
653
|
+
message.conversationId,
|
|
654
|
+
userId
|
|
655
|
+
);
|
|
656
|
+
if (!membership) {
|
|
657
|
+
throw new TRPCError({
|
|
658
|
+
code: "FORBIDDEN",
|
|
659
|
+
message: "Not a member of this conversation",
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (!isAIUser(message.senderId)) {
|
|
664
|
+
throw new TRPCError({
|
|
665
|
+
code: "BAD_REQUEST",
|
|
666
|
+
message: "Only AI messages can be regenerated",
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const { labChat, newtonChat } = message.conversation;
|
|
671
|
+
if (!labChat && !newtonChat) {
|
|
672
|
+
throw new TRPCError({
|
|
673
|
+
code: "BAD_REQUEST",
|
|
674
|
+
message: "This conversation has no AI pipeline to regenerate with",
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (message.status === JobStatus.PENDING) {
|
|
679
|
+
throw new TRPCError({
|
|
680
|
+
code: "BAD_REQUEST",
|
|
681
|
+
message: "This response is still being generated",
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const latestMessage = await prisma.message.findFirst({
|
|
686
|
+
where: { conversationId: message.conversationId },
|
|
687
|
+
orderBy: { createdAt: "desc" },
|
|
688
|
+
select: { id: true },
|
|
689
|
+
});
|
|
690
|
+
if (latestMessage?.id !== message.id) {
|
|
691
|
+
throw new TRPCError({
|
|
692
|
+
code: "BAD_REQUEST",
|
|
693
|
+
message: "Only the latest AI response can be regenerated",
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const reusedAiMessage = await prisma.message.update({
|
|
698
|
+
where: { id: message.id },
|
|
699
|
+
data: {
|
|
700
|
+
content: "",
|
|
701
|
+
status: JobStatus.PENDING,
|
|
702
|
+
meta: {},
|
|
703
|
+
aiFeedback: null,
|
|
704
|
+
},
|
|
705
|
+
select: {
|
|
706
|
+
id: true,
|
|
707
|
+
content: true,
|
|
708
|
+
senderId: true,
|
|
709
|
+
conversationId: true,
|
|
710
|
+
status: true,
|
|
711
|
+
createdAt: true,
|
|
712
|
+
},
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// Tell the chat UI the existing AI bubble is regenerating — content
|
|
716
|
+
// cleared, status flipped back to PENDING, same id.
|
|
717
|
+
try {
|
|
718
|
+
await pusher.trigger(
|
|
719
|
+
chatChannel(message.conversationId),
|
|
720
|
+
"message-updated",
|
|
721
|
+
{
|
|
722
|
+
id: reusedAiMessage.id,
|
|
723
|
+
content: reusedAiMessage.content,
|
|
724
|
+
senderId: reusedAiMessage.senderId,
|
|
725
|
+
conversationId: reusedAiMessage.conversationId,
|
|
726
|
+
status: reusedAiMessage.status,
|
|
727
|
+
createdAt: reusedAiMessage.createdAt,
|
|
728
|
+
sender: {
|
|
729
|
+
id: reusedAiMessage.senderId,
|
|
730
|
+
username: "Newton_AI",
|
|
731
|
+
profile: { displayName: "Newton AI", profilePicture: null },
|
|
732
|
+
},
|
|
733
|
+
mentionedUserIds: [],
|
|
734
|
+
meta: {},
|
|
735
|
+
attachments: [],
|
|
736
|
+
}
|
|
737
|
+
);
|
|
738
|
+
} catch (error) {
|
|
739
|
+
logger.error("Failed to broadcast AI regenerate reset", {
|
|
740
|
+
error,
|
|
741
|
+
messageId,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
try {
|
|
746
|
+
if (labChat) {
|
|
747
|
+
await enqueueRunJob(
|
|
748
|
+
"AI_LAB_CHAT",
|
|
749
|
+
{ conversationId: message.conversationId, aiMessageId: reusedAiMessage.id },
|
|
750
|
+
{ classId: labChat.classId, userId }
|
|
751
|
+
);
|
|
752
|
+
} else {
|
|
753
|
+
await enqueueRunJob(
|
|
754
|
+
"AI_NEWTON_CHAT",
|
|
755
|
+
{ conversationId: message.conversationId, aiMessageId: reusedAiMessage.id },
|
|
756
|
+
{ userId }
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
} catch (error) {
|
|
760
|
+
logger.error("Failed to enqueue AI regeneration", {
|
|
761
|
+
error,
|
|
762
|
+
messageId,
|
|
763
|
+
conversationId: message.conversationId,
|
|
764
|
+
});
|
|
765
|
+
throw new TRPCError({
|
|
766
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
767
|
+
message: "Failed to start regeneration",
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return { success: true, messageId: reusedAiMessage.id };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Record (or clear) thumbs up/down feedback on an AI message. */
|
|
775
|
+
export async function setAIMessageFeedback(
|
|
776
|
+
userId: string,
|
|
777
|
+
input: {
|
|
778
|
+
messageId: string;
|
|
779
|
+
feedback: AiMessageFeedback | null;
|
|
780
|
+
}
|
|
781
|
+
) {
|
|
782
|
+
const message = await prisma.message.findUnique({
|
|
783
|
+
where: { id: input.messageId },
|
|
784
|
+
select: { id: true, senderId: true, conversationId: true, status: true },
|
|
785
|
+
});
|
|
786
|
+
if (!message) {
|
|
787
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Message not found" });
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const membership = await findConversationMembership(
|
|
791
|
+
message.conversationId,
|
|
792
|
+
userId
|
|
793
|
+
);
|
|
794
|
+
if (!membership) {
|
|
795
|
+
throw new TRPCError({
|
|
796
|
+
code: "FORBIDDEN",
|
|
797
|
+
message: "Not a member of this conversation",
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (!isAIUser(message.senderId)) {
|
|
802
|
+
throw new TRPCError({
|
|
803
|
+
code: "BAD_REQUEST",
|
|
804
|
+
message: "Feedback can only be left on AI messages",
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (message.status !== JobStatus.COMPLETED) {
|
|
809
|
+
throw new TRPCError({
|
|
810
|
+
code: "BAD_REQUEST",
|
|
811
|
+
message: "Feedback can only be left on completed AI responses",
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const updated = await prisma.message.update({
|
|
816
|
+
where: { id: message.id },
|
|
817
|
+
data: { aiFeedback: input.feedback },
|
|
818
|
+
select: { id: true, aiFeedback: true },
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
return { success: true, messageId: updated.id, feedback: updated.aiFeedback };
|
|
822
|
+
}
|
package/src/services/module.ts
CHANGED
|
@@ -182,6 +182,7 @@ const CONTENT_TYPES: ModuleItemType[] = [
|
|
|
182
182
|
ModuleItemType.FILE,
|
|
183
183
|
ModuleItemType.ANNOUNCEMENT,
|
|
184
184
|
ModuleItemType.DISCUSSION,
|
|
185
|
+
ModuleItemType.VISUALIZATION,
|
|
185
186
|
];
|
|
186
187
|
|
|
187
188
|
const SUBMITTABLE_TYPES: ModuleItemType[] = [
|
|
@@ -235,7 +236,9 @@ async function validateItemInput(
|
|
|
235
236
|
? await prisma.announcement.findFirst({ where: { id: itemId, classId } })
|
|
236
237
|
: itemType === ModuleItemType.DISCUSSION
|
|
237
238
|
? await prisma.discussion.findFirst({ where: { id: itemId, classId } })
|
|
238
|
-
:
|
|
239
|
+
: itemType === ModuleItemType.VISUALIZATION
|
|
240
|
+
? await prisma.visualization.findFirst({ where: { id: itemId, classId } })
|
|
241
|
+
: await prisma.file.findUnique({ where: { id: itemId } });
|
|
239
242
|
if (!exists) {
|
|
240
243
|
throw new TRPCError({
|
|
241
244
|
code: "NOT_FOUND",
|
|
@@ -437,11 +440,35 @@ function isModuleComplete(
|
|
|
437
440
|
* completion/accessibility and per-module progress percentages.
|
|
438
441
|
*/
|
|
439
442
|
export async function getStudentModulesState(classId: string, studentId: string) {
|
|
440
|
-
const [
|
|
443
|
+
const [allModules, progress] = await Promise.all([
|
|
441
444
|
findModulesByClassId(classId, true),
|
|
442
445
|
findStudentProgressForClass(classId, studentId),
|
|
443
446
|
]);
|
|
444
447
|
|
|
448
|
+
// Hide visualisations the teacher hasn't published yet.
|
|
449
|
+
const visualizationIds = allModules
|
|
450
|
+
.flatMap((m) => m.items)
|
|
451
|
+
.filter((i) => i.itemType === ModuleItemType.VISUALIZATION && i.itemId)
|
|
452
|
+
.map((i) => i.itemId as string);
|
|
453
|
+
const publishedVisualizationIds = new Set(
|
|
454
|
+
visualizationIds.length
|
|
455
|
+
? (
|
|
456
|
+
await prisma.visualization.findMany({
|
|
457
|
+
where: { id: { in: visualizationIds }, classId, published: true },
|
|
458
|
+
select: { id: true },
|
|
459
|
+
})
|
|
460
|
+
).map((v) => v.id)
|
|
461
|
+
: []
|
|
462
|
+
);
|
|
463
|
+
const modules = allModules.map((m) => ({
|
|
464
|
+
...m,
|
|
465
|
+
items: m.items.filter(
|
|
466
|
+
(i) =>
|
|
467
|
+
i.itemType !== ModuleItemType.VISUALIZATION ||
|
|
468
|
+
(!!i.itemId && publishedVisualizationIds.has(i.itemId))
|
|
469
|
+
),
|
|
470
|
+
}));
|
|
471
|
+
|
|
445
472
|
const completedItemIds = new Set(
|
|
446
473
|
progress
|
|
447
474
|
.filter((p) => p.status === ModuleItemProgressStatus.COMPLETE)
|
|
@@ -85,6 +85,91 @@ export async function linkParentToStudent(
|
|
|
85
85
|
return { link };
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
export async function addParentToStudent(
|
|
89
|
+
userId: string,
|
|
90
|
+
input: { schoolId: string; studentUserId: string; parentIdentifier: string }
|
|
91
|
+
) {
|
|
92
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
93
|
+
|
|
94
|
+
const studentMembership = await findSchoolMembership(
|
|
95
|
+
input.schoolId,
|
|
96
|
+
input.studentUserId
|
|
97
|
+
);
|
|
98
|
+
if (
|
|
99
|
+
!studentMembership ||
|
|
100
|
+
studentMembership.status !== "ACTIVE" ||
|
|
101
|
+
studentMembership.role !== "STUDENT"
|
|
102
|
+
) {
|
|
103
|
+
throw new TRPCError({
|
|
104
|
+
code: "BAD_REQUEST",
|
|
105
|
+
message: "studentUserId must be an active STUDENT member",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const identifier = input.parentIdentifier.trim();
|
|
110
|
+
const parentUser = await prisma.user.findFirst({
|
|
111
|
+
where: {
|
|
112
|
+
OR: [{ email: identifier }, { username: identifier }],
|
|
113
|
+
},
|
|
114
|
+
select: { id: true },
|
|
115
|
+
});
|
|
116
|
+
if (!parentUser) {
|
|
117
|
+
throw new TRPCError({
|
|
118
|
+
code: "NOT_FOUND",
|
|
119
|
+
message: "No account found with that email or username",
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (parentUser.id === input.studentUserId) {
|
|
123
|
+
throw new TRPCError({
|
|
124
|
+
code: "BAD_REQUEST",
|
|
125
|
+
message: "A student cannot be their own parent",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const parentMembership = await findSchoolMembership(
|
|
130
|
+
input.schoolId,
|
|
131
|
+
parentUser.id
|
|
132
|
+
);
|
|
133
|
+
if (!parentMembership) {
|
|
134
|
+
await prisma.schoolMembership.create({
|
|
135
|
+
data: {
|
|
136
|
+
schoolId: input.schoolId,
|
|
137
|
+
userId: parentUser.id,
|
|
138
|
+
role: "PARENT",
|
|
139
|
+
status: "ACTIVE",
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
} else if (parentMembership.role !== "PARENT") {
|
|
143
|
+
throw new TRPCError({
|
|
144
|
+
code: "BAD_REQUEST",
|
|
145
|
+
message: "That account already belongs to this school with another role",
|
|
146
|
+
});
|
|
147
|
+
} else if (parentMembership.status !== "ACTIVE") {
|
|
148
|
+
throw new TRPCError({
|
|
149
|
+
code: "BAD_REQUEST",
|
|
150
|
+
message: "Parent must be an active school member",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const link = await prisma.parentStudentLink.upsert({
|
|
155
|
+
where: {
|
|
156
|
+
schoolId_parentUserId_studentUserId: {
|
|
157
|
+
schoolId: input.schoolId,
|
|
158
|
+
parentUserId: parentUser.id,
|
|
159
|
+
studentUserId: input.studentUserId,
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
update: {},
|
|
163
|
+
create: {
|
|
164
|
+
schoolId: input.schoolId,
|
|
165
|
+
parentUserId: parentUser.id,
|
|
166
|
+
studentUserId: input.studentUserId,
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return { link };
|
|
171
|
+
}
|
|
172
|
+
|
|
88
173
|
export async function unlinkParentFromStudent(
|
|
89
174
|
userId: string,
|
|
90
175
|
input: { schoolId: string; parentUserId: string; studentUserId: string }
|
|
@@ -6,6 +6,7 @@ import type { AssignmentType } from "@prisma/client";
|
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { prisma } from "../lib/prisma.js";
|
|
8
8
|
import { isTeacherInClass } from "../models/class.js";
|
|
9
|
+
import { createAssignmentRecord } from "./assignment.js";
|
|
9
10
|
import { inference } from "../utils/inference.js";
|
|
10
11
|
import { logger } from "../utils/logger.js";
|
|
11
12
|
import {
|
|
@@ -617,6 +618,140 @@ export async function dismissStudentRecommendation(
|
|
|
617
618
|
return { success: true };
|
|
618
619
|
}
|
|
619
620
|
|
|
621
|
+
const followUpAssignmentSchema = z.object({
|
|
622
|
+
title: z.string().min(1).max(200),
|
|
623
|
+
instructions: z.string().min(1),
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
export type FollowUpAssignmentDraft = z.infer<typeof followUpAssignmentSchema>;
|
|
627
|
+
|
|
628
|
+
export async function generateFollowUpAssignment(
|
|
629
|
+
viewerId: string,
|
|
630
|
+
input: {
|
|
631
|
+
classId: string;
|
|
632
|
+
studentId: string;
|
|
633
|
+
submissionId: string;
|
|
634
|
+
prompt: string;
|
|
635
|
+
dueDate?: string;
|
|
636
|
+
},
|
|
637
|
+
) {
|
|
638
|
+
const teacherInClass = await isTeacherInClass(input.classId, viewerId);
|
|
639
|
+
if (!teacherInClass) {
|
|
640
|
+
throw new TRPCError({
|
|
641
|
+
code: "UNAUTHORIZED",
|
|
642
|
+
message: "Only teachers can generate follow-up assignments",
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const submission = await prisma.submission.findFirst({
|
|
647
|
+
where: {
|
|
648
|
+
id: input.submissionId,
|
|
649
|
+
studentId: input.studentId,
|
|
650
|
+
assignment: { classId: input.classId },
|
|
651
|
+
},
|
|
652
|
+
select: {
|
|
653
|
+
id: true,
|
|
654
|
+
gradeReceived: true,
|
|
655
|
+
teacherComments: true,
|
|
656
|
+
student: {
|
|
657
|
+
select: {
|
|
658
|
+
id: true,
|
|
659
|
+
username: true,
|
|
660
|
+
profile: { select: { displayName: true } },
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
assignment: {
|
|
664
|
+
select: {
|
|
665
|
+
id: true,
|
|
666
|
+
title: true,
|
|
667
|
+
instructions: true,
|
|
668
|
+
type: true,
|
|
669
|
+
maxGrade: true,
|
|
670
|
+
weight: true,
|
|
671
|
+
section: { select: { id: true, name: true } },
|
|
672
|
+
},
|
|
673
|
+
},
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
if (!submission) {
|
|
678
|
+
throw new TRPCError({
|
|
679
|
+
code: "NOT_FOUND",
|
|
680
|
+
message: "Submission not found for this student in this class",
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const displayName =
|
|
685
|
+
submission.student.profile?.displayName ?? submission.student.username;
|
|
686
|
+
const percentage =
|
|
687
|
+
submission.gradeReceived != null && submission.assignment.maxGrade
|
|
688
|
+
? Math.round(
|
|
689
|
+
(submission.gradeReceived / submission.assignment.maxGrade) * 1000,
|
|
690
|
+
) / 10
|
|
691
|
+
: null;
|
|
692
|
+
|
|
693
|
+
const draft = await inference<FollowUpAssignmentDraft>(
|
|
694
|
+
[
|
|
695
|
+
{
|
|
696
|
+
role: "system" as const,
|
|
697
|
+
content:
|
|
698
|
+
"You are an experienced teacher creating a targeted follow-up assignment for one student. Ground the task in the source assignment and the teacher's request. Do not fabricate material beyond the source assignment's topic. Return a concise title and clear, student-facing instructions.",
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
role: "user" as const,
|
|
702
|
+
content: JSON.stringify({
|
|
703
|
+
student: { displayName },
|
|
704
|
+
sourceAssignment: {
|
|
705
|
+
title: submission.assignment.title,
|
|
706
|
+
instructions: submission.assignment.instructions,
|
|
707
|
+
type: submission.assignment.type,
|
|
708
|
+
maxGrade: submission.assignment.maxGrade,
|
|
709
|
+
section: submission.assignment.section?.name ?? null,
|
|
710
|
+
},
|
|
711
|
+
submission: {
|
|
712
|
+
gradeReceived: submission.gradeReceived,
|
|
713
|
+
percentage,
|
|
714
|
+
teacherFeedback: submission.teacherComments,
|
|
715
|
+
},
|
|
716
|
+
}),
|
|
717
|
+
},
|
|
718
|
+
{ role: "user" as const, content: input.prompt },
|
|
719
|
+
],
|
|
720
|
+
followUpAssignmentSchema,
|
|
721
|
+
{
|
|
722
|
+
reason: "student_progress_follow_up_assignment",
|
|
723
|
+
metadata: {
|
|
724
|
+
viewerId,
|
|
725
|
+
classId: input.classId,
|
|
726
|
+
studentId: input.studentId,
|
|
727
|
+
submissionId: input.submissionId,
|
|
728
|
+
},
|
|
729
|
+
attribution: { userId: viewerId, classId: input.classId },
|
|
730
|
+
},
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
const dueDate =
|
|
734
|
+
input.dueDate ??
|
|
735
|
+
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
736
|
+
|
|
737
|
+
const assignment = await createAssignmentRecord(viewerId, {
|
|
738
|
+
classId: input.classId,
|
|
739
|
+
title: draft.title,
|
|
740
|
+
instructions: draft.instructions,
|
|
741
|
+
dueDate,
|
|
742
|
+
studentIds: [input.studentId],
|
|
743
|
+
maxGrade: submission.assignment.maxGrade ?? 100,
|
|
744
|
+
graded: true,
|
|
745
|
+
weight: submission.assignment.weight || 1,
|
|
746
|
+
sectionId: submission.assignment.section?.id ?? undefined,
|
|
747
|
+
type: normalizeAssignmentType(submission.assignment.type),
|
|
748
|
+
acceptExtendedResponse: true,
|
|
749
|
+
sourceAssignmentId: submission.assignment.id,
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
return { assignment, draft };
|
|
753
|
+
}
|
|
754
|
+
|
|
620
755
|
function buildProgressSummary(submissions: GradeSubmission[]) {
|
|
621
756
|
return submissions.map((submission) => ({
|
|
622
757
|
title: submission.assignment.title,
|