@studious-lms/server 1.3.0 → 1.4.1

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 (77) hide show
  1. package/dist/models/class.d.ts +24 -2
  2. package/dist/models/class.d.ts.map +1 -1
  3. package/dist/models/class.js +180 -81
  4. package/dist/models/class.js.map +1 -1
  5. package/dist/models/worksheet.d.ts +34 -34
  6. package/dist/pipelines/aiLabChat.d.ts +61 -2
  7. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  8. package/dist/pipelines/aiLabChat.js +204 -172
  9. package/dist/pipelines/aiLabChat.js.map +1 -1
  10. package/dist/pipelines/aiLabChatContract.d.ts +413 -0
  11. package/dist/pipelines/aiLabChatContract.d.ts.map +1 -0
  12. package/dist/pipelines/aiLabChatContract.js +74 -0
  13. package/dist/pipelines/aiLabChatContract.js.map +1 -0
  14. package/dist/pipelines/gradeWorksheet.d.ts +4 -4
  15. package/dist/pipelines/labChatPrompt.d.ts +2 -0
  16. package/dist/pipelines/labChatPrompt.d.ts.map +1 -0
  17. package/dist/pipelines/labChatPrompt.js +72 -0
  18. package/dist/pipelines/labChatPrompt.js.map +1 -0
  19. package/dist/routers/_app.d.ts +284 -56
  20. package/dist/routers/_app.d.ts.map +1 -1
  21. package/dist/routers/_app.js +4 -2
  22. package/dist/routers/_app.js.map +1 -1
  23. package/dist/routers/class.d.ts +24 -3
  24. package/dist/routers/class.d.ts.map +1 -1
  25. package/dist/routers/class.js +3 -3
  26. package/dist/routers/class.js.map +1 -1
  27. package/dist/routers/labChat.d.ts +10 -1
  28. package/dist/routers/labChat.d.ts.map +1 -1
  29. package/dist/routers/labChat.js +6 -3
  30. package/dist/routers/labChat.js.map +1 -1
  31. package/dist/routers/message.d.ts +11 -0
  32. package/dist/routers/message.d.ts.map +1 -1
  33. package/dist/routers/message.js +10 -3
  34. package/dist/routers/message.js.map +1 -1
  35. package/dist/routers/studentProgress.d.ts +75 -0
  36. package/dist/routers/studentProgress.d.ts.map +1 -0
  37. package/dist/routers/studentProgress.js +33 -0
  38. package/dist/routers/studentProgress.js.map +1 -0
  39. package/dist/routers/worksheet.d.ts +24 -24
  40. package/dist/services/class.d.ts +24 -2
  41. package/dist/services/class.d.ts.map +1 -1
  42. package/dist/services/class.js +18 -6
  43. package/dist/services/class.js.map +1 -1
  44. package/dist/services/labChat.d.ts +5 -1
  45. package/dist/services/labChat.d.ts.map +1 -1
  46. package/dist/services/labChat.js +112 -4
  47. package/dist/services/labChat.js.map +1 -1
  48. package/dist/services/message.d.ts +8 -0
  49. package/dist/services/message.d.ts.map +1 -1
  50. package/dist/services/message.js +116 -2
  51. package/dist/services/message.js.map +1 -1
  52. package/dist/services/studentProgress.d.ts +45 -0
  53. package/dist/services/studentProgress.d.ts.map +1 -0
  54. package/dist/services/studentProgress.js +291 -0
  55. package/dist/services/studentProgress.js.map +1 -0
  56. package/dist/services/worksheet.d.ts +18 -18
  57. package/package.json +2 -2
  58. package/prisma/schema.prisma +1 -1
  59. package/sentry.properties +3 -0
  60. package/src/models/class.ts +189 -84
  61. package/src/pipelines/aiLabChat.ts +246 -184
  62. package/src/pipelines/aiLabChatContract.ts +75 -0
  63. package/src/pipelines/labChatPrompt.ts +68 -0
  64. package/src/routers/_app.ts +4 -2
  65. package/src/routers/class.ts +1 -1
  66. package/src/routers/labChat.ts +7 -0
  67. package/src/routers/message.ts +13 -0
  68. package/src/routers/studentProgress.ts +47 -0
  69. package/src/services/class.ts +14 -7
  70. package/src/services/labChat.ts +120 -5
  71. package/src/services/message.ts +142 -0
  72. package/src/services/studentProgress.ts +390 -0
  73. package/tests/lib/aiLabChatContract.test.ts +32 -0
  74. package/tests/pipelines/aiLabChat.test.ts +95 -0
  75. package/tests/routers/studentProgress.test.ts +283 -0
  76. package/tests/utils/aiLabChatPrompt.test.ts +18 -0
  77. package/vitest.unit.config.ts +7 -1
@@ -0,0 +1,68 @@
1
+ export const buildLabChatSystemPrompt = (context: string): string => `${context}
2
+
3
+ IMPORTANT INSTRUCTIONS:
4
+ - Use the context information above (subject, topic, difficulty, objectives, etc.) as your foundation
5
+ - A separate CLASS CONTEXT message lists this class's sections, mark schemes, grading boundaries, worksheets, files, and students with their database IDs
6
+ - Do NOT ask teachers about technical details (hex codes, format numbers, IDs, schema fields). Use sensible defaults yourself.
7
+ - Only ask clarifying questions about content or pedagogy (e.g., topic scope, difficulty, number of questions). Never ask "what hex color?" or "which format?"
8
+ - When creating content, make reasonable choices: pick nice default colors, use standard formatting. Teachers care about the content, not implementation.
9
+ - Only output final course materials when you have sufficient details about the content itself
10
+ - Do not use markdown in your responses - use plain text only
11
+ - You are primarily a chatbot - only provide docs/assignments when the teacher explicitly requests them
12
+ - If the request is vague, ask 1-2 high-level clarifying questions (topic, scope, style) - never technical ones
13
+
14
+ CRITICAL: REFERENCING OBJECTS - NAMES vs IDs:
15
+ - In "text": Refer to objects by NAME (e.g., "Unit 1", "Biology Rubric", "Cell_Structure_Worksheet")
16
+ - In "assignmentsToCreate", "worksheetsToCreate", "sectionsToCreate": Use DATABASE IDs from the CLASS CONTEXT
17
+ * sectionId, gradingBoundaryId, markSchemeId, worksheetIds, studentIds, attachments[].id must be real IDs from the context
18
+ * If the class has no sections/mark schemes/grading boundaries, use sectionsToCreate first, or omit optional IDs
19
+
20
+ RESPONSE FORMAT (JSON):
21
+ { "text": string, "docs": null | array, "worksheetsToCreate": array, "sectionsToCreate": array, "assignmentsToCreate": null | array }
22
+
23
+ CRITICAL ARRAY RULES:
24
+ - "worksheetsToCreate" must always be an array. Use [] when there are no worksheets to create.
25
+ - "sectionsToCreate" must always be an array. Use [] when there are no sections to create.
26
+ - Do not return null for "worksheetsToCreate" or "sectionsToCreate".
27
+
28
+ CRITICAL - "text" field rules:
29
+ - "text" must be a SHORT conversational summary (2-4 sentences). Plain text, no markdown.
30
+ - NEVER list assignment/worksheet fields in text (no "Type:", "dueDate:", "worksheetIds:", "sectionId:", etc.)
31
+ - NEVER dump schema or JSON-like output in text. The teacher sees the actual content in UI cards below.
32
+ - Good example: "I've created 4 assignments for Unit 1: Week 1 homework on the worksheet, Week 2 quiz, Week 3 lab activity, and Week 4 review test. You can create them below."
33
+ - Bad example: "Week 1 - Homework. Type: HOMEWORK. dueDate: 2026-03-10. worksheetIds: [...]" - NEVER do this.
34
+
35
+ - "docs": PDF documents when creating course materials (worksheets, handouts, answer keys)
36
+ - "worksheetsToCreate": Worksheets with questions when teacher wants structured assessments. Always return an array.
37
+ - "sectionsToCreate": New sections when the class has none or teacher wants new units. Always return an array.
38
+ - "assignmentsToCreate": Assignments when teacher explicitly requests them. Use IDs from CLASS CONTEXT. The structured data goes HERE only, not in text.
39
+
40
+ WHEN CREATING DOCUMENTS (docs):
41
+ - docs: [ { "title": string, "blocks": [ { "format": 0-12, "content": string | string[], "metadata"?: {...} } ] } ]
42
+ - Format: 0=H1, 1=H2, 2=H3, 3=H4, 4=H5, 5=H6, 6=PARAGRAPH, 7=BULLET, 8=NUMBERED, 9=TABLE, 10=IMAGE, 11=CODE_BLOCK, 12=QUOTE
43
+ - Bullets (7) and Numbered (8): content is array of strings; do NOT include * or 1. 2. 3. - renderer adds them
44
+ - Table (9) and Image (10) not supported - do not emit
45
+ - Colors: use sensible defaults (e.g. "#3B82F6" blue, "#10B981" green) - never ask the teacher
46
+
47
+ WHEN CREATING WORKSHEETS (worksheetsToCreate):
48
+ - Return an array every time, even when empty.
49
+ - Question types: MULTIPLE_CHOICE, TRUE_FALSE, SHORT_ANSWER, LONG_ANSWER, MATH_EXPRESSION, ESSAY
50
+ - For MULTIPLE_CHOICE/TRUE_FALSE: options array with { id, text, isCorrect }
51
+ - For others: options can be empty; answer holds the key
52
+ - markScheme: array of { id, points, description } for rubric items
53
+ - points: total points per question; order: display order
54
+
55
+ WHEN CREATING SECTIONS (sectionsToCreate):
56
+ - Return an array every time, even when empty.
57
+ - Use when class has no sections or teacher wants new units (e.g., "Unit 1", "Chapter 3")
58
+ - color: pick a nice default (e.g. "#3B82F6") - do not ask
59
+
60
+ WHEN CREATING ASSIGNMENTS (assignmentsToCreate):
61
+ - Put ALL assignment data (title, type, dueDate, instructions, worksheetIds, etc.) ONLY in assignmentsToCreate. The "text" field gets a brief friendly summary only.
62
+ - Use IDs from CLASS CONTEXT. If class has no sections, suggest sectionsToCreate first.
63
+ - type: HOMEWORK | QUIZ | TEST | PROJECT | ESSAY | DISCUSSION | PRESENTATION | LAB | OTHER
64
+ - sectionId, gradingBoundaryId, markSchemeId: use from context; omit if class has none (suggest creating first)
65
+ - studentIds: empty array = assign to all; otherwise list specific student IDs
66
+ - worksheetIds: IDs of existing worksheets; empty if using docs-only or new worksheets
67
+ - attachments[].id: file IDs from CLASS CONTEXT (PDFs, documents)
68
+ - acceptFiles, acceptExtendedResponse, acceptWorksheet: set based on assignment type`;
@@ -20,6 +20,7 @@ import { newtonChatRouter } from "./newtonChat.js";
20
20
  import { marketingRouter } from "./marketing.js";
21
21
  import { worksheetRouter } from "./worksheet.js";
22
22
  import { commentRouter } from "./comment.js";
23
+ import { studentProgressRouter } from "./studentProgress.js";
23
24
 
24
25
  export const appRouter = createTRPCRouter({
25
26
  class: classRouter,
@@ -41,7 +42,8 @@ export const appRouter = createTRPCRouter({
41
42
  marketing: marketingRouter,
42
43
  worksheet: worksheetRouter,
43
44
  comment: commentRouter,
44
- });
45
+ studentProgress: studentProgressRouter,
46
+ });
45
47
 
46
48
  // Export type router type definition
47
49
  export type AppRouter = typeof appRouter;
@@ -49,4 +51,4 @@ export type RouterInputs = inferRouterInputs<AppRouter>;
49
51
  export type RouterOutputs = inferRouterOutputs<AppRouter>;
50
52
 
51
53
  // Export caller
52
- export const createCaller = createCallerFactory(appRouter);
54
+ export const createCaller = createCallerFactory(appRouter);
@@ -318,7 +318,7 @@ export const classRouter = createTRPCRouter({
318
318
  .input(z.object({ classId: z.string() }))
319
319
  .mutation(({ ctx, input }) => exportClass(input.classId, ctx.user?.id ?? "")),
320
320
 
321
- importClass: protectedTeacherProcedure
321
+ importClass: protectedProcedure
322
322
  .input(
323
323
  z.object({
324
324
  classId: z.string(),
@@ -6,6 +6,7 @@ import {
6
6
  listLabChats,
7
7
  postToLabChat,
8
8
  deleteLabChat,
9
+ rerunLastResponse,
9
10
  } from "../services/labChat.js";
10
11
 
11
12
  export const labChatRouter = createTRPCRouter({
@@ -54,4 +55,10 @@ export const labChatRouter = createTRPCRouter({
54
55
  .mutation(({ ctx, input }) =>
55
56
  deleteLabChat(ctx.user!.id, input.labChatId)
56
57
  ),
58
+
59
+ rerunLastResponse: protectedProcedure
60
+ .input(z.object({ labChatId: z.string() }))
61
+ .mutation(({ ctx, input }) =>
62
+ rerunLastResponse(ctx.user!.id, input.labChatId)
63
+ ),
57
64
  });
@@ -7,6 +7,7 @@ import {
7
7
  deleteMessage,
8
8
  markAsRead,
9
9
  markMentionsAsRead,
10
+ markSuggestionCreated,
10
11
  getUnreadCount,
11
12
  } from "../services/message.js";
12
13
 
@@ -75,6 +76,18 @@ export const messageRouter = createTRPCRouter({
75
76
  markMentionsAsRead(ctx.user!.id, input.conversationId)
76
77
  ),
77
78
 
79
+ markSuggestionCreated: protectedProcedure
80
+ .input(
81
+ z.object({
82
+ messageId: z.string(),
83
+ type: z.enum(["assignment", "worksheet", "section"]),
84
+ index: z.number().int().min(0),
85
+ })
86
+ )
87
+ .mutation(({ ctx, input }) =>
88
+ markSuggestionCreated(ctx.user!.id, input)
89
+ ),
90
+
78
91
  getUnreadCount: protectedProcedure
79
92
  .input(z.object({ conversationId: z.string() }))
80
93
  .query(({ ctx, input }) =>
@@ -0,0 +1,47 @@
1
+ import { z } from "zod";
2
+ import { createTRPCRouter, protectedClassMemberProcedure } from "../trpc.js";
3
+ import {
4
+ chatAboutStudentProgress,
5
+ getStudentProgressRecommendations,
6
+ } from "../services/studentProgress.js";
7
+
8
+ const progressInputSchema = z.object({
9
+ classId: z.string(),
10
+ studentId: z.string(),
11
+ });
12
+
13
+ export const studentProgressRouter = createTRPCRouter({
14
+ getRecommendations: protectedClassMemberProcedure
15
+ .input(progressInputSchema)
16
+ .query(({ ctx, input }) =>
17
+ getStudentProgressRecommendations(
18
+ ctx.user!.id,
19
+ input.classId,
20
+ input.studentId,
21
+ ),
22
+ ),
23
+
24
+ chat: protectedClassMemberProcedure
25
+ .input(
26
+ progressInputSchema.extend({
27
+ message: z.string().min(1).max(4000),
28
+ history: z
29
+ .array(
30
+ z.object({
31
+ role: z.enum(["user", "assistant"]),
32
+ content: z.string().min(1).max(4000),
33
+ }),
34
+ )
35
+ .max(8)
36
+ .optional(),
37
+ }),
38
+ )
39
+ .mutation(({ ctx, input }) =>
40
+ chatAboutStudentProgress(ctx.user!.id, {
41
+ classId: input.classId,
42
+ studentId: input.studentId,
43
+ message: input.message,
44
+ history: input.history,
45
+ }),
46
+ ),
47
+ });
@@ -608,15 +608,22 @@ export async function exportClass(classId: string, userId: string) {
608
608
  }
609
609
 
610
610
  export async function importClass(classId: string, userId: string, year: number, classData: Class & { classFiles: Folder | null }) {
611
- const newClassId = await createClassByImport(classId, userId, year, classData);
612
-
613
-
614
- if (!newClassId) {
611
+ try {
612
+ const newClassId = await createClassByImport(classId, userId, year, classData);
613
+ if (!newClassId) {
614
+ throw new TRPCError({
615
+ code: "INTERNAL_SERVER_ERROR",
616
+ message: "Failed to import class",
617
+ });
618
+ }
619
+ return newClassId;
620
+ } catch (err) {
621
+ if (err instanceof TRPCError) throw err;
622
+ const message = err instanceof Error ? err.message : "Unknown error";
615
623
  throw new TRPCError({
616
624
  code: "INTERNAL_SERVER_ERROR",
617
- message: "Failed to import class",
625
+ message: `Failed to import class: ${message}`,
626
+ cause: err,
618
627
  });
619
628
  }
620
-
621
- return newClassId;
622
629
  }
@@ -3,6 +3,7 @@
3
3
  * trigger AI responses, and broadcast via Pusher.
4
4
  */
5
5
  import { TRPCError } from "@trpc/server";
6
+ import { GenerationStatus } from "@prisma/client";
6
7
  import { prisma } from "../lib/prisma.js";
7
8
  import { chatChannel, teacherChannel, pusher } from "../lib/pusher.js";
8
9
  import {
@@ -18,6 +19,7 @@ import {
18
19
  } from "../models/labChat.js";
19
20
  import { generateAndSendLabIntroduction, generateAndSendLabResponse } from "../pipelines/aiLabChat.js";
20
21
  import { isAIUser } from "../utils/aiUser.js";
22
+ import { logger } from "../utils/logger.js";
21
23
 
22
24
  /** Create a lab chat with conversation and teacher members. Broadcasts via Pusher. */
23
25
  export async function createLabChat(
@@ -166,7 +168,20 @@ export async function getLabChat(userId: string, labChatId: string) {
166
168
  });
167
169
  }
168
170
 
169
- return labChat;
171
+ const pendingMessage = await prisma.message.findFirst({
172
+ where: {
173
+ conversationId: labChat.conversationId,
174
+ status: GenerationStatus.PENDING,
175
+ senderId: { not: "AI_ASSISTANT" },
176
+ },
177
+ orderBy: { createdAt: "desc" },
178
+ select: { id: true },
179
+ });
180
+
181
+ return {
182
+ ...labChat,
183
+ pendingGenerationMessageId: pendingMessage?.id ?? null,
184
+ };
170
185
  }
171
186
 
172
187
  export async function listLabChats(userId: string, classId: string) {
@@ -229,6 +244,7 @@ export async function postToLabChat(
229
244
  content,
230
245
  senderId: userId,
231
246
  conversationId: labChat.conversationId,
247
+ ...(isAIUser(userId) ? {} : { status: GenerationStatus.PENDING }),
232
248
  },
233
249
  include: {
234
250
  sender: {
@@ -282,10 +298,25 @@ export async function postToLabChat(
282
298
  }
283
299
 
284
300
  if (!isAIUser(userId)) {
285
- generateAndSendLabResponse(
286
- labChatId,
287
- content,
288
- labChat.conversationId
301
+ try {
302
+ await pusher.trigger(teacherChannel(labChat.classId), "lab-response-pending", {
303
+ labChatId,
304
+ messageId: result.id,
305
+ conversationId: labChat.conversationId,
306
+ });
307
+ } catch (error) {
308
+ console.error("Failed to broadcast lab response pending:", error);
309
+ }
310
+ generateAndSendLabResponse(labChatId, content, {
311
+ classId: labChat.classId,
312
+ messageId: result.id,
313
+ }).catch((error) =>
314
+ logger.error("Failed to generate lab response", {
315
+ error,
316
+ labChatId,
317
+ messageId: result.id,
318
+ conversationId: labChat.conversationId,
319
+ })
289
320
  );
290
321
  }
291
322
 
@@ -350,3 +381,87 @@ export async function deleteLabChat(userId: string, labChatId: string) {
350
381
 
351
382
  return { success: true };
352
383
  }
384
+
385
+ /** Rerun the last AI response for a lab chat. Deletes the last AI message and regenerates. */
386
+ export async function rerunLastResponse(userId: string, labChatId: string) {
387
+ const labChat = await findLabChatForPost(labChatId, userId);
388
+ if (!labChat) {
389
+ throw new TRPCError({
390
+ code: "FORBIDDEN",
391
+ message: "Lab chat not found or access denied",
392
+ });
393
+ }
394
+
395
+ const messages = await prisma.message.findMany({
396
+ where: { conversationId: labChat.conversationId },
397
+ orderBy: { createdAt: "desc" },
398
+ take: 10,
399
+ select: { id: true, content: true, senderId: true, createdAt: true },
400
+ });
401
+
402
+ const lastMessage = messages[0];
403
+ if (!lastMessage) {
404
+ throw new TRPCError({
405
+ code: "BAD_REQUEST",
406
+ message: "No messages to rerun",
407
+ });
408
+ }
409
+
410
+ if (!isAIUser(lastMessage.senderId)) {
411
+ throw new TRPCError({
412
+ code: "BAD_REQUEST",
413
+ message: "Last message is not from AI – nothing to rerun",
414
+ });
415
+ }
416
+
417
+ const teacherMessage = messages.find((m) => !isAIUser(m.senderId));
418
+ if (!teacherMessage) {
419
+ throw new TRPCError({
420
+ code: "BAD_REQUEST",
421
+ message: "No teacher message found to rerun from",
422
+ });
423
+ }
424
+
425
+ await prisma.$transaction([
426
+ prisma.message.delete({
427
+ where: { id: lastMessage.id },
428
+ }),
429
+ prisma.message.update({
430
+ where: { id: teacherMessage.id },
431
+ data: { status: GenerationStatus.PENDING },
432
+ }),
433
+ ]);
434
+
435
+ try {
436
+ await pusher.trigger(chatChannel(labChat.conversationId), "message-deleted", {
437
+ messageId: lastMessage.id,
438
+ conversationId: labChat.conversationId,
439
+ senderId: lastMessage.senderId,
440
+ });
441
+ } catch (error) {
442
+ console.error("Failed to broadcast message deletion:", error);
443
+ }
444
+ try {
445
+ await pusher.trigger(teacherChannel(labChat.classId), "lab-response-pending", {
446
+ labChatId,
447
+ messageId: teacherMessage.id,
448
+ conversationId: labChat.conversationId,
449
+ });
450
+ } catch (error) {
451
+ console.error("Failed to broadcast lab response pending:", error);
452
+ }
453
+
454
+ generateAndSendLabResponse(labChatId, teacherMessage.content, {
455
+ classId: labChat.classId,
456
+ messageId: teacherMessage.id,
457
+ }).catch((error) =>
458
+ logger.error("Failed to generate lab response", {
459
+ error,
460
+ labChatId,
461
+ messageId: teacherMessage.id,
462
+ conversationId: labChat.conversationId,
463
+ })
464
+ );
465
+
466
+ return { success: true };
467
+ }
@@ -357,6 +357,148 @@ export async function deleteMessage(userId: string, messageId: string) {
357
357
  return { success: true, messageId };
358
358
  }
359
359
 
360
+ const CREATED_INDICES_KEY: Record<"assignment" | "worksheet" | "section", string> = {
361
+ assignment: "assignments",
362
+ worksheet: "worksheets",
363
+ section: "sections",
364
+ };
365
+
366
+ const MARK_SUGGESTION_MAX_RETRIES = 5;
367
+
368
+ /** Mark an AI-suggested item as created. Stores in message meta for fast reads. */
369
+ export async function markSuggestionCreated(
370
+ userId: string,
371
+ input: {
372
+ messageId: string;
373
+ type: "assignment" | "worksheet" | "section";
374
+ index: number;
375
+ }
376
+ ) {
377
+ const { messageId, type, index } = input;
378
+
379
+ const existingMessage = await findMessageByIdMinimal(messageId);
380
+ if (!existingMessage) {
381
+ throw new TRPCError({
382
+ code: "NOT_FOUND",
383
+ message: "Message not found",
384
+ });
385
+ }
386
+
387
+ const membership = await findConversationMembership(
388
+ existingMessage.conversationId,
389
+ userId
390
+ );
391
+ if (!membership) {
392
+ throw new TRPCError({
393
+ code: "FORBIDDEN",
394
+ message: "Not a member of this conversation",
395
+ });
396
+ }
397
+
398
+ const conversationId = existingMessage.conversationId;
399
+ let lastError: Error | null = null;
400
+
401
+ for (let attempt = 0; attempt < MARK_SUGGESTION_MAX_RETRIES; attempt++) {
402
+ const msg = await prisma.message.findUnique({
403
+ where: { id: messageId },
404
+ select: { meta: true },
405
+ });
406
+ if (!msg) {
407
+ throw new TRPCError({
408
+ code: "NOT_FOUND",
409
+ message: "Message not found",
410
+ });
411
+ }
412
+
413
+ const currentMeta = (msg.meta as Record<string, unknown>) ?? {};
414
+ const createdIndices = (currentMeta.createdIndices as Record<string, number[]>) ?? {};
415
+ const typeIndices = createdIndices[CREATED_INDICES_KEY[type]] ?? [];
416
+ if (typeIndices.includes(index)) return { success: true };
417
+
418
+ const newTypeIndices = [...typeIndices, index].sort((a, b) => a - b);
419
+ const newMeta = {
420
+ ...currentMeta,
421
+ createdIndices: {
422
+ ...createdIndices,
423
+ [CREATED_INDICES_KEY[type]]: newTypeIndices,
424
+ },
425
+ };
426
+
427
+ const newMetaJson = JSON.stringify(newMeta);
428
+ const currentMetaJson = msg.meta === null ? null : JSON.stringify(msg.meta);
429
+
430
+ const updatedCount =
431
+ currentMetaJson === null
432
+ ? await prisma.$executeRaw`
433
+ UPDATE "Message" SET meta = ${newMetaJson}::jsonb
434
+ WHERE id = ${messageId} AND meta IS NULL
435
+ `
436
+ : await prisma.$executeRaw`
437
+ UPDATE "Message" SET meta = ${newMetaJson}::jsonb
438
+ WHERE id = ${messageId} AND meta = ${currentMetaJson}::jsonb
439
+ `;
440
+
441
+ if (Number(updatedCount) > 0) {
442
+ const updated = await prisma.message.findUnique({
443
+ where: { id: messageId },
444
+ include: {
445
+ sender: {
446
+ select: {
447
+ id: true,
448
+ username: true,
449
+ profile: {
450
+ select: { displayName: true, profilePicture: true },
451
+ },
452
+ },
453
+ },
454
+ attachments: { select: { id: true, name: true, type: true } },
455
+ mentions: { select: { userId: true } },
456
+ },
457
+ });
458
+
459
+ if (updated) {
460
+ const mentionedUserIds = updated.mentions.map((m) => m.userId);
461
+ try {
462
+ await pusher.trigger(
463
+ chatChannel(conversationId),
464
+ "message-updated",
465
+ {
466
+ id: updated.id,
467
+ content: updated.content,
468
+ senderId: updated.senderId,
469
+ conversationId: updated.conversationId,
470
+ createdAt: updated.createdAt,
471
+ sender: updated.sender,
472
+ attachments: updated.attachments ?? [],
473
+ meta: newMeta,
474
+ mentionedUserIds,
475
+ }
476
+ );
477
+ } catch (error) {
478
+ logger.error("Failed to broadcast suggestion status via Pusher", {
479
+ error,
480
+ messageId,
481
+ });
482
+ }
483
+ }
484
+ return { success: true };
485
+ }
486
+
487
+ lastError = new Error("Concurrent update conflict");
488
+ }
489
+
490
+ logger.error("markSuggestionCreated failed after retries", {
491
+ messageId,
492
+ type,
493
+ index,
494
+ error: lastError,
495
+ });
496
+ throw new TRPCError({
497
+ code: "CONFLICT",
498
+ message: "Failed to update suggestion status after retries due to concurrent updates",
499
+ });
500
+ }
501
+
360
502
  export async function markAsRead(userId: string, conversationId: string) {
361
503
  const membership = await findConversationMembership(conversationId, userId);
362
504
  if (!membership) {