@studious-lms/server 1.19.0 → 1.20.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 (151) hide show
  1. package/dist/index.js +8 -2
  2. package/dist/index.js.map +1 -1
  3. package/dist/lib/pusher.d.ts +3 -1
  4. package/dist/lib/pusher.d.ts.map +1 -1
  5. package/dist/lib/pusher.js +5 -3
  6. package/dist/lib/pusher.js.map +1 -1
  7. package/dist/middleware/security.d.ts.map +1 -1
  8. package/dist/middleware/security.js +4 -4
  9. package/dist/middleware/security.js.map +1 -1
  10. package/dist/models/class.d.ts +1 -1
  11. package/dist/models/class.d.ts.map +1 -1
  12. package/dist/models/class.js +5 -4
  13. package/dist/models/class.js.map +1 -1
  14. package/dist/models/conversation.d.ts +17 -17
  15. package/dist/models/notification.d.ts +63 -11
  16. package/dist/models/notification.d.ts.map +1 -1
  17. package/dist/models/notification.js +24 -10
  18. package/dist/models/notification.js.map +1 -1
  19. package/dist/pipelines/aiLabChat.d.ts +1 -0
  20. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  21. package/dist/pipelines/aiLabChat.js +7 -2
  22. package/dist/pipelines/aiLabChat.js.map +1 -1
  23. package/dist/pipelines/aiNewtonChat.d.ts +2 -2
  24. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  25. package/dist/pipelines/aiNewtonChat.js +12 -4
  26. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  27. package/dist/pipelines/gradeWorksheet.d.ts +3 -3
  28. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  29. package/dist/pipelines/gradeWorksheet.js +13 -9
  30. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  31. package/dist/pipelines/pdfToWorksheet.d.ts +2 -0
  32. package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -1
  33. package/dist/pipelines/pdfToWorksheet.js +3 -2
  34. package/dist/pipelines/pdfToWorksheet.js.map +1 -1
  35. package/dist/routers/_app.d.ts +174 -82
  36. package/dist/routers/_app.d.ts.map +1 -1
  37. package/dist/routers/announcement.d.ts.map +1 -1
  38. package/dist/routers/announcement.js +2 -2
  39. package/dist/routers/announcement.js.map +1 -1
  40. package/dist/routers/assignment.d.ts.map +1 -1
  41. package/dist/routers/assignment.js +12 -2
  42. package/dist/routers/assignment.js.map +1 -1
  43. package/dist/routers/notifications.d.ts +51 -41
  44. package/dist/routers/notifications.d.ts.map +1 -1
  45. package/dist/routers/notifications.js +22 -18
  46. package/dist/routers/notifications.js.map +1 -1
  47. package/dist/routers/school.d.ts +30 -0
  48. package/dist/routers/school.d.ts.map +1 -1
  49. package/dist/routers/school.js +11 -2
  50. package/dist/routers/school.js.map +1 -1
  51. package/dist/routers/user.d.ts +12 -0
  52. package/dist/routers/user.d.ts.map +1 -1
  53. package/dist/routers/user.js +11 -2
  54. package/dist/routers/user.js.map +1 -1
  55. package/dist/seedDatabase.d.ts +4 -0
  56. package/dist/seedDatabase.d.ts.map +1 -1
  57. package/dist/services/aiChatStream.d.ts +5 -0
  58. package/dist/services/aiChatStream.d.ts.map +1 -1
  59. package/dist/services/aiChatStream.js +32 -3
  60. package/dist/services/aiChatStream.js.map +1 -1
  61. package/dist/services/aiStream.d.ts +5 -0
  62. package/dist/services/aiStream.d.ts.map +1 -1
  63. package/dist/services/aiStream.js +29 -3
  64. package/dist/services/aiStream.js.map +1 -1
  65. package/dist/services/aiUsage.d.ts +72 -0
  66. package/dist/services/aiUsage.d.ts.map +1 -0
  67. package/dist/services/aiUsage.js +150 -0
  68. package/dist/services/aiUsage.js.map +1 -0
  69. package/dist/services/announcement.d.ts.map +1 -1
  70. package/dist/services/announcement.js +7 -2
  71. package/dist/services/announcement.js.map +1 -1
  72. package/dist/services/assignment.d.ts.map +1 -1
  73. package/dist/services/assignment.js +29 -4
  74. package/dist/services/assignment.js.map +1 -1
  75. package/dist/services/attendance.js +10 -2
  76. package/dist/services/attendance.js.map +1 -1
  77. package/dist/services/class.d.ts.map +1 -1
  78. package/dist/services/class.js +6 -2
  79. package/dist/services/class.js.map +1 -1
  80. package/dist/services/discussion.d.ts.map +1 -1
  81. package/dist/services/discussion.js +41 -12
  82. package/dist/services/discussion.js.map +1 -1
  83. package/dist/services/discussionSummary.d.ts.map +1 -1
  84. package/dist/services/discussionSummary.js +3 -2
  85. package/dist/services/discussionSummary.js.map +1 -1
  86. package/dist/services/notification.d.ts +79 -16
  87. package/dist/services/notification.d.ts.map +1 -1
  88. package/dist/services/notification.js +64 -16
  89. package/dist/services/notification.js.map +1 -1
  90. package/dist/services/studentProgress.d.ts.map +1 -1
  91. package/dist/services/studentProgress.js +6 -2
  92. package/dist/services/studentProgress.js.map +1 -1
  93. package/dist/services/userPrivacy.d.ts +4 -0
  94. package/dist/services/userPrivacy.d.ts.map +1 -1
  95. package/dist/services/userPrivacy.js +6 -2
  96. package/dist/services/userPrivacy.js.map +1 -1
  97. package/dist/services/worksheet.js +4 -4
  98. package/dist/services/worksheet.js.map +1 -1
  99. package/dist/utils/inference.d.ts +27 -0
  100. package/dist/utils/inference.d.ts.map +1 -1
  101. package/dist/utils/inference.js +23 -3
  102. package/dist/utils/inference.js.map +1 -1
  103. package/dist/workers/processors/aiLabChat.d.ts.map +1 -1
  104. package/dist/workers/processors/aiLabChat.js +4 -3
  105. package/dist/workers/processors/aiLabChat.js.map +1 -1
  106. package/dist/workers/processors/aiNewtonChat.d.ts.map +1 -1
  107. package/dist/workers/processors/aiNewtonChat.js +5 -5
  108. package/dist/workers/processors/aiNewtonChat.js.map +1 -1
  109. package/dist/workers/processors/gradeWorksheet.js +4 -4
  110. package/dist/workers/processors/gradeWorksheet.js.map +1 -1
  111. package/dist/workers/processors/pdfToWorksheet.d.ts.map +1 -1
  112. package/dist/workers/processors/pdfToWorksheet.js +7 -4
  113. package/dist/workers/processors/pdfToWorksheet.js.map +1 -1
  114. package/package.json +1 -1
  115. package/prisma/migrations/20260708011500_add_ai_usage_attribution/migration.sql +13 -0
  116. package/prisma/migrations/20260708013000_add_notification_type_and_entity/migration.sql +14 -0
  117. package/prisma/schema.prisma +29 -5
  118. package/src/index.ts +7 -0
  119. package/src/lib/pusher.ts +3 -1
  120. package/src/middleware/security.ts +2 -2
  121. package/src/models/class.ts +3 -2
  122. package/src/models/notification.ts +42 -13
  123. package/src/pipelines/aiLabChat.ts +6 -1
  124. package/src/pipelines/aiNewtonChat.ts +10 -0
  125. package/src/pipelines/gradeWorksheet.ts +25 -7
  126. package/src/pipelines/pdfToWorksheet.ts +3 -0
  127. package/src/routers/announcement.ts +0 -1
  128. package/src/routers/assignment.ts +10 -0
  129. package/src/routers/notifications.ts +26 -24
  130. package/src/routers/school.ts +12 -0
  131. package/src/routers/user.ts +12 -0
  132. package/src/services/aiChatStream.ts +35 -0
  133. package/src/services/aiStream.ts +34 -0
  134. package/src/services/aiUsage.ts +202 -0
  135. package/src/services/announcement.ts +5 -0
  136. package/src/services/assignment.ts +29 -2
  137. package/src/services/attendance.ts +12 -0
  138. package/src/services/class.ts +4 -0
  139. package/src/services/discussion.ts +50 -11
  140. package/src/services/discussionSummary.ts +1 -0
  141. package/src/services/notification.ts +83 -20
  142. package/src/services/studentProgress.ts +4 -0
  143. package/src/services/userPrivacy.ts +4 -0
  144. package/src/services/worksheet.ts +2 -2
  145. package/src/utils/inference.ts +40 -2
  146. package/src/workers/processors/aiLabChat.ts +2 -1
  147. package/src/workers/processors/aiNewtonChat.ts +3 -1
  148. package/src/workers/processors/gradeWorksheet.ts +2 -2
  149. package/src/workers/processors/pdfToWorksheet.ts +5 -2
  150. package/tests/integration/routers/notification.test.ts +68 -20
  151. package/tests/unit/services/aiUsage.test.ts +105 -0
@@ -1,5 +1,5 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="1acdee19-3e17-56ee-ab44-55001b9460f2")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="777a2189-86f3-5317-9904-cac13606cce2")}catch(e){}}();
3
3
  import { gradeWorksheetPipeline } from '../../pipelines/gradeWorksheet.js';
4
4
  import { createRunJobWorker } from './createRunJobWorker.js';
5
5
  export const gradeWorksheetWorker = createRunJobWorker({
@@ -10,10 +10,10 @@ export const gradeWorksheetWorker = createRunJobWorker({
10
10
  // throughput modest until we measure real-world usage.
11
11
  limiter: { max: 5, duration: 60000 },
12
12
  },
13
- run: async ({ worksheetResponseId }) => {
14
- await gradeWorksheetPipeline(worksheetResponseId);
13
+ run: async ({ worksheetResponseId }, ctx) => {
14
+ await gradeWorksheetPipeline(worksheetResponseId, ctx.userId);
15
15
  return { worksheetResponseId };
16
16
  },
17
17
  });
18
18
  //# sourceMappingURL=gradeWorksheet.js.map
19
- //# debugId=1acdee19-3e17-56ee-ab44-55001b9460f2
19
+ //# debugId=777a2189-86f3-5317-9904-cac13606cce2
@@ -1 +1 @@
1
- {"version":3,"file":"gradeWorksheet.js","sources":["workers/processors/gradeWorksheet.ts"],"sourceRoot":"/","sourcesContent":["import { gradeWorksheetPipeline } from '../../pipelines/gradeWorksheet.js';\nimport { createRunJobWorker } from './createRunJobWorker.js';\n\nexport const gradeWorksheetWorker = createRunJobWorker({\n type: 'GRADE_WORKSHEET',\n workerOptions: {\n concurrency: 2,\n // Each pipeline run can fan out to many per-question inferences; keep\n // throughput modest until we measure real-world usage.\n limiter: { max: 5, duration: 60_000 },\n },\n run: async ({ worksheetResponseId }) => {\n await gradeWorksheetPipeline(worksheetResponseId);\n return { worksheetResponseId };\n },\n});\n"],"names":[],"mappings":";;AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,mCAAmC,CAAC;AAC3E,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAE7D,MAAM,CAAC,MAAM,oBAAoB,GAAG,kBAAkB,CAAC;IACrD,IAAI,EAAE,iBAAiB;IACvB,aAAa,EAAE;QACb,WAAW,EAAE,CAAC;QACd,sEAAsE;QACtE,uDAAuD;QACvD,OAAO,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,QAAQ,EAAE,KAAM,EAAE;KACtC;IACD,GAAG,EAAE,KAAK,EAAE,EAAE,mBAAmB,EAAE,EAAE,EAAE;QACrC,MAAM,sBAAsB,CAAC,mBAAmB,CAAC,CAAC;QAClD,OAAO,EAAE,mBAAmB,EAAE,CAAC;IACjC,CAAC;CACF,CAAC,CAAC","debug_id":"1acdee19-3e17-56ee-ab44-55001b9460f2"}
1
+ {"version":3,"file":"gradeWorksheet.js","sources":["workers/processors/gradeWorksheet.ts"],"sourceRoot":"/","sourcesContent":["import { gradeWorksheetPipeline } from '../../pipelines/gradeWorksheet.js';\nimport { createRunJobWorker } from './createRunJobWorker.js';\n\nexport const gradeWorksheetWorker = createRunJobWorker({\n type: 'GRADE_WORKSHEET',\n workerOptions: {\n concurrency: 2,\n // Each pipeline run can fan out to many per-question inferences; keep\n // throughput modest until we measure real-world usage.\n limiter: { max: 5, duration: 60_000 },\n },\n run: async ({ worksheetResponseId }, ctx) => {\n await gradeWorksheetPipeline(worksheetResponseId, ctx.userId);\n return { worksheetResponseId };\n },\n});\n"],"names":[],"mappings":";;AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,mCAAmC,CAAC;AAC3E,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAE7D,MAAM,CAAC,MAAM,oBAAoB,GAAG,kBAAkB,CAAC;IACrD,IAAI,EAAE,iBAAiB;IACvB,aAAa,EAAE;QACb,WAAW,EAAE,CAAC;QACd,sEAAsE;QACtE,uDAAuD;QACvD,OAAO,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,QAAQ,EAAE,KAAM,EAAE;KACtC;IACD,GAAG,EAAE,KAAK,EAAE,EAAE,mBAAmB,EAAE,EAAE,GAAG,EAAE,EAAE;QAC1C,MAAM,sBAAsB,CAAC,mBAAmB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;QAC9D,OAAO,EAAE,mBAAmB,EAAE,CAAC;IACjC,CAAC;CACF,CAAC,CAAC","debug_id":"777a2189-86f3-5317-9904-cac13606cce2"}
@@ -1 +1 @@
1
- {"version":3,"file":"pdfToWorksheet.d.ts","sourceRoot":"/","sources":["workers/processors/pdfToWorksheet.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,oBAAoB;;;;;;eAa/B,CAAC"}
1
+ {"version":3,"file":"pdfToWorksheet.d.ts","sourceRoot":"/","sources":["workers/processors/pdfToWorksheet.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,oBAAoB;;;;;;eAgB/B,CAAC"}
@@ -1,5 +1,5 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="3beb4788-6c75-5aae-b6c1-3195cbdeffa3")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="83752754-be47-54a0-b136-1b4140250056")}catch(e){}}();
3
3
  import { pdfToWorksheet } from '../../pipelines/pdfToWorksheet.js';
4
4
  import { createRunJobWorker } from './createRunJobWorker.js';
5
5
  export const pdfToWorksheetWorker = createRunJobWorker({
@@ -9,12 +9,15 @@ export const pdfToWorksheetWorker = createRunJobWorker({
9
9
  // 5 jobs / minute, e.g. to stay under OpenAI rate limits.
10
10
  limiter: { max: 5, duration: 60000 },
11
11
  },
12
- run: async ({ gcsUrl, classId, worksheetId }) => {
13
- const worksheet = await pdfToWorksheet(gcsUrl, classId, { worksheetId });
12
+ run: async ({ gcsUrl, classId, worksheetId }, ctx) => {
13
+ const worksheet = await pdfToWorksheet(gcsUrl, classId, {
14
+ worksheetId,
15
+ userId: ctx.userId,
16
+ });
14
17
  return worksheet
15
18
  ? { worksheetId: worksheet.id, name: worksheet.name }
16
19
  : null;
17
20
  },
18
21
  });
19
22
  //# sourceMappingURL=pdfToWorksheet.js.map
20
- //# debugId=3beb4788-6c75-5aae-b6c1-3195cbdeffa3
23
+ //# debugId=83752754-be47-54a0-b136-1b4140250056
@@ -1 +1 @@
1
- {"version":3,"file":"pdfToWorksheet.js","sources":["workers/processors/pdfToWorksheet.ts"],"sourceRoot":"/","sourcesContent":["import { pdfToWorksheet } from '../../pipelines/pdfToWorksheet.js';\nimport { createRunJobWorker } from './createRunJobWorker.js';\n\nexport const pdfToWorksheetWorker = createRunJobWorker({\n type: 'PDF_TO_WORKSHEET',\n workerOptions: {\n concurrency: 2,\n // 5 jobs / minute, e.g. to stay under OpenAI rate limits.\n limiter: { max: 5, duration: 60_000 },\n },\n run: async ({ gcsUrl, classId, worksheetId }) => {\n const worksheet = await pdfToWorksheet(gcsUrl, classId, { worksheetId });\n return worksheet\n ? { worksheetId: worksheet.id, name: worksheet.name }\n : null;\n },\n});\n"],"names":[],"mappings":";;AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,mCAAmC,CAAC;AACnE,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAE7D,MAAM,CAAC,MAAM,oBAAoB,GAAG,kBAAkB,CAAC;IACrD,IAAI,EAAE,kBAAkB;IACxB,aAAa,EAAE;QACb,WAAW,EAAE,CAAC;QACd,0DAA0D;QAC1D,OAAO,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,QAAQ,EAAE,KAAM,EAAE;KACtC;IACD,GAAG,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,EAAE,EAAE;QAC9C,MAAM,SAAS,GAAG,MAAM,cAAc,CAAC,MAAM,EAAE,OAAO,EAAE,EAAE,WAAW,EAAE,CAAC,CAAC;QACzE,OAAO,SAAS;YACd,CAAC,CAAC,EAAE,WAAW,EAAE,SAAS,CAAC,EAAE,EAAE,IAAI,EAAE,SAAS,CAAC,IAAI,EAAE;YACrD,CAAC,CAAC,IAAI,CAAC;IACX,CAAC;CACF,CAAC,CAAC","debug_id":"3beb4788-6c75-5aae-b6c1-3195cbdeffa3"}
1
+ {"version":3,"file":"pdfToWorksheet.js","sources":["workers/processors/pdfToWorksheet.ts"],"sourceRoot":"/","sourcesContent":["import { pdfToWorksheet } from '../../pipelines/pdfToWorksheet.js';\nimport { createRunJobWorker } from './createRunJobWorker.js';\n\nexport const pdfToWorksheetWorker = createRunJobWorker({\n type: 'PDF_TO_WORKSHEET',\n workerOptions: {\n concurrency: 2,\n // 5 jobs / minute, e.g. to stay under OpenAI rate limits.\n limiter: { max: 5, duration: 60_000 },\n },\n run: async ({ gcsUrl, classId, worksheetId }, ctx) => {\n const worksheet = await pdfToWorksheet(gcsUrl, classId, {\n worksheetId,\n userId: ctx.userId,\n });\n return worksheet\n ? { worksheetId: worksheet.id, name: worksheet.name }\n : null;\n },\n});\n"],"names":[],"mappings":";;AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,mCAAmC,CAAC;AACnE,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAE7D,MAAM,CAAC,MAAM,oBAAoB,GAAG,kBAAkB,CAAC;IACrD,IAAI,EAAE,kBAAkB;IACxB,aAAa,EAAE;QACb,WAAW,EAAE,CAAC;QACd,0DAA0D;QAC1D,OAAO,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,QAAQ,EAAE,KAAM,EAAE;KACtC;IACD,GAAG,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,EAAE,GAAG,EAAE,EAAE;QACnD,MAAM,SAAS,GAAG,MAAM,cAAc,CAAC,MAAM,EAAE,OAAO,EAAE;YACtD,WAAW;YACX,MAAM,EAAE,GAAG,CAAC,MAAM;SACnB,CAAC,CAAC;QACH,OAAO,SAAS;YACd,CAAC,CAAC,EAAE,WAAW,EAAE,SAAS,CAAC,EAAE,EAAE,IAAI,EAAE,SAAS,CAAC,IAAI,EAAE;YACrD,CAAC,CAAC,IAAI,CAAC;IACX,CAAC;CACF,CAAC,CAAC","debug_id":"83752754-be47-54a0-b136-1b4140250056"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studious-lms/server",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "description": "Backend server for Studious application",
5
5
  "main": "dist/exportType.js",
6
6
  "types": "dist/exportType.d.ts",
@@ -0,0 +1,13 @@
1
+ -- AlterTable
2
+ ALTER TABLE "AiUsageLog" ADD COLUMN "userId" TEXT,
3
+ ADD COLUMN "schoolId" TEXT,
4
+ ADD COLUMN "classId" TEXT;
5
+
6
+ -- CreateIndex
7
+ CREATE INDEX "AiUsageLog_userId_createdAt_idx" ON "AiUsageLog"("userId", "createdAt");
8
+
9
+ -- CreateIndex
10
+ CREATE INDEX "AiUsageLog_schoolId_createdAt_idx" ON "AiUsageLog"("schoolId", "createdAt");
11
+
12
+ -- CreateIndex
13
+ CREATE INDEX "AiUsageLog_classId_createdAt_idx" ON "AiUsageLog"("classId", "createdAt");
@@ -0,0 +1,14 @@
1
+ -- CreateEnum
2
+ CREATE TYPE "NotificationType" AS ENUM ('ASSIGNMENT_POSTED', 'ASSIGNMENT_RETURNED', 'COMMENT_REPLY', 'DISCUSSION_REPLY', 'DISCUSSION_MENTION', 'ANNOUNCEMENT', 'ATTENDANCE', 'SYSTEM');
3
+
4
+ -- AlterTable
5
+ ALTER TABLE "Notification" ADD COLUMN "type" "NotificationType" NOT NULL DEFAULT 'SYSTEM',
6
+ ADD COLUMN "entityType" TEXT,
7
+ ADD COLUMN "entityId" TEXT,
8
+ ADD COLUMN "classId" TEXT;
9
+
10
+ -- CreateIndex
11
+ CREATE INDEX "Notification_receiverId_createdAt_idx" ON "Notification"("receiverId", "createdAt");
12
+
13
+ -- CreateIndex
14
+ CREATE INDEX "Notification_receiverId_read_idx" ON "Notification"("receiverId", "read");
@@ -734,16 +734,34 @@ model Attendance {
734
734
  absent User[] @relation("AbsentAttendance")
735
735
  }
736
736
 
737
+ enum NotificationType {
738
+ ASSIGNMENT_POSTED
739
+ ASSIGNMENT_RETURNED
740
+ COMMENT_REPLY
741
+ DISCUSSION_REPLY
742
+ DISCUSSION_MENTION
743
+ ANNOUNCEMENT
744
+ ATTENDANCE
745
+ SYSTEM
746
+ }
747
+
737
748
  model Notification {
738
- id String @id @default(uuid())
749
+ id String @id @default(uuid())
739
750
  title String
740
751
  content String
741
- createdAt DateTime @default(now())
752
+ createdAt DateTime @default(now())
742
753
  senderId String? // no ID means sys
743
754
  receiverId String
744
- read Boolean @default(false)
745
- sender User? @relation("SentNotifications", fields: [senderId], references: [id])
746
- receiver User @relation("ReceivedNotifications", fields: [receiverId], references: [id], onDelete: Cascade)
755
+ read Boolean @default(false)
756
+ type NotificationType @default(SYSTEM)
757
+ entityType String? // e.g. ASSIGNMENT, ANNOUNCEMENT, DISCUSSION, SUBMISSION
758
+ entityId String?
759
+ classId String?
760
+ sender User? @relation("SentNotifications", fields: [senderId], references: [id])
761
+ receiver User @relation("ReceivedNotifications", fields: [receiverId], references: [id], onDelete: Cascade)
762
+
763
+ @@index([receiverId, createdAt])
764
+ @@index([receiverId, read])
747
765
  }
748
766
 
749
767
  enum ConversationType {
@@ -837,9 +855,15 @@ model AiUsageLog {
837
855
  estimatedInputTokens Int?
838
856
  metadata Json? @default("{}")
839
857
  error String?
858
+ userId String?
859
+ schoolId String?
860
+ classId String?
840
861
  createdAt DateTime @default(now())
841
862
 
842
863
  @@index([reason, createdAt])
864
+ @@index([userId, createdAt])
865
+ @@index([schoolId, createdAt])
866
+ @@index([classId, createdAt])
843
867
  }
844
868
 
845
869
  model Mention {
package/src/index.ts CHANGED
@@ -272,6 +272,13 @@ app.post('/api/pusher/auth', async (req, res) => {
272
272
  // }
273
273
  // }
274
274
 
275
+ // Per-user notification channel: only the user themselves may subscribe
276
+ if (channel_name.startsWith('private-user-')) {
277
+ if (channel_name !== `private-user-${user.id}-notifications`) {
278
+ return res.status(403).json({ error: 'No access to this user channel' });
279
+ }
280
+ }
281
+
275
282
  if (channel_name.startsWith('presence-')) {
276
283
  const authResponse = pusher.authorizeChannel(socket_id, channel_name, {
277
284
  user_id: user.id,
package/src/lib/pusher.ts CHANGED
@@ -19,5 +19,7 @@ const discussionChannel = (classId: string) => `private-class-${classId}-discuss
19
19
  * worksheet-response channel so both can co-exist without auth collisions.
20
20
  */
21
21
  const worksheetJobChannel = (worksheetId: string) => `private-worksheet-job-${worksheetId}`;
22
+ /** Per-user private channel for in-app notification pushes. */
23
+ const userNotificationChannel = (userId: string) => `private-user-${userId}-notifications`;
22
24
 
23
- export { pusher, chatChannel, worksheetChannel, teacherChannel, discussionChannel, worksheetJobChannel };
25
+ export { pusher, chatChannel, worksheetChannel, teacherChannel, discussionChannel, worksheetJobChannel, userNotificationChannel };
@@ -20,7 +20,7 @@ const rateLimitHandler = (req: Request, res: Response) => {
20
20
  // General API rate limiter - applies to all routes
21
21
  export const generalLimiter = rateLimit({
22
22
  windowMs: 10 * 60 * 1000, // 10 minutes
23
- max: 1000, // Limit each IP to 100 requests per windowMs
23
+ max: 10000, // Limit each IP to 10000 requests per windowMs (schools often share one NAT IP)
24
24
  message: 'Too many requests from this IP, please try again later.',
25
25
  standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
26
26
  legacyHeaders: false, // Disable the `X-RateLimit-*` headers
@@ -45,7 +45,7 @@ export const authLimiter = rateLimit({
45
45
  // File upload rate limiter
46
46
  export const uploadLimiter = rateLimit({
47
47
  windowMs: 30 * 60 * 1000, // 30 minutes
48
- max: 50, // Limit each IP to 50 uploads per hour
48
+ max: 500, // Limit each IP to 500 uploads per windowMs (a whole class may submit at once behind one IP)
49
49
  message: 'Too many file uploads, please try again later.',
50
50
  standardHeaders: true,
51
51
  legacyHeaders: false,
@@ -298,10 +298,11 @@ export function findSessionByClassCode(classCode: string) {
298
298
  });
299
299
  }
300
300
 
301
- /** @returns Session for a class. */
301
+ /** @returns Invite session for a class. */
302
302
  export function findSessionByClassId(classId: string) {
303
303
  return prisma.session.findFirst({
304
- where: { classId },
304
+ where: { classId, type: SessionType.CLASS_INVITE },
305
+ orderBy: { expiresAt: "desc" },
305
306
  });
306
307
  }
307
308
 
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Notification model – in-app notifications, create, mark read.
3
3
  */
4
+ import type { NotificationType } from "@prisma/client";
4
5
  import { prisma } from "../lib/prisma.js";
5
6
 
6
7
  const notificationInclude = {
@@ -8,11 +9,28 @@ const notificationInclude = {
8
9
  receiver: { select: { username: true } },
9
10
  };
10
11
 
11
- /** @returns Notifications for a receiver, newest first. */
12
- export function findNotificationsByReceiverId(receiverId: string) {
12
+ export type NotificationCreateData = {
13
+ receiverId: string;
14
+ title: string;
15
+ content: string;
16
+ senderId?: string | null;
17
+ type?: NotificationType;
18
+ entityType?: string | null;
19
+ entityId?: string | null;
20
+ classId?: string | null;
21
+ };
22
+
23
+ /** @returns A page of notifications for a receiver, newest first. */
24
+ export function findNotificationsByReceiverIdPaginated(
25
+ receiverId: string,
26
+ limit: number,
27
+ cursor?: string | null
28
+ ) {
13
29
  return prisma.notification.findMany({
14
30
  where: { receiverId },
15
- orderBy: { createdAt: "desc" },
31
+ orderBy: [{ createdAt: "desc" }, { id: "desc" }],
32
+ take: limit + 1,
33
+ ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
16
34
  include: notificationInclude,
17
35
  });
18
36
  }
@@ -25,23 +43,26 @@ export function findNotificationByIdForReceiver(id: string, receiverId: string)
25
43
  });
26
44
  }
27
45
 
46
+ /** @returns Number of unread notifications for a receiver. */
47
+ export function countUnreadNotifications(receiverId: string) {
48
+ return prisma.notification.count({
49
+ where: { receiverId, read: false },
50
+ });
51
+ }
52
+
28
53
  /** Create a notification for one receiver. */
29
- export function createNotification(data: {
30
- receiverId: string;
31
- title: string;
32
- content: string;
33
- }) {
54
+ export function createNotification(data: NotificationCreateData) {
34
55
  return prisma.notification.create({
35
56
  data,
57
+ include: notificationInclude,
36
58
  });
37
59
  }
38
60
 
39
- /** Create multiple notifications in one call. */
40
- export function createManyNotifications(
41
- data: Array<{ receiverId: string; title: string; content: string }>
42
- ) {
43
- return prisma.notification.createMany({
61
+ /** Create multiple notifications in one call and return the created rows. */
62
+ export function createManyNotifications(data: NotificationCreateData[]) {
63
+ return prisma.notification.createManyAndReturn({
44
64
  data,
65
+ include: notificationInclude,
45
66
  });
46
67
  }
47
68
 
@@ -58,3 +79,11 @@ export async function markNotificationAsReadForReceiver(id: string, receiverId:
58
79
 
59
80
  return findNotificationByIdForReceiver(id, receiverId);
60
81
  }
82
+
83
+ /** Mark all of a receiver's notifications as read. */
84
+ export function markAllNotificationsAsReadForReceiver(receiverId: string) {
85
+ return prisma.notification.updateMany({
86
+ where: { receiverId, read: false },
87
+ data: { read: true },
88
+ });
89
+ }
@@ -378,7 +378,7 @@ export const generateAndSendLabIntroduction = async (
378
378
  export const generateAndSendLabResponse = async (
379
379
  labChatId: string,
380
380
  teacherMessage: string,
381
- emitOptions: { classId: string; aiMessageId: string }
381
+ emitOptions: { classId: string; aiMessageId: string; userId?: string | null }
382
382
  ): Promise<void> => {
383
383
  try {
384
384
  // Get lab context from database
@@ -499,6 +499,11 @@ export const generateAndSendLabIntroduction = async (
499
499
  model: AI_CHAT_MODEL,
500
500
  system,
501
501
  messages,
502
+ usageReason: 'lab_chat_response',
503
+ attribution: {
504
+ userId: emitOptions.userId ?? fullLabChat.createdById,
505
+ classId: emitOptions.classId,
506
+ },
502
507
  tools,
503
508
  temperature: 0.7,
504
509
  maxOutputTokens: 4096,
@@ -99,6 +99,7 @@ export const generateAndSendNewtonIntroduction = async (
99
99
  conversationId: string,
100
100
  submissionId: string,
101
101
  aiMessageId: string,
102
+ actingUserId?: string | null,
102
103
  ): Promise<void> => {
103
104
  try {
104
105
  // Get submission details for context
@@ -109,6 +110,7 @@ export const generateAndSendNewtonIntroduction = async (
109
110
  select: {
110
111
  title: true,
111
112
  instructions: true,
113
+ classId: true,
112
114
  class: {
113
115
  select: {
114
116
  subject: true,
@@ -160,6 +162,11 @@ export const generateAndSendNewtonIntroduction = async (
160
162
  ],
161
163
  temperature: 0.8,
162
164
  maxOutputTokens: 400,
165
+ usageReason: 'newton_introduction',
166
+ attribution: {
167
+ userId: actingUserId,
168
+ classId: submission.assignment.classId,
169
+ },
163
170
  });
164
171
 
165
172
  if (!response) {
@@ -237,6 +244,7 @@ export const generateAndSendNewtonIntroduction = async (
237
244
  };
238
245
  },
239
246
  aiMessageId: string,
247
+ actingUserId?: string | null,
240
248
  ): Promise<void> => {
241
249
  try {
242
250
  // Get recent conversation history (excluding the empty AI placeholder
@@ -339,6 +347,8 @@ export const generateAndSendNewtonIntroduction = async (
339
347
  ...(newtonClassId ? { tools: aiTools(newtonClassId), maxSteps: 5 } : {}),
340
348
  temperature: 0.7,
341
349
  maxOutputTokens: 1200,
350
+ usageReason: 'newton_response',
351
+ attribution: { userId: actingUserId, classId: newtonClassId },
342
352
  });
343
353
 
344
354
  if (!response) {
@@ -79,7 +79,11 @@ INSTRUCTIONS:
79
79
  - Mark isCorrect=true only if the student earned full or near-full marks.`;
80
80
  }
81
81
 
82
- const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQuestionProgressId: string) => {
82
+ const gradeWorksheetQuestion = async (
83
+ worksheetResponseId: string,
84
+ worksheetQuestionProgressId: string,
85
+ actingUserId?: string | null,
86
+ ) => {
83
87
 
84
88
  await removeAllPreviousAIComments(worksheetQuestionProgressId);
85
89
 
@@ -155,6 +159,10 @@ const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQues
155
159
  questionId: worksheetQuestionProgressId,
156
160
  studentId: worksheetResponse.studentId,
157
161
  },
162
+ attribution: {
163
+ userId: actingUserId ?? worksheetResponse.studentId,
164
+ classId: worksheetResponse.worksheet.classId,
165
+ },
158
166
  },
159
167
  ).catch((error) => {
160
168
  logger.error('Failed to grade worksheet response', { error });
@@ -259,7 +267,10 @@ const gradeWorksheetQuestion = async (worksheetResponseId: string, worksheetQues
259
267
 
260
268
  const DO_NOT_INFERENCE_STATUSES = [JobStatus.CANCELLED, JobStatus.PENDING, JobStatus.COMPLETED];
261
269
 
262
- export const gradeWorksheetPipeline = async (worksheetResponseId: string) => {
270
+ export const gradeWorksheetPipeline = async (
271
+ worksheetResponseId: string,
272
+ actingUserId?: string | null,
273
+ ) => {
263
274
  logger.info('Grading worksheet response', { worksheetResponseId });
264
275
  const worksheetResponse = await prisma.studentWorksheetResponse.findUnique({
265
276
  where: { id: worksheetResponseId },
@@ -349,7 +360,7 @@ export const gradeWorksheetPipeline = async (worksheetResponseId: string) => {
349
360
  }
350
361
 
351
362
  try {
352
- await gradeWorksheetQuestion(worksheetResponseId, response.id);
363
+ await gradeWorksheetQuestion(worksheetResponseId, response.id, actingUserId);
353
364
  } catch (questionError) {
354
365
  logger.error('Failed to grade individual question, continuing with remaining questions', {
355
366
  questionId: response.questionId,
@@ -387,7 +398,10 @@ export const cancelGradePipeline = async (worksheetResponseId: string, worksheet
387
398
  return updatedStudentQuestionProgress;
388
399
  };
389
400
 
390
- export const regradeAllPipeline = async (worksheetResponseId: string) => {
401
+ export const regradeAllPipeline = async (
402
+ worksheetResponseId: string,
403
+ actingUserId?: string | null,
404
+ ) => {
391
405
  logger.info('Regrading all questions for worksheet response', { worksheetResponseId });
392
406
  const worksheetResponse = await prisma.studentWorksheetResponse.findUnique({
393
407
  where: { id: worksheetResponseId },
@@ -434,7 +448,7 @@ export const regradeAllPipeline = async (worksheetResponseId: string) => {
434
448
  // Re-grade each question sequentially
435
449
  for (const response of worksheetResponse.responses) {
436
450
  try {
437
- await gradeWorksheetQuestion(worksheetResponseId, response.id);
451
+ await gradeWorksheetQuestion(worksheetResponseId, response.id, actingUserId);
438
452
  } catch (questionError) {
439
453
  logger.error('Failed to regrade question, continuing with remaining', {
440
454
  questionId: response.questionId,
@@ -444,7 +458,11 @@ export const regradeAllPipeline = async (worksheetResponseId: string) => {
444
458
  }
445
459
  };
446
460
 
447
- export const regradeWorksheetPipeline = async (worksheetResponseId: string, worksheetQuestionProgressId: string) => {
461
+ export const regradeWorksheetPipeline = async (
462
+ worksheetResponseId: string,
463
+ worksheetQuestionProgressId: string,
464
+ actingUserId?: string | null,
465
+ ) => {
448
466
  logger.info('Regrading worksheet response', { worksheetResponseId, worksheetQuestionProgressId });
449
467
  try {
450
468
  const worksheetResponse = await prisma.studentWorksheetResponse.findUnique({
@@ -479,7 +497,7 @@ export const regradeWorksheetPipeline = async (worksheetResponseId: string, work
479
497
  where: { id: worksheetQuestionProgressId },
480
498
  });
481
499
 
482
- await gradeWorksheetQuestion(worksheetResponseId, worksheetQuestionProgressId);
500
+ await gradeWorksheetQuestion(worksheetResponseId, worksheetQuestionProgressId, actingUserId);
483
501
 
484
502
  return updatedStudentQuestionProgress;
485
503
  } catch (error) {
@@ -179,6 +179,8 @@ export interface PdfToWorksheetOptions {
179
179
  * channel. This is the path used by the BullMQ worker.
180
180
  */
181
181
  worksheetId?: string;
182
+ /** Acting user for AI usage attribution. */
183
+ userId?: string | null;
182
184
  }
183
185
 
184
186
  /**
@@ -265,6 +267,7 @@ export async function pdfToWorksheet(
265
267
  classId,
266
268
  filename,
267
269
  },
270
+ attribution: { userId: options.userId, classId },
268
271
  }
269
272
  );
270
273
  } catch (error) {
@@ -21,7 +21,6 @@ import { getReactions as getCommentReactions } from "../services/comment.js";
21
21
  import { logger } from "../utils/logger.js";
22
22
  import { createDirectUploadFiles, type UploadedFile, type DirectUploadFile, confirmDirectUpload } from "../lib/fileUpload.js";
23
23
  import { deleteFile } from "../lib/googleCloudStorage.js";
24
- import { sendToMultiple } from "../services/notification.js";
25
24
 
26
25
  // Schema for direct file uploads (no base64 data)
27
26
  const directFileSchema = z.object({
@@ -437,6 +437,11 @@ export const assignmentRouter = createTRPCRouter({
437
437
  receiverIds,
438
438
  title: `New comment on ${assignment.title}`,
439
439
  content: `${getCommenterName(comment)} commented in ${assignment.class.name}:\n\n${input.content}\n\n[View assignment](/class/${assignment.class.id}/assignments/${assignment.id})`,
440
+ senderId: authorId,
441
+ type: "COMMENT_REPLY",
442
+ entityType: "ASSIGNMENT",
443
+ entityId: assignment.id,
444
+ classId: assignment.class.id,
440
445
  }).catch((error) => {
441
446
  logger.error("Failed to send assignment comment notifications:", error);
442
447
  });
@@ -559,6 +564,11 @@ export const assignmentRouter = createTRPCRouter({
559
564
  receiverIds,
560
565
  title: `New private comment on ${submission.assignment.title}`,
561
566
  content: `${getCommenterName(comment)} commented on a submission in ${submission.assignment.class.name}:\n\n${input.content}\n\n[View submission](/class/${submission.assignment.class.id}/assignments/${submission.assignment.id}/submissions/${submission.id})`,
567
+ senderId: authorId,
568
+ type: "COMMENT_REPLY",
569
+ entityType: "ASSIGNMENT",
570
+ entityId: submission.assignment.id,
571
+ classId: submission.assignment.class.id,
562
572
  }).catch((error) => {
563
573
  logger.error("Failed to send submission comment notifications:", error);
564
574
  });
@@ -3,41 +3,43 @@ import { z } from "zod";
3
3
  import {
4
4
  listNotifications,
5
5
  getNotification,
6
- sendNotification,
7
- sendToMultiple,
6
+ getUnreadCount,
8
7
  markAsRead,
8
+ markAllAsRead,
9
9
  } from "../services/notification.js";
10
10
 
11
+ /**
12
+ * Read-only notification API. Notifications are emitted exclusively by
13
+ * server-side domain services — there is intentionally no public "send"
14
+ * endpoint (the previous sendTo/sendToMultiple procedures allowed any logged-in
15
+ * user to notify arbitrary users and were removed).
16
+ */
11
17
  export const notificationRouter = createTRPCRouter({
12
- list: protectedProcedure.query(({ ctx }) =>
13
- listNotifications(ctx.user!.id)
14
- ),
18
+ list: protectedProcedure
19
+ .input(
20
+ z
21
+ .object({
22
+ limit: z.number().int().min(1).max(100).default(20),
23
+ cursor: z.string().nullish(),
24
+ })
25
+ .optional()
26
+ )
27
+ .query(({ ctx, input }) =>
28
+ listNotifications(ctx.user!.id, {
29
+ limit: input?.limit,
30
+ cursor: input?.cursor,
31
+ })
32
+ ),
15
33
 
16
34
  get: protectedProcedure
17
35
  .input(z.object({ id: z.string() }))
18
36
  .query(({ ctx, input }) => getNotification(ctx.user!.id, input.id)),
19
37
 
20
- sendTo: protectedProcedure
21
- .input(
22
- z.object({
23
- receiverId: z.string(),
24
- title: z.string(),
25
- content: z.string(),
26
- })
27
- )
28
- .mutation(({ input }) => sendNotification(input)),
29
-
30
- sendToMultiple: protectedProcedure
31
- .input(
32
- z.object({
33
- receiverIds: z.array(z.string()),
34
- title: z.string(),
35
- content: z.string(),
36
- })
37
- )
38
- .mutation(({ input }) => sendToMultiple(input)),
38
+ unreadCount: protectedProcedure.query(({ ctx }) => getUnreadCount(ctx.user!.id)),
39
39
 
40
40
  markAsRead: protectedProcedure
41
41
  .input(z.object({ id: z.string() }))
42
42
  .mutation(({ ctx, input }) => markAsRead(ctx.user!.id, input.id)),
43
+
44
+ markAllAsRead: protectedProcedure.mutation(({ ctx }) => markAllAsRead(ctx.user!.id)),
43
45
  });
@@ -27,6 +27,7 @@ import {
27
27
  updateMemberRole,
28
28
  updateSchoolDetails,
29
29
  } from "../services/school.js";
30
+ import { getSchoolAiUsage } from "../services/aiUsage.js";
30
31
 
31
32
  const schoolRoleSchema = z.enum(["ADMIN", "TEACHER", "STUDENT"]);
32
33
 
@@ -142,6 +143,17 @@ export const schoolRouter = createTRPCRouter({
142
143
  .input(schoolIdSchema)
143
144
  .query(({ ctx, input }) => getAdminOverview(ctx.user!.id, input.schoolId)),
144
145
 
146
+ getAiUsage: protectedProcedure
147
+ .input(
148
+ z.object({
149
+ schoolId: z.string(),
150
+ startDate: z.date().optional(),
151
+ endDate: z.date().optional(),
152
+ topUsersLimit: z.number().min(1).max(50).optional(),
153
+ })
154
+ )
155
+ .query(({ ctx, input }) => getSchoolAiUsage(ctx.user!.id, input)),
156
+
145
157
  listClasses: protectedProcedure
146
158
  .input(schoolIdSchema)
147
159
  .query(({ ctx, input }) => listClasses(ctx.user!.id, input.schoolId)),
@@ -10,6 +10,7 @@ import {
10
10
  deleteUserAccount,
11
11
  exportUserData,
12
12
  } from "../services/userPrivacy.js";
13
+ import { getMyAiUsage } from "../services/aiUsage.js";
13
14
 
14
15
  const fileUploadSchema = z.object({
15
16
  filePath: z.string().min(1, "File path is required"),
@@ -89,6 +90,17 @@ export const userRouter = createTRPCRouter({
89
90
  exportUserData(ctx.user!.id),
90
91
  ),
91
92
 
93
+ getMyAiUsage: protectedProcedure
94
+ .input(
95
+ z
96
+ .object({
97
+ startDate: z.date().optional(),
98
+ endDate: z.date().optional(),
99
+ })
100
+ .optional(),
101
+ )
102
+ .query(({ ctx, input }) => getMyAiUsage(ctx.user!.id, input ?? {})),
103
+
92
104
  deleteAccount: protectedProcedure
93
105
  .input(
94
106
  z.object({