@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.
Files changed (222) hide show
  1. package/.env.example +3 -1
  2. package/.github/workflows/npm-publish.yml +1 -0
  3. package/AGENTS.md +84 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +31 -4
  6. package/dist/index.js.map +1 -1
  7. package/dist/instrument.js +9 -4
  8. package/dist/instrument.js.map +1 -1
  9. package/dist/lib/fileUpload.d.ts +15 -0
  10. package/dist/lib/fileUpload.d.ts.map +1 -1
  11. package/dist/lib/fileUpload.js +43 -2
  12. package/dist/lib/fileUpload.js.map +1 -1
  13. package/dist/models/cloudStorage.d.ts +11 -0
  14. package/dist/models/cloudStorage.d.ts.map +1 -1
  15. package/dist/models/cloudStorage.js +18 -2
  16. package/dist/models/cloudStorage.js.map +1 -1
  17. package/dist/models/conversation.d.ts +1 -0
  18. package/dist/models/conversation.d.ts.map +1 -1
  19. package/dist/models/labChat.d.ts +40 -0
  20. package/dist/models/labChat.d.ts.map +1 -1
  21. package/dist/models/labChat.js +32 -2
  22. package/dist/models/labChat.js.map +1 -1
  23. package/dist/models/message.d.ts +3 -0
  24. package/dist/models/message.d.ts.map +1 -1
  25. package/dist/pipelines/aiLabChat.d.ts +2 -0
  26. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  27. package/dist/pipelines/aiLabChat.js +60 -16
  28. package/dist/pipelines/aiLabChat.js.map +1 -1
  29. package/dist/pipelines/aiLabChatContract.d.ts +170 -2
  30. package/dist/pipelines/aiLabChatContract.d.ts.map +1 -1
  31. package/dist/pipelines/aiLabChatContract.js +30 -2
  32. package/dist/pipelines/aiLabChatContract.js.map +1 -1
  33. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  34. package/dist/pipelines/aiNewtonChat.js +30 -5
  35. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  36. package/dist/pipelines/generateVisualization.d.ts +33 -0
  37. package/dist/pipelines/generateVisualization.d.ts.map +1 -0
  38. package/dist/pipelines/generateVisualization.js +79 -0
  39. package/dist/pipelines/generateVisualization.js.map +1 -0
  40. package/dist/pipelines/labChatPrompt.d.ts +1 -1
  41. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  42. package/dist/pipelines/labChatPrompt.js +13 -14
  43. package/dist/pipelines/labChatPrompt.js.map +1 -1
  44. package/dist/routers/_app.d.ts +1118 -70
  45. package/dist/routers/_app.d.ts.map +1 -1
  46. package/dist/routers/_app.js +4 -2
  47. package/dist/routers/_app.js.map +1 -1
  48. package/dist/routers/admin.d.ts +297 -1
  49. package/dist/routers/admin.d.ts.map +1 -1
  50. package/dist/routers/admin.js +43 -2
  51. package/dist/routers/admin.js.map +1 -1
  52. package/dist/routers/class.d.ts.map +1 -1
  53. package/dist/routers/class.js +16 -3
  54. package/dist/routers/class.js.map +1 -1
  55. package/dist/routers/conversation.d.ts +1 -0
  56. package/dist/routers/conversation.d.ts.map +1 -1
  57. package/dist/routers/labChat.d.ts +10 -0
  58. package/dist/routers/labChat.d.ts.map +1 -1
  59. package/dist/routers/labChat.js +6 -3
  60. package/dist/routers/labChat.js.map +1 -1
  61. package/dist/routers/message.d.ts +37 -4
  62. package/dist/routers/message.d.ts.map +1 -1
  63. package/dist/routers/message.js +24 -7
  64. package/dist/routers/message.js.map +1 -1
  65. package/dist/routers/module.d.ts +1 -1
  66. package/dist/routers/parent.d.ts +18 -0
  67. package/dist/routers/parent.d.ts.map +1 -1
  68. package/dist/routers/parent.js +10 -3
  69. package/dist/routers/parent.js.map +1 -1
  70. package/dist/routers/studentProgress.d.ts +44 -0
  71. package/dist/routers/studentProgress.d.ts.map +1 -1
  72. package/dist/routers/studentProgress.js +12 -3
  73. package/dist/routers/studentProgress.js.map +1 -1
  74. package/dist/routers/visualization.d.ts +124 -0
  75. package/dist/routers/visualization.d.ts.map +1 -0
  76. package/dist/routers/visualization.js +41 -0
  77. package/dist/routers/visualization.js.map +1 -0
  78. package/dist/routes/aiStream.js +32 -2
  79. package/dist/routes/aiStream.js.map +1 -1
  80. package/dist/seedDatabase.d.ts.map +1 -1
  81. package/dist/seedDatabase.js +107 -3
  82. package/dist/seedDatabase.js.map +1 -1
  83. package/dist/services/adminClass.d.ts +153 -0
  84. package/dist/services/adminClass.d.ts.map +1 -0
  85. package/dist/services/adminClass.js +196 -0
  86. package/dist/services/adminClass.js.map +1 -0
  87. package/dist/services/adminInstitution.d.ts +2 -0
  88. package/dist/services/adminInstitution.d.ts.map +1 -1
  89. package/dist/services/adminInstitution.js +11 -4
  90. package/dist/services/adminInstitution.js.map +1 -1
  91. package/dist/services/adminMetrics.d.ts +8 -0
  92. package/dist/services/adminMetrics.d.ts.map +1 -1
  93. package/dist/services/adminMetrics.js +30 -3
  94. package/dist/services/adminMetrics.js.map +1 -1
  95. package/dist/services/adminSpam.d.ts +76 -0
  96. package/dist/services/adminSpam.d.ts.map +1 -0
  97. package/dist/services/adminSpam.js +163 -0
  98. package/dist/services/adminSpam.js.map +1 -0
  99. package/dist/services/adminSql.d.ts +36 -0
  100. package/dist/services/adminSql.d.ts.map +1 -0
  101. package/dist/services/adminSql.js +172 -0
  102. package/dist/services/adminSql.js.map +1 -0
  103. package/dist/services/adminTelemetry.d.ts +56 -0
  104. package/dist/services/adminTelemetry.d.ts.map +1 -0
  105. package/dist/services/adminTelemetry.js +147 -0
  106. package/dist/services/adminTelemetry.js.map +1 -0
  107. package/dist/services/adminUser.d.ts +45 -1
  108. package/dist/services/adminUser.d.ts.map +1 -1
  109. package/dist/services/adminUser.js +53 -13
  110. package/dist/services/adminUser.js.map +1 -1
  111. package/dist/services/aiChatStream.d.ts +15 -0
  112. package/dist/services/aiChatStream.d.ts.map +1 -1
  113. package/dist/services/aiChatStream.js +144 -74
  114. package/dist/services/aiChatStream.js.map +1 -1
  115. package/dist/services/aiGeneration.d.ts +4 -0
  116. package/dist/services/aiGeneration.d.ts.map +1 -0
  117. package/dist/services/aiGeneration.js +88 -0
  118. package/dist/services/aiGeneration.js.map +1 -0
  119. package/dist/services/aiProposalTools.d.ts +4 -1
  120. package/dist/services/aiProposalTools.d.ts.map +1 -1
  121. package/dist/services/aiProposalTools.js +128 -3
  122. package/dist/services/aiProposalTools.js.map +1 -1
  123. package/dist/services/aiProposals.d.ts +70 -0
  124. package/dist/services/aiProposals.d.ts.map +1 -0
  125. package/dist/services/aiProposals.js +363 -0
  126. package/dist/services/aiProposals.js.map +1 -0
  127. package/dist/services/auth.d.ts.map +1 -1
  128. package/dist/services/auth.js +10 -4
  129. package/dist/services/auth.js.map +1 -1
  130. package/dist/services/conversation.d.ts +1 -0
  131. package/dist/services/conversation.d.ts.map +1 -1
  132. package/dist/services/fees.d.ts.map +1 -1
  133. package/dist/services/fees.js +7 -3
  134. package/dist/services/fees.js.map +1 -1
  135. package/dist/services/labChat.d.ts +10 -0
  136. package/dist/services/labChat.d.ts.map +1 -1
  137. package/dist/services/labChat.js +52 -3
  138. package/dist/services/labChat.js.map +1 -1
  139. package/dist/services/message.d.ts +24 -8
  140. package/dist/services/message.d.ts.map +1 -1
  141. package/dist/services/message.js +170 -117
  142. package/dist/services/message.js.map +1 -1
  143. package/dist/services/module.d.ts.map +1 -1
  144. package/dist/services/module.js +23 -4
  145. package/dist/services/module.js.map +1 -1
  146. package/dist/services/parentPortal.d.ts +14 -0
  147. package/dist/services/parentPortal.d.ts.map +1 -1
  148. package/dist/services/parentPortal.js +72 -2
  149. package/dist/services/parentPortal.js.map +1 -1
  150. package/dist/services/studentProgress.d.ts +50 -0
  151. package/dist/services/studentProgress.d.ts.map +1 -1
  152. package/dist/services/studentProgress.js +107 -2
  153. package/dist/services/studentProgress.js.map +1 -1
  154. package/dist/services/visualization.d.ts +72 -0
  155. package/dist/services/visualization.d.ts.map +1 -0
  156. package/dist/services/visualization.js +122 -0
  157. package/dist/services/visualization.js.map +1 -0
  158. package/dist/utils/inference.d.ts +10 -0
  159. package/dist/utils/inference.d.ts.map +1 -1
  160. package/dist/utils/inference.js +77 -2
  161. package/dist/utils/inference.js.map +1 -1
  162. package/dist/utils/sentryTrpc.d.ts +3 -0
  163. package/dist/utils/sentryTrpc.d.ts.map +1 -0
  164. package/dist/utils/sentryTrpc.js +26 -0
  165. package/dist/utils/sentryTrpc.js.map +1 -0
  166. package/dist/workers/processors/createRunJobWorker.js +17 -16
  167. package/dist/workers/processors/createRunJobWorker.js.map +1 -1
  168. package/package.json +1 -1
  169. package/prisma/migrations/20260811130000_add_ai_message_feedback/migration.sql +7 -0
  170. package/prisma/migrations/20261025120000_add_visualizations/migration.sql +27 -0
  171. package/prisma/schema.prisma +32 -0
  172. package/src/index.ts +30 -4
  173. package/src/instrument.ts +8 -2
  174. package/src/lib/fileUpload.ts +49 -0
  175. package/src/models/cloudStorage.ts +19 -0
  176. package/src/models/labChat.ts +34 -0
  177. package/src/pipelines/aiLabChat.ts +79 -18
  178. package/src/pipelines/aiLabChatContract.ts +35 -0
  179. package/src/pipelines/aiNewtonChat.ts +34 -2
  180. package/src/pipelines/generateVisualization.ts +107 -0
  181. package/src/pipelines/labChatPrompt.ts +11 -12
  182. package/src/routers/_app.ts +2 -0
  183. package/src/routers/admin.ts +72 -0
  184. package/src/routers/class.ts +14 -1
  185. package/src/routers/labChat.ts +5 -0
  186. package/src/routers/message.ts +34 -5
  187. package/src/routers/parent.ts +11 -0
  188. package/src/routers/studentProgress.ts +15 -0
  189. package/src/routers/visualization.ts +60 -0
  190. package/src/routes/aiStream.ts +32 -0
  191. package/src/seedDatabase.ts +107 -2
  192. package/src/services/adminClass.ts +222 -0
  193. package/src/services/adminInstitution.ts +9 -2
  194. package/src/services/adminMetrics.ts +35 -0
  195. package/src/services/adminSpam.ts +224 -0
  196. package/src/services/adminSql.ts +214 -0
  197. package/src/services/adminTelemetry.ts +194 -0
  198. package/src/services/adminUser.ts +66 -11
  199. package/src/services/aiChatStream.ts +83 -7
  200. package/src/services/aiGeneration.ts +92 -0
  201. package/src/services/aiProposalTools.ts +166 -1
  202. package/src/services/aiProposals.ts +428 -0
  203. package/src/services/auth.ts +8 -2
  204. package/src/services/fees.ts +5 -1
  205. package/src/services/labChat.ts +65 -0
  206. package/src/services/message.ts +205 -142
  207. package/src/services/module.ts +29 -2
  208. package/src/services/parentPortal.ts +85 -0
  209. package/src/services/studentProgress.ts +135 -0
  210. package/src/services/visualization.ts +147 -0
  211. package/src/utils/inference.ts +85 -0
  212. package/src/utils/sentryTrpc.ts +25 -0
  213. package/src/workers/processors/createRunJobWorker.ts +17 -17
  214. package/tests/unit/lib/fileUploadSession.test.ts +67 -0
  215. package/tests/unit/services/adminClass.test.ts +102 -0
  216. package/tests/unit/services/adminSpam.test.ts +108 -0
  217. package/tests/unit/services/adminSql.test.ts +56 -0
  218. package/tests/unit/services/adminTelemetry.test.ts +47 -0
  219. package/tests/unit/services/adminUser.test.ts +77 -0
  220. package/tests/unit/services/visualization.test.ts +130 -0
  221. package/tests/unit/utils/adminPanelAccess.test.ts +26 -0
  222. package/tests/unit/utils/sentryTrpc.test.ts +15 -0
@@ -0,0 +1,428 @@
1
+ /**
2
+ * AI creation proposals – defer-and-confirm flow for Lab chat.
3
+ *
4
+ * The lab pipeline records every `propose_*` tool call as a pending creation
5
+ * on the AI message's meta (`meta.pendingCreations`). Nothing is created at
6
+ * generation time: the teacher approves or dismisses each proposal from the
7
+ * chat, and approval creates the real entity server-side.
8
+ */
9
+ import { TRPCError } from "@trpc/server";
10
+ import { v4 } from "uuid";
11
+ import { prisma } from "../lib/prisma.js";
12
+ import { chatChannel, pusher } from "../lib/pusher.js";
13
+ import { logger } from "../utils/logger.js";
14
+ import {
15
+ findConversationMembership,
16
+ findMessageByIdMinimal,
17
+ messageFileSelect,
18
+ } from "../models/message.js";
19
+ import { createAssignmentRecord, updateAssignmentRecord } from "./assignment.js";
20
+ import {
21
+ createWorksheetRecord,
22
+ addQuestionToWorksheet,
23
+ updateWorksheetRecord,
24
+ } from "./worksheet.js";
25
+ import { createSectionRecord, updateSectionRecord } from "./section.js";
26
+ import type { StreamPart } from "./aiChatStream.js";
27
+ import type { LabProposals } from "./aiProposalTools.js";
28
+ import type {
29
+ AssignmentToCreate,
30
+ WorksheetToCreate,
31
+ SectionToCreate,
32
+ AssignmentUpdate,
33
+ WorksheetUpdate,
34
+ SectionUpdate,
35
+ } from "../pipelines/aiLabChatContract.js";
36
+
37
+ export type PendingCreationStatus = "pending" | "approved" | "dismissed";
38
+
39
+ export type PendingCreation =
40
+ | { id: string; kind: "assignment"; title: string; payload: AssignmentToCreate; status: PendingCreationStatus; createdId?: string }
41
+ | { id: string; kind: "worksheet"; title: string; payload: WorksheetToCreate; status: PendingCreationStatus; createdId?: string }
42
+ | { id: string; kind: "section"; title: string; payload: SectionToCreate; status: PendingCreationStatus; createdId?: string }
43
+ | { id: string; kind: "assignmentUpdate"; title: string; payload: AssignmentUpdate; status: PendingCreationStatus; createdId?: string }
44
+ | { id: string; kind: "worksheetUpdate"; title: string; payload: WorksheetUpdate; status: PendingCreationStatus; createdId?: string }
45
+ | { id: string; kind: "sectionUpdate"; title: string; payload: SectionUpdate; status: PendingCreationStatus; createdId?: string };
46
+
47
+ const PROPOSAL_TOOL_KIND: Record<string, PendingCreation["kind"]> = {
48
+ propose_assignment: "assignment",
49
+ propose_worksheet: "worksheet",
50
+ propose_section: "section",
51
+ propose_update_assignment: "assignmentUpdate",
52
+ propose_update_worksheet: "worksheetUpdate",
53
+ propose_update_section: "sectionUpdate",
54
+ };
55
+
56
+ /**
57
+ * Turn the stream's accumulated proposals into pending creations and stamp
58
+ * each `propose_*` tool part with its proposal id so the client can render
59
+ * the confirmation card inline at the tool-call position.
60
+ */
61
+ export function buildPendingCreations(
62
+ proposals: LabProposals,
63
+ parts: StreamPart[],
64
+ ): { pendingCreations: PendingCreation[]; parts: StreamPart[] } {
65
+ const pendingCreations: PendingCreation[] = [
66
+ ...proposals.assignments.map((payload): PendingCreation => ({
67
+ id: v4(), kind: "assignment", title: payload.title, payload, status: "pending",
68
+ })),
69
+ ...proposals.worksheets.map((payload): PendingCreation => ({
70
+ id: v4(), kind: "worksheet", title: payload.title, payload, status: "pending",
71
+ })),
72
+ ...proposals.sections.map((payload): PendingCreation => ({
73
+ id: v4(), kind: "section", title: payload.name, payload, status: "pending",
74
+ })),
75
+ ...proposals.assignmentUpdates.map((payload): PendingCreation => ({
76
+ id: v4(), kind: "assignmentUpdate", title: payload.targetTitle, payload, status: "pending",
77
+ })),
78
+ ...proposals.worksheetUpdates.map((payload): PendingCreation => ({
79
+ id: v4(), kind: "worksheetUpdate", title: payload.targetTitle, payload, status: "pending",
80
+ })),
81
+ ...proposals.sectionUpdates.map((payload): PendingCreation => ({
82
+ id: v4(), kind: "sectionUpdate", title: payload.targetTitle, payload, status: "pending",
83
+ })),
84
+ ];
85
+
86
+ const remaining = new Map<PendingCreation["kind"], PendingCreation[]>();
87
+ for (const creation of pendingCreations) {
88
+ const list = remaining.get(creation.kind) ?? [];
89
+ list.push(creation);
90
+ remaining.set(creation.kind, list);
91
+ }
92
+
93
+ const stamped = parts.map((part) => {
94
+ if (part.type !== "tool") return part;
95
+ const kind = PROPOSAL_TOOL_KIND[part.toolName];
96
+ if (!kind) return part;
97
+ const list = remaining.get(kind);
98
+ if (!list || list.length === 0) return part;
99
+ // Prefer a title match; fall back to stream order.
100
+ const idx = part.title
101
+ ? list.findIndex((c) => c.title.trim().toLowerCase() === part.title!.trim().toLowerCase())
102
+ : 0;
103
+ const creation = list.splice(idx >= 0 ? idx : 0, 1)[0];
104
+ return { ...part, proposalId: creation.id };
105
+ });
106
+
107
+ return { pendingCreations, parts: stamped };
108
+ }
109
+
110
+ const RESOLVE_MAX_RETRIES = 5;
111
+
112
+ /**
113
+ * Compare-and-swap a proposal's status inside message meta. Returns the new
114
+ * meta on success, or null when the proposal was not in `fromStatus`.
115
+ */
116
+ async function casProposalStatus(
117
+ messageId: string,
118
+ proposalId: string,
119
+ fromStatus: PendingCreationStatus,
120
+ patch: Partial<Pick<PendingCreation, "status" | "createdId">>,
121
+ ): Promise<Record<string, unknown> | null> {
122
+ for (let attempt = 0; attempt < RESOLVE_MAX_RETRIES; attempt++) {
123
+ const msg = await prisma.message.findUnique({
124
+ where: { id: messageId },
125
+ select: { meta: true },
126
+ });
127
+ if (!msg) {
128
+ throw new TRPCError({ code: "NOT_FOUND", message: "Message not found" });
129
+ }
130
+
131
+ const currentMeta = (msg.meta as Record<string, unknown>) ?? {};
132
+ const creations = Array.isArray(currentMeta.pendingCreations)
133
+ ? (currentMeta.pendingCreations as PendingCreation[])
134
+ : [];
135
+ const target = creations.find((c) => c.id === proposalId);
136
+ if (!target) {
137
+ throw new TRPCError({ code: "NOT_FOUND", message: "Proposal not found" });
138
+ }
139
+ if (target.status !== fromStatus) return null;
140
+
141
+ const newMeta = {
142
+ ...currentMeta,
143
+ pendingCreations: creations.map((c) =>
144
+ c.id === proposalId ? { ...c, ...patch } : c,
145
+ ),
146
+ };
147
+
148
+ const newMetaJson = JSON.stringify(newMeta);
149
+ const currentMetaJson = msg.meta === null ? null : JSON.stringify(msg.meta);
150
+ const updatedCount =
151
+ currentMetaJson === null
152
+ ? await prisma.$executeRaw`
153
+ UPDATE "Message" SET meta = ${newMetaJson}::jsonb
154
+ WHERE id = ${messageId} AND meta IS NULL
155
+ `
156
+ : await prisma.$executeRaw`
157
+ UPDATE "Message" SET meta = ${newMetaJson}::jsonb
158
+ WHERE id = ${messageId} AND meta = ${currentMetaJson}::jsonb
159
+ `;
160
+
161
+ if (Number(updatedCount) > 0) return newMeta;
162
+ }
163
+
164
+ throw new TRPCError({
165
+ code: "CONFLICT",
166
+ message: "Failed to update proposal after retries due to concurrent updates",
167
+ });
168
+ }
169
+
170
+ async function broadcastMessageMeta(messageId: string, conversationId: string) {
171
+ const updated = await prisma.message.findUnique({
172
+ where: { id: messageId },
173
+ include: {
174
+ sender: {
175
+ select: {
176
+ id: true,
177
+ username: true,
178
+ profile: { select: { displayName: true, profilePicture: true } },
179
+ },
180
+ },
181
+ attachments: { select: messageFileSelect },
182
+ mentions: { select: { userId: true } },
183
+ },
184
+ });
185
+ if (!updated) return;
186
+
187
+ try {
188
+ await pusher.trigger(chatChannel(conversationId), "message-updated", {
189
+ id: updated.id,
190
+ content: updated.content,
191
+ senderId: updated.senderId,
192
+ conversationId: updated.conversationId,
193
+ status: updated.status,
194
+ createdAt: updated.createdAt,
195
+ sender: updated.sender,
196
+ attachments: updated.attachments ?? [],
197
+ meta: updated.meta,
198
+ mentionedUserIds: updated.mentions.map((m) => m.userId),
199
+ });
200
+ } catch (error) {
201
+ logger.error("Failed to broadcast proposal resolution via Pusher", {
202
+ error,
203
+ messageId,
204
+ });
205
+ }
206
+ }
207
+
208
+ /** Create or update the proposed entity; returns the affected entity's id. */
209
+ async function createProposedEntity(
210
+ userId: string,
211
+ classId: string,
212
+ proposal: PendingCreation,
213
+ ): Promise<string> {
214
+ switch (proposal.kind) {
215
+ case "sectionUpdate": {
216
+ const payload = proposal.payload;
217
+ await updateSectionRecord(userId, {
218
+ id: payload.id,
219
+ classId,
220
+ name: payload.name,
221
+ color: payload.color ?? undefined,
222
+ });
223
+ return payload.id;
224
+ }
225
+ case "worksheetUpdate": {
226
+ const payload = proposal.payload;
227
+ if (payload.name) {
228
+ await updateWorksheetRecord(payload.id, userId, { name: payload.name });
229
+ }
230
+ for (const question of payload.addQuestions ?? []) {
231
+ await addQuestionToWorksheet(payload.id, userId, {
232
+ question: question.question,
233
+ answer: question.answer,
234
+ points: question.points,
235
+ options: question.options,
236
+ markScheme: question.markScheme,
237
+ type: question.type,
238
+ order: question.order,
239
+ });
240
+ }
241
+ return payload.id;
242
+ }
243
+ case "assignmentUpdate": {
244
+ const payload = proposal.payload;
245
+ await updateAssignmentRecord(userId, {
246
+ id: payload.id,
247
+ classId,
248
+ title: payload.title,
249
+ instructions: payload.instructions,
250
+ dueDate: payload.dueDate,
251
+ type: payload.type,
252
+ maxGrade: payload.maxGrade,
253
+ acceptFiles: payload.acceptFiles,
254
+ acceptExtendedResponse: payload.acceptExtendedResponse,
255
+ acceptWorksheet: payload.acceptWorksheet,
256
+ sectionId: payload.sectionId,
257
+ });
258
+ return payload.id;
259
+ }
260
+ case "section": {
261
+ const section = await createSectionRecord(userId, {
262
+ classId,
263
+ name: proposal.payload.name,
264
+ color: proposal.payload.color ?? undefined,
265
+ });
266
+ return section.id;
267
+ }
268
+ case "worksheet": {
269
+ const worksheet = await createWorksheetRecord(classId, proposal.payload.title);
270
+ for (const question of proposal.payload.questions) {
271
+ await addQuestionToWorksheet(worksheet.id, userId, {
272
+ question: question.question,
273
+ answer: question.answer,
274
+ points: question.points,
275
+ options: question.options,
276
+ markScheme: question.markScheme,
277
+ type: question.type,
278
+ order: question.order,
279
+ });
280
+ }
281
+ return worksheet.id;
282
+ }
283
+ case "assignment": {
284
+ const payload = proposal.payload;
285
+ const assignment = await createAssignmentRecord(userId, {
286
+ classId,
287
+ title: payload.title,
288
+ instructions: payload.instructions,
289
+ dueDate: payload.dueDate,
290
+ type: payload.type,
291
+ maxGrade: payload.maxGrade,
292
+ acceptFiles: payload.acceptFiles,
293
+ acceptExtendedResponse: payload.acceptExtendedResponse,
294
+ acceptWorksheet: payload.acceptWorksheet,
295
+ gradingBoundaryId: payload.gradingBoundaryId ?? undefined,
296
+ markSchemeId: payload.markSchemeId ?? undefined,
297
+ worksheetIds: payload.worksheetIds,
298
+ studentIds: payload.studentIds,
299
+ sectionId: payload.sectionId ?? undefined,
300
+ existingFileIds: payload.attachments.map((a) => a.id),
301
+ });
302
+ return assignment.id;
303
+ }
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Approve or dismiss a pending AI creation proposal. Approval creates the
309
+ * entity server-side; both outcomes persist to message meta and broadcast a
310
+ * `message-updated` event so every client (and reloads) see the final state.
311
+ */
312
+ export async function resolveAiProposal(
313
+ userId: string,
314
+ input: { messageId: string; proposalId: string; action: "approve" | "dismiss" },
315
+ ) {
316
+ const { messageId, proposalId, action } = input;
317
+
318
+ const message = await findMessageByIdMinimal(messageId);
319
+ if (!message) {
320
+ throw new TRPCError({ code: "NOT_FOUND", message: "Message not found" });
321
+ }
322
+
323
+ const membership = await findConversationMembership(
324
+ message.conversationId,
325
+ userId,
326
+ );
327
+ if (!membership) {
328
+ throw new TRPCError({
329
+ code: "FORBIDDEN",
330
+ message: "Not a member of this conversation",
331
+ });
332
+ }
333
+
334
+ const labChat = await prisma.labChat.findFirst({
335
+ where: { conversationId: message.conversationId },
336
+ select: { classId: true },
337
+ });
338
+ if (!labChat) {
339
+ throw new TRPCError({
340
+ code: "BAD_REQUEST",
341
+ message: "Proposals can only be resolved in lab chats",
342
+ });
343
+ }
344
+
345
+ const teaches = await prisma.class.findFirst({
346
+ where: { id: labChat.classId, teachers: { some: { id: userId } } },
347
+ select: { id: true },
348
+ });
349
+ if (!teaches) {
350
+ throw new TRPCError({
351
+ code: "FORBIDDEN",
352
+ message: "Only class teachers can resolve proposals",
353
+ });
354
+ }
355
+
356
+ const currentMeta = await prisma.message.findUnique({
357
+ where: { id: messageId },
358
+ select: { meta: true },
359
+ });
360
+ const creations = Array.isArray(
361
+ (currentMeta?.meta as Record<string, unknown> | null)?.pendingCreations,
362
+ )
363
+ ? ((currentMeta!.meta as Record<string, unknown>)
364
+ .pendingCreations as PendingCreation[])
365
+ : [];
366
+ const proposal = creations.find((c) => c.id === proposalId);
367
+ if (!proposal) {
368
+ throw new TRPCError({ code: "NOT_FOUND", message: "Proposal not found" });
369
+ }
370
+ if (proposal.status !== "pending") {
371
+ return {
372
+ status: proposal.status,
373
+ createdId: proposal.createdId ?? null,
374
+ };
375
+ }
376
+
377
+ if (action === "dismiss") {
378
+ const meta = await casProposalStatus(messageId, proposalId, "pending", {
379
+ status: "dismissed",
380
+ });
381
+ if (meta) {
382
+ await broadcastMessageMeta(messageId, message.conversationId);
383
+ }
384
+ return { status: "dismissed" as const, createdId: null };
385
+ }
386
+
387
+ // Approve: claim the proposal first so concurrent approvals create at most
388
+ // one entity, then create and record the created id (roll back on failure).
389
+ const claimed = await casProposalStatus(messageId, proposalId, "pending", {
390
+ status: "approved",
391
+ });
392
+ if (!claimed) {
393
+ const msg = await prisma.message.findUnique({
394
+ where: { id: messageId },
395
+ select: { meta: true },
396
+ });
397
+ const latest = (
398
+ ((msg?.meta as Record<string, unknown> | null)?.pendingCreations ??
399
+ []) as PendingCreation[]
400
+ ).find((c) => c.id === proposalId);
401
+ return {
402
+ status: latest?.status ?? "pending",
403
+ createdId: latest?.createdId ?? null,
404
+ };
405
+ }
406
+
407
+ try {
408
+ const createdId = await createProposedEntity(userId, labChat.classId, proposal);
409
+ await casProposalStatus(messageId, proposalId, "approved", { createdId });
410
+ await broadcastMessageMeta(messageId, message.conversationId);
411
+ return { status: "approved" as const, createdId };
412
+ } catch (error) {
413
+ logger.error("Failed to create approved AI proposal", {
414
+ error,
415
+ messageId,
416
+ proposalId,
417
+ kind: proposal.kind,
418
+ });
419
+ await casProposalStatus(messageId, proposalId, "approved", {
420
+ status: "pending",
421
+ }).catch(() => null);
422
+ await broadcastMessageMeta(messageId, message.conversationId);
423
+ throw new TRPCError({
424
+ code: "INTERNAL_SERVER_ERROR",
425
+ message: "Failed to create the approved item",
426
+ });
427
+ }
428
+ }
@@ -182,7 +182,10 @@ export async function resendVerificationEmail(email: string) {
182
182
  });
183
183
  }
184
184
 
185
- await deleteSessionsByUserId(user.id);
185
+ // Do NOT delete the user's sessions here — resending a verification email
186
+ // must never log the account out of its active devices. A previously issued
187
+ // verification token simply expires on its own; issuing a fresh one is
188
+ // enough.
186
189
 
187
190
  const verificationToken = await createSession({
188
191
  id: uuidv4(),
@@ -298,6 +301,9 @@ export async function resetPassword(token: string, password: string) {
298
301
  }
299
302
 
300
303
  await updateUserPassword(session.userId, await hash(password, 10));
301
- await deleteSessionById(token);
304
+ // Revoke every session (AUTH + any other reset/verification tokens), not
305
+ // just the consumed reset token, so a stolen bearer token can't outlive a
306
+ // password reset.
307
+ await deleteSessionsByUserId(session.userId);
302
308
  return { success: true };
303
309
  }
@@ -428,8 +428,12 @@ export async function getReceiptPdf(userId: string, paymentId: string) {
428
428
  },
429
429
  {
430
430
  label: "Paid at",
431
+ // Format in UTC rather than a hardcoded country timezone — Studious is
432
+ // used internationally, and this matches the UTC basis already used
433
+ // for invoice month labels. A per-school timezone setting is the
434
+ // proper long-term home for this (see docs/audits bug audit §2).
431
435
  value: new Intl.DateTimeFormat("en-CA", {
432
- timeZone: "Asia/Kathmandu",
436
+ timeZone: "UTC",
433
437
  year: "numeric",
434
438
  month: "2-digit",
435
439
  day: "2-digit",
@@ -11,6 +11,7 @@ import {
11
11
  findLabChatByMember,
12
12
  findLabChatByTeacher,
13
13
  findLabChatById,
14
+ findClassContextForStarterPrompts,
14
15
  findClassMembership,
15
16
  findLabChatsByClass,
16
17
  findLabChatForPost,
@@ -203,6 +204,70 @@ export async function getLabChat(userId: string, labChatId: string) {
203
204
  };
204
205
  }
205
206
 
207
+ const GENERIC_STARTER_PROMPTS = [
208
+ "Create a quiz on this week's topic",
209
+ "Draft a worksheet with 10 practice questions",
210
+ "Plan a lesson with objectives and activities",
211
+ "Suggest an assignment with a mark scheme",
212
+ ];
213
+
214
+ const MAX_STARTER_PROMPTS = 5;
215
+
216
+ /**
217
+ * Suggest starter prompts for a class's lab chat, derived from recent
218
+ * class context (upcoming due work, recent assignments, units, subject).
219
+ * Pure heuristics — no LLM call — so it stays cheap and fast. Falls back
220
+ * to generic defaults when the class has no usable context.
221
+ */
222
+ export async function getStarterPrompts(userId: string, classId: string) {
223
+ const classContext = await findClassContextForStarterPrompts(
224
+ classId,
225
+ userId
226
+ );
227
+ if (!classContext) {
228
+ throw new TRPCError({
229
+ code: "FORBIDDEN",
230
+ message: "Not a teacher in this class",
231
+ });
232
+ }
233
+
234
+ const prompts: string[] = [];
235
+ const now = new Date();
236
+
237
+ const published = classContext.assignments.filter((a) => !a.inProgress);
238
+ const upcoming = published
239
+ .filter((a) => a.dueDate > now)
240
+ .sort((a, b) => a.dueDate.getTime() - b.dueDate.getTime())[0];
241
+ if (upcoming) {
242
+ prompts.push(
243
+ `Create a worksheet to help students prepare for "${upcoming.title}"`
244
+ );
245
+ }
246
+
247
+ const recent = published.find((a) => a.title !== upcoming?.title);
248
+ if (recent) {
249
+ prompts.push(`Draft a quiz reviewing "${recent.title}"`);
250
+ }
251
+
252
+ const unit = classContext.sections[0];
253
+ if (unit) {
254
+ prompts.push(`Plan a lesson for the unit "${unit.name}"`);
255
+ }
256
+
257
+ if (classContext.subject) {
258
+ prompts.push(
259
+ `Suggest a ${classContext.subject} assignment with a mark scheme`
260
+ );
261
+ }
262
+
263
+ for (const generic of GENERIC_STARTER_PROMPTS) {
264
+ if (prompts.length >= 3) break;
265
+ if (!prompts.includes(generic)) prompts.push(generic);
266
+ }
267
+
268
+ return { prompts: prompts.slice(0, MAX_STARTER_PROMPTS) };
269
+ }
270
+
206
271
  export async function listLabChats(userId: string, classId: string) {
207
272
  const classMembership = await findClassMembership(classId, userId);
208
273
  if (!classMembership) {