@studious-lms/server 1.9.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/.env.example +3 -0
  2. package/dist/index.js +40 -4
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/config/cors.d.ts.map +1 -1
  5. package/dist/lib/config/cors.js +7 -2
  6. package/dist/lib/config/cors.js.map +1 -1
  7. package/dist/lib/config/env.d.ts +8 -0
  8. package/dist/lib/config/env.d.ts.map +1 -1
  9. package/dist/lib/config/env.js +5 -2
  10. package/dist/lib/config/env.js.map +1 -1
  11. package/dist/lib/pusher.d.ts +2 -1
  12. package/dist/lib/pusher.d.ts.map +1 -1
  13. package/dist/lib/pusher.js +4 -3
  14. package/dist/lib/pusher.js.map +1 -1
  15. package/dist/models/agenda.d.ts +6 -0
  16. package/dist/models/agenda.d.ts.map +1 -1
  17. package/dist/models/announcement.d.ts +3 -0
  18. package/dist/models/announcement.d.ts.map +1 -1
  19. package/dist/models/assignment.d.ts +61 -0
  20. package/dist/models/assignment.d.ts.map +1 -1
  21. package/dist/models/auth.d.ts +6 -0
  22. package/dist/models/auth.d.ts.map +1 -1
  23. package/dist/models/class.d.ts +18 -0
  24. package/dist/models/class.d.ts.map +1 -1
  25. package/dist/models/class.js +5 -2
  26. package/dist/models/class.js.map +1 -1
  27. package/dist/models/comment.d.ts +6 -0
  28. package/dist/models/comment.d.ts.map +1 -1
  29. package/dist/models/event.d.ts +12 -0
  30. package/dist/models/event.d.ts.map +1 -1
  31. package/dist/models/file.d.ts +38 -0
  32. package/dist/models/file.d.ts.map +1 -1
  33. package/dist/models/folder.d.ts +6 -0
  34. package/dist/models/folder.d.ts.map +1 -1
  35. package/dist/models/labChat.d.ts +6 -0
  36. package/dist/models/labChat.d.ts.map +1 -1
  37. package/dist/models/newtonChat.d.ts +3 -0
  38. package/dist/models/newtonChat.d.ts.map +1 -1
  39. package/dist/models/school.d.ts +3 -0
  40. package/dist/models/school.d.ts.map +1 -1
  41. package/dist/models/section.d.ts +3 -0
  42. package/dist/models/section.d.ts.map +1 -1
  43. package/dist/models/worksheet.d.ts +58 -0
  44. package/dist/models/worksheet.d.ts.map +1 -1
  45. package/dist/models/worksheet.js +26 -2
  46. package/dist/models/worksheet.js.map +1 -1
  47. package/dist/routers/_app.d.ts +6400 -4078
  48. package/dist/routers/_app.d.ts.map +1 -1
  49. package/dist/routers/_app.js +6 -2
  50. package/dist/routers/_app.js.map +1 -1
  51. package/dist/routers/admin.d.ts +190 -0
  52. package/dist/routers/admin.d.ts.map +1 -0
  53. package/dist/routers/admin.js +50 -0
  54. package/dist/routers/admin.js.map +1 -0
  55. package/dist/routers/agenda.d.ts +6 -0
  56. package/dist/routers/agenda.d.ts.map +1 -1
  57. package/dist/routers/announcement.d.ts +2 -0
  58. package/dist/routers/announcement.d.ts.map +1 -1
  59. package/dist/routers/assignment.d.ts +50 -0
  60. package/dist/routers/assignment.d.ts.map +1 -1
  61. package/dist/routers/class.d.ts +31 -0
  62. package/dist/routers/class.d.ts.map +1 -1
  63. package/dist/routers/class.js +5 -2
  64. package/dist/routers/class.js.map +1 -1
  65. package/dist/routers/comment.d.ts +2 -0
  66. package/dist/routers/comment.d.ts.map +1 -1
  67. package/dist/routers/discussion.d.ts +823 -0
  68. package/dist/routers/discussion.d.ts.map +1 -0
  69. package/dist/routers/discussion.js +151 -0
  70. package/dist/routers/discussion.js.map +1 -0
  71. package/dist/routers/event.d.ts +3 -0
  72. package/dist/routers/event.d.ts.map +1 -1
  73. package/dist/routers/file.d.ts +4 -0
  74. package/dist/routers/file.d.ts.map +1 -1
  75. package/dist/routers/school.d.ts +6 -0
  76. package/dist/routers/school.d.ts.map +1 -1
  77. package/dist/routers/studentProgress.d.ts +45 -0
  78. package/dist/routers/studentProgress.d.ts.map +1 -1
  79. package/dist/routers/studentProgress.js +12 -3
  80. package/dist/routers/studentProgress.js.map +1 -1
  81. package/dist/routers/worksheet.d.ts +3 -0
  82. package/dist/routers/worksheet.d.ts.map +1 -1
  83. package/dist/seedDatabase.d.ts.map +1 -1
  84. package/dist/seedDatabase.js +1490 -1307
  85. package/dist/seedDatabase.js.map +1 -1
  86. package/dist/services/adminInstitution.d.ts +102 -0
  87. package/dist/services/adminInstitution.d.ts.map +1 -0
  88. package/dist/services/adminInstitution.js +117 -0
  89. package/dist/services/adminInstitution.js.map +1 -0
  90. package/dist/services/adminRunJob.d.ts +45 -0
  91. package/dist/services/adminRunJob.d.ts.map +1 -0
  92. package/dist/services/adminRunJob.js +41 -0
  93. package/dist/services/adminRunJob.js.map +1 -0
  94. package/dist/services/agenda.d.ts +6 -0
  95. package/dist/services/agenda.d.ts.map +1 -1
  96. package/dist/services/assignment.d.ts +50 -0
  97. package/dist/services/assignment.d.ts.map +1 -1
  98. package/dist/services/assignment.js +8 -3
  99. package/dist/services/assignment.js.map +1 -1
  100. package/dist/services/class.d.ts +31 -0
  101. package/dist/services/class.d.ts.map +1 -1
  102. package/dist/services/class.js +14 -2
  103. package/dist/services/class.js.map +1 -1
  104. package/dist/services/comment.d.ts +2 -0
  105. package/dist/services/comment.d.ts.map +1 -1
  106. package/dist/services/discussion.d.ts +762 -0
  107. package/dist/services/discussion.d.ts.map +1 -0
  108. package/dist/services/discussion.js +625 -0
  109. package/dist/services/discussion.js.map +1 -0
  110. package/dist/services/event.d.ts +3 -0
  111. package/dist/services/event.d.ts.map +1 -1
  112. package/dist/services/file.d.ts +8 -0
  113. package/dist/services/file.d.ts.map +1 -1
  114. package/dist/services/school.d.ts +6 -0
  115. package/dist/services/school.d.ts.map +1 -1
  116. package/dist/services/studentProgress.d.ts +94 -0
  117. package/dist/services/studentProgress.d.ts.map +1 -1
  118. package/dist/services/studentProgress.js +116 -3
  119. package/dist/services/studentProgress.js.map +1 -1
  120. package/dist/services/studentProgressInferenceCache.d.ts +16 -0
  121. package/dist/services/studentProgressInferenceCache.d.ts.map +1 -0
  122. package/dist/services/studentProgressInferenceCache.js +43 -0
  123. package/dist/services/studentProgressInferenceCache.js.map +1 -0
  124. package/dist/services/worksheet.d.ts +3 -0
  125. package/dist/services/worksheet.d.ts.map +1 -1
  126. package/dist/services/worksheet.js +5 -5
  127. package/dist/services/worksheet.js.map +1 -1
  128. package/dist/trpc.d.ts +12 -0
  129. package/dist/trpc.d.ts.map +1 -1
  130. package/dist/trpc.js +16 -3
  131. package/dist/trpc.js.map +1 -1
  132. package/dist/utils/adminPanelAccess.d.ts +8 -0
  133. package/dist/utils/adminPanelAccess.d.ts.map +1 -0
  134. package/dist/utils/adminPanelAccess.js +44 -0
  135. package/dist/utils/adminPanelAccess.js.map +1 -0
  136. package/package.json +1 -1
  137. package/prisma/migrations/20260510183000_add_discussions/migration.sql +151 -0
  138. package/prisma/schema.prisma +97 -0
  139. package/src/index.ts +39 -2
  140. package/src/lib/config/cors.ts +5 -0
  141. package/src/lib/config/env.ts +3 -0
  142. package/src/lib/pusher.ts +2 -1
  143. package/src/models/class.ts +3 -0
  144. package/src/models/worksheet.ts +28 -0
  145. package/src/routers/_app.ts +4 -0
  146. package/src/routers/admin.ts +70 -0
  147. package/src/routers/class.ts +3 -0
  148. package/src/routers/discussion.ts +205 -0
  149. package/src/routers/studentProgress.ts +21 -0
  150. package/src/seedDatabase.ts +1713 -1361
  151. package/src/services/adminInstitution.ts +140 -0
  152. package/src/services/adminRunJob.ts +46 -0
  153. package/src/services/assignment.ts +11 -1
  154. package/src/services/class.ts +18 -0
  155. package/src/services/discussion.ts +701 -0
  156. package/src/services/studentProgress.ts +274 -11
  157. package/src/services/studentProgressInferenceCache.ts +64 -0
  158. package/src/services/worksheet.ts +4 -2
  159. package/src/trpc.ts +15 -0
  160. package/src/utils/adminPanelAccess.ts +46 -0
  161. package/tests/integration/routers/discussion.test.ts +253 -0
  162. package/tests/unit/services/discussion.test.ts +356 -0
  163. package/tests/unit/services/studentProgress.test.ts +168 -2
package/dist/trpc.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"trpc.js","sources":["trpc.ts"],"sourceRoot":"/","sourcesContent":["import { initTRPC, TRPCError } from '@trpc/server';\nimport { ZodError } from 'zod';\nimport { logger } from './utils/logger.js';\nimport { createLoggingMiddleware } from './middleware/logging.js';\nimport { createAuthMiddleware } from './middleware/auth.js';\nimport { Request, Response } from 'express';\nimport { z } from 'zod';\nimport { handlePrismaError, PrismaErrorInfo } from './utils/prismaErrorHandler.js';\nimport { findUserBySessionToken } from './models/auth.js';\n\ninterface CreateContextOptions {\n req: Request;\n res: Response;\n}\n\nexport type Context = {\n req: Request;\n res: Response;\n user: { id: string } | null;\n meta?: {\n classId?: string;\n institutionId?: string;\n };\n};\n\nexport const createTRPCContext = async (opts: CreateContextOptions): Promise<Context> => {\n const { req, res } = opts;\n\n const userHeader = req.headers[\"x-user\"];\n const tokenFromUserHeader = typeof userHeader === \"string\" ? userHeader : userHeader?.[0];\n const authorizationHeader = req.headers.authorization;\n const tokenFromAuthorization =\n typeof authorizationHeader === \"string\" && authorizationHeader.startsWith(\"Bearer \")\n ? authorizationHeader.slice(7)\n : null;\n const token = tokenFromUserHeader ?? tokenFromAuthorization;\n const user = token ? await findUserBySessionToken(token) : null;\n\n return {\n req,\n res,\n user,\n meta: {},\n };\n};\n\nexport const t = initTRPC.context<Context>().create({\n errorFormatter({ shape, error }) {\n // Handle Prisma errors specifically\n let prismaErrorInfo: PrismaErrorInfo | null = null;\n if (error.cause) {\n try {\n prismaErrorInfo = handlePrismaError(error.cause);\n } catch (e) {\n // If Prisma error handling fails, continue with normal error handling\n }\n }\n\n logger.error('tRPC Error', {\n code: shape.code,\n message: error.message,\n cause: error.cause,\n stack: error.stack,\n prismaError: prismaErrorInfo,\n });\n\n return {\n ...shape,\n data: {\n ...shape.data,\n zodError:\n error.cause instanceof ZodError ? error.cause.flatten() : null,\n prismaError: prismaErrorInfo,\n },\n };\n },\n});\n\n// Create middleware\nconst loggingMiddleware = createLoggingMiddleware(t);\nconst { isAuthed, isMemberInClass, isTeacherInClass } = createAuthMiddleware(t);\n\n// Base procedures\nexport const createTRPCRouter = t.router;\nexport const publicProcedure = t.procedure.use(loggingMiddleware);\n\n\n// Protected procedures\nexport const protectedProcedure = publicProcedure.use(isAuthed);\n\nexport const protectedClassMemberProcedure = protectedProcedure\n .input(z.object({ classId: z.string() }).passthrough())\n .use(isMemberInClass);\n\nexport const protectedTeacherProcedure = protectedProcedure\n .input(z.object({ classId: z.string() }).passthrough())\n .use(isTeacherInClass);\n\n\n// Create caller factory\nexport const createCallerFactory = t.createCallerFactory; "],"names":[],"mappings":"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","debug_id":"6b11f8e8-b33f-5cae-aab3-08d27c3e81e9"}
1
+ {"version":3,"file":"trpc.js","sources":["trpc.ts"],"sourceRoot":"/","sourcesContent":["import { initTRPC, TRPCError } from '@trpc/server';\nimport { ZodError } from 'zod';\nimport { logger } from './utils/logger.js';\nimport { createLoggingMiddleware } from './middleware/logging.js';\nimport { createAuthMiddleware } from './middleware/auth.js';\nimport { Request, Response } from 'express';\nimport { z } from 'zod';\nimport { handlePrismaError, PrismaErrorInfo } from './utils/prismaErrorHandler.js';\nimport { findUserBySessionToken } from './models/auth.js';\nimport { assertAdminPanelUser } from './utils/adminPanelAccess.js';\n\ninterface CreateContextOptions {\n req: Request;\n res: Response;\n}\n\nexport type Context = {\n req: Request;\n res: Response;\n user: { id: string } | null;\n meta?: {\n classId?: string;\n institutionId?: string;\n };\n};\n\nexport const createTRPCContext = async (opts: CreateContextOptions): Promise<Context> => {\n const { req, res } = opts;\n\n const userHeader = req.headers[\"x-user\"];\n const tokenFromUserHeader = typeof userHeader === \"string\" ? userHeader : userHeader?.[0];\n const authorizationHeader = req.headers.authorization;\n const tokenFromAuthorization =\n typeof authorizationHeader === \"string\" && authorizationHeader.startsWith(\"Bearer \")\n ? authorizationHeader.slice(7)\n : null;\n const token = tokenFromUserHeader ?? tokenFromAuthorization;\n const user = token ? await findUserBySessionToken(token) : null;\n\n return {\n req,\n res,\n user,\n meta: {},\n };\n};\n\nexport const t = initTRPC.context<Context>().create({\n errorFormatter({ shape, error }) {\n // Handle Prisma errors specifically\n let prismaErrorInfo: PrismaErrorInfo | null = null;\n if (error.cause) {\n try {\n prismaErrorInfo = handlePrismaError(error.cause);\n } catch (e) {\n // If Prisma error handling fails, continue with normal error handling\n }\n }\n\n logger.error('tRPC Error', {\n code: shape.code,\n message: error.message,\n cause: error.cause,\n stack: error.stack,\n prismaError: prismaErrorInfo,\n });\n\n return {\n ...shape,\n data: {\n ...shape.data,\n zodError:\n error.cause instanceof ZodError ? error.cause.flatten() : null,\n prismaError: prismaErrorInfo,\n },\n };\n },\n});\n\n// Create middleware\nconst loggingMiddleware = createLoggingMiddleware(t);\nconst { isAuthed, isMemberInClass, isTeacherInClass } = createAuthMiddleware(t);\n\n// Base procedures\nexport const createTRPCRouter = t.router;\nexport const publicProcedure = t.procedure.use(loggingMiddleware);\n\n\n// Protected procedures\nexport const protectedProcedure = publicProcedure.use(isAuthed);\n\nconst adminPanelMiddleware = t.middleware(({ ctx, next }) => {\n if (!ctx.user) {\n throw new TRPCError({\n code: 'UNAUTHORIZED',\n message: 'Not authenticated',\n });\n }\n assertAdminPanelUser(ctx.user.id);\n return next({ ctx });\n});\n\n/** Authenticated + env allowlist (see `assertAdminPanelUser`). */\nexport const adminProcedure = protectedProcedure.use(adminPanelMiddleware);\n\nexport const protectedClassMemberProcedure = protectedProcedure\n .input(z.object({ classId: z.string() }).passthrough())\n .use(isMemberInClass);\n\nexport const protectedTeacherProcedure = protectedProcedure\n .input(z.object({ classId: z.string() }).passthrough())\n .use(isTeacherInClass);\n\n\n// Create caller factory\nexport const createCallerFactory = t.createCallerFactory; "],"names":[],"mappings":"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","debug_id":"29e1f1ae-1333-5312-bcdb-8c944e9f0f07"}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Admin tRPC routes: in production `ADMIN_PANEL_USER_IDS` must be a non-empty
3
+ * comma/whitespace-separated list of user UUIDs. In non-production, an empty
4
+ * unset value allows any authenticated user (local ops); a non-empty list is
5
+ * still enforced.
6
+ */
7
+ export declare function assertAdminPanelUser(userId: string): void;
8
+ //# sourceMappingURL=adminPanelAccess.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adminPanelAccess.d.ts","sourceRoot":"/","sources":["utils/adminPanelAccess.ts"],"names":[],"mappings":"AAaA;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CA0BzD"}
@@ -0,0 +1,44 @@
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]="c58bf633-ef92-5c8c-bfe2-f50882cb800c")}catch(e){}}();
3
+ import { TRPCError } from '@trpc/server';
4
+ import { env } from '../lib/config/env.js';
5
+ function parseAdminUserIds(raw) {
6
+ if (!raw?.trim())
7
+ return new Set();
8
+ return new Set(raw
9
+ .split(/[\s,]+/)
10
+ .map((s) => s.trim())
11
+ .filter(Boolean));
12
+ }
13
+ /**
14
+ * Admin tRPC routes: in production `ADMIN_PANEL_USER_IDS` must be a non-empty
15
+ * comma/whitespace-separated list of user UUIDs. In non-production, an empty
16
+ * unset value allows any authenticated user (local ops); a non-empty list is
17
+ * still enforced.
18
+ */
19
+ export function assertAdminPanelUser(userId) {
20
+ const allowed = parseAdminUserIds(env.ADMIN_PANEL_USER_IDS);
21
+ if (env.NODE_ENV === 'production') {
22
+ if (allowed.size === 0) {
23
+ throw new TRPCError({
24
+ code: 'FORBIDDEN',
25
+ message: 'Admin API disabled: set ADMIN_PANEL_USER_IDS to a non-empty list of user ids.',
26
+ });
27
+ }
28
+ if (!allowed.has(userId)) {
29
+ throw new TRPCError({
30
+ code: 'FORBIDDEN',
31
+ message: 'You are not allowed to use the admin API.',
32
+ });
33
+ }
34
+ return;
35
+ }
36
+ if (allowed.size > 0 && !allowed.has(userId)) {
37
+ throw new TRPCError({
38
+ code: 'FORBIDDEN',
39
+ message: 'You are not allowed to use the admin API.',
40
+ });
41
+ }
42
+ }
43
+ //# sourceMappingURL=adminPanelAccess.js.map
44
+ //# debugId=c58bf633-ef92-5c8c-bfe2-f50882cb800c
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adminPanelAccess.js","sources":["utils/adminPanelAccess.ts"],"sourceRoot":"/","sourcesContent":["import { TRPCError } from '@trpc/server';\nimport { env } from '../lib/config/env.js';\n\nfunction parseAdminUserIds(raw: string | undefined): Set<string> {\n if (!raw?.trim()) return new Set();\n return new Set(\n raw\n .split(/[\\s,]+/)\n .map((s) => s.trim())\n .filter(Boolean),\n );\n}\n\n/**\n * Admin tRPC routes: in production `ADMIN_PANEL_USER_IDS` must be a non-empty\n * comma/whitespace-separated list of user UUIDs. In non-production, an empty\n * unset value allows any authenticated user (local ops); a non-empty list is\n * still enforced.\n */\nexport function assertAdminPanelUser(userId: string): void {\n const allowed = parseAdminUserIds(env.ADMIN_PANEL_USER_IDS);\n\n if (env.NODE_ENV === 'production') {\n if (allowed.size === 0) {\n throw new TRPCError({\n code: 'FORBIDDEN',\n message:\n 'Admin API disabled: set ADMIN_PANEL_USER_IDS to a non-empty list of user ids.',\n });\n }\n if (!allowed.has(userId)) {\n throw new TRPCError({\n code: 'FORBIDDEN',\n message: 'You are not allowed to use the admin API.',\n });\n }\n return;\n }\n\n if (allowed.size > 0 && !allowed.has(userId)) {\n throw new TRPCError({\n code: 'FORBIDDEN',\n message: 'You are not allowed to use the admin API.',\n });\n }\n}\n"],"names":[],"mappings":";;AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,GAAG,EAAE,MAAM,sBAAsB,CAAC;AAE3C,SAAS,iBAAiB,CAAC,GAAuB;IAChD,IAAI,CAAC,GAAG,EAAE,IAAI,EAAE;QAAE,OAAO,IAAI,GAAG,EAAE,CAAC;IACnC,OAAO,IAAI,GAAG,CACZ,GAAG;SACA,KAAK,CAAC,QAAQ,CAAC;SACf,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;SACpB,MAAM,CAAC,OAAO,CAAC,CACnB,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAAC,MAAc;IACjD,MAAM,OAAO,GAAG,iBAAiB,CAAC,GAAG,CAAC,oBAAoB,CAAC,CAAC;IAE5D,IAAI,GAAG,CAAC,QAAQ,KAAK,YAAY,EAAE,CAAC;QAClC,IAAI,OAAO,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;YACvB,MAAM,IAAI,SAAS,CAAC;gBAClB,IAAI,EAAE,WAAW;gBACjB,OAAO,EACL,+EAA+E;aAClF,CAAC,CAAC;QACL,CAAC;QACD,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;YACzB,MAAM,IAAI,SAAS,CAAC;gBAClB,IAAI,EAAE,WAAW;gBACjB,OAAO,EAAE,2CAA2C;aACrD,CAAC,CAAC;QACL,CAAC;QACD,OAAO;IACT,CAAC;IAED,IAAI,OAAO,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;QAC7C,MAAM,IAAI,SAAS,CAAC;YAClB,IAAI,EAAE,WAAW;YACjB,OAAO,EAAE,2CAA2C;SACrD,CAAC,CAAC;IACL,CAAC;AACH,CAAC","debug_id":"c58bf633-ef92-5c8c-bfe2-f50882cb800c"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studious-lms/server",
3
- "version": "1.9.0",
3
+ "version": "1.12.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,151 @@
1
+ -- CreateEnum
2
+ CREATE TYPE "DiscussionType" AS ENUM ('DISCUSSION', 'QUESTION', 'ANNOUNCEMENT');
3
+
4
+ -- AlterTable
5
+ ALTER TABLE "Class" ADD COLUMN "discussionsEnabled" BOOLEAN NOT NULL DEFAULT true;
6
+ ALTER TABLE "Class" ADD COLUMN "studentsCanCreateDiscussions" BOOLEAN NOT NULL DEFAULT true;
7
+ ALTER TABLE "Class" ADD COLUMN "studentsCanReply" BOOLEAN NOT NULL DEFAULT true;
8
+
9
+ -- AlterTable
10
+ ALTER TABLE "File" ADD COLUMN "discussionId" TEXT;
11
+ ALTER TABLE "File" ADD COLUMN "discussionReplyId" TEXT;
12
+
13
+ -- AlterTable
14
+ ALTER TABLE "Reaction" ADD COLUMN "discussionId" TEXT;
15
+ ALTER TABLE "Reaction" ADD COLUMN "discussionReplyId" TEXT;
16
+
17
+ -- CreateTable
18
+ CREATE TABLE "Discussion" (
19
+ "id" TEXT NOT NULL,
20
+ "classId" TEXT NOT NULL,
21
+ "authorId" TEXT NOT NULL,
22
+ "title" TEXT NOT NULL,
23
+ "body" TEXT NOT NULL,
24
+ "type" "DiscussionType" NOT NULL DEFAULT 'DISCUSSION',
25
+ "isPinned" BOOLEAN NOT NULL DEFAULT false,
26
+ "isLocked" BOOLEAN NOT NULL DEFAULT false,
27
+ "allowReplies" BOOLEAN NOT NULL DEFAULT true,
28
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
29
+ "updatedAt" TIMESTAMP(3) NOT NULL,
30
+ "lastActivityAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
31
+
32
+ CONSTRAINT "Discussion_pkey" PRIMARY KEY ("id")
33
+ );
34
+
35
+ -- CreateTable
36
+ CREATE TABLE "DiscussionReply" (
37
+ "id" TEXT NOT NULL,
38
+ "discussionId" TEXT NOT NULL,
39
+ "authorId" TEXT NOT NULL,
40
+ "parentId" TEXT,
41
+ "body" TEXT NOT NULL,
42
+ "isAcceptedAnswer" BOOLEAN NOT NULL DEFAULT false,
43
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
44
+ "modifiedAt" TIMESTAMP(3),
45
+ "deletedAt" TIMESTAMP(3),
46
+
47
+ CONSTRAINT "DiscussionReply_pkey" PRIMARY KEY ("id")
48
+ );
49
+
50
+ -- CreateTable
51
+ CREATE TABLE "DiscussionTag" (
52
+ "id" TEXT NOT NULL,
53
+ "classId" TEXT NOT NULL,
54
+ "name" TEXT NOT NULL,
55
+ "color" TEXT NOT NULL DEFAULT '#3B82F6',
56
+
57
+ CONSTRAINT "DiscussionTag_pkey" PRIMARY KEY ("id")
58
+ );
59
+
60
+ -- CreateTable
61
+ CREATE TABLE "DiscussionRead" (
62
+ "userId" TEXT NOT NULL,
63
+ "discussionId" TEXT NOT NULL,
64
+ "lastReadAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
65
+
66
+ CONSTRAINT "DiscussionRead_pkey" PRIMARY KEY ("userId","discussionId")
67
+ );
68
+
69
+ -- CreateTable
70
+ CREATE TABLE "_DiscussionToTag" (
71
+ "A" TEXT NOT NULL,
72
+ "B" TEXT NOT NULL
73
+ );
74
+
75
+ -- CreateIndex
76
+ CREATE INDEX "Discussion_classId_isPinned_lastActivityAt_idx" ON "Discussion"("classId", "isPinned", "lastActivityAt");
77
+
78
+ -- CreateIndex
79
+ CREATE INDEX "Discussion_classId_createdAt_idx" ON "Discussion"("classId", "createdAt");
80
+
81
+ -- CreateIndex
82
+ CREATE INDEX "DiscussionReply_discussionId_createdAt_idx" ON "DiscussionReply"("discussionId", "createdAt");
83
+
84
+ -- CreateIndex
85
+ CREATE INDEX "DiscussionReply_parentId_idx" ON "DiscussionReply"("parentId");
86
+
87
+ -- CreateIndex
88
+ CREATE UNIQUE INDEX "DiscussionTag_classId_name_key" ON "DiscussionTag"("classId", "name");
89
+
90
+ -- CreateIndex
91
+ CREATE INDEX "DiscussionRead_userId_idx" ON "DiscussionRead"("userId");
92
+
93
+ -- CreateIndex
94
+ CREATE UNIQUE INDEX "_DiscussionToTag_AB_unique" ON "_DiscussionToTag"("A", "B");
95
+
96
+ -- CreateIndex
97
+ CREATE INDEX "_DiscussionToTag_B_index" ON "_DiscussionToTag"("B");
98
+
99
+ -- CreateIndex
100
+ CREATE UNIQUE INDEX "Reaction_userId_discussionId_key" ON "Reaction"("userId", "discussionId");
101
+
102
+ -- CreateIndex
103
+ CREATE UNIQUE INDEX "Reaction_userId_discussionReplyId_key" ON "Reaction"("userId", "discussionReplyId");
104
+
105
+ -- CreateIndex
106
+ CREATE INDEX "Reaction_discussionId_idx" ON "Reaction"("discussionId");
107
+
108
+ -- CreateIndex
109
+ CREATE INDEX "Reaction_discussionReplyId_idx" ON "Reaction"("discussionReplyId");
110
+
111
+ -- AddForeignKey
112
+ ALTER TABLE "File" ADD CONSTRAINT "File_discussionId_fkey" FOREIGN KEY ("discussionId") REFERENCES "Discussion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
113
+
114
+ -- AddForeignKey
115
+ ALTER TABLE "File" ADD CONSTRAINT "File_discussionReplyId_fkey" FOREIGN KEY ("discussionReplyId") REFERENCES "DiscussionReply"("id") ON DELETE CASCADE ON UPDATE CASCADE;
116
+
117
+ -- AddForeignKey
118
+ ALTER TABLE "Discussion" ADD CONSTRAINT "Discussion_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE;
119
+
120
+ -- AddForeignKey
121
+ ALTER TABLE "Discussion" ADD CONSTRAINT "Discussion_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
122
+
123
+ -- AddForeignKey
124
+ ALTER TABLE "DiscussionReply" ADD CONSTRAINT "DiscussionReply_discussionId_fkey" FOREIGN KEY ("discussionId") REFERENCES "Discussion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
125
+
126
+ -- AddForeignKey
127
+ ALTER TABLE "DiscussionReply" ADD CONSTRAINT "DiscussionReply_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
128
+
129
+ -- AddForeignKey
130
+ ALTER TABLE "DiscussionReply" ADD CONSTRAINT "DiscussionReply_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "DiscussionReply"("id") ON DELETE CASCADE ON UPDATE CASCADE;
131
+
132
+ -- AddForeignKey
133
+ ALTER TABLE "DiscussionTag" ADD CONSTRAINT "DiscussionTag_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class"("id") ON DELETE CASCADE ON UPDATE CASCADE;
134
+
135
+ -- AddForeignKey
136
+ ALTER TABLE "DiscussionRead" ADD CONSTRAINT "DiscussionRead_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
137
+
138
+ -- AddForeignKey
139
+ ALTER TABLE "DiscussionRead" ADD CONSTRAINT "DiscussionRead_discussionId_fkey" FOREIGN KEY ("discussionId") REFERENCES "Discussion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
140
+
141
+ -- AddForeignKey
142
+ ALTER TABLE "Reaction" ADD CONSTRAINT "Reaction_discussionId_fkey" FOREIGN KEY ("discussionId") REFERENCES "Discussion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
143
+
144
+ -- AddForeignKey
145
+ ALTER TABLE "Reaction" ADD CONSTRAINT "Reaction_discussionReplyId_fkey" FOREIGN KEY ("discussionReplyId") REFERENCES "DiscussionReply"("id") ON DELETE CASCADE ON UPDATE CASCADE;
146
+
147
+ -- AddForeignKey
148
+ ALTER TABLE "_DiscussionToTag" ADD CONSTRAINT "_DiscussionToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Discussion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
149
+
150
+ -- AddForeignKey
151
+ ALTER TABLE "_DiscussionToTag" ADD CONSTRAINT "_DiscussionToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "DiscussionTag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -84,6 +84,12 @@ enum ReactionType {
84
84
  HAPPY
85
85
  }
86
86
 
87
+ enum DiscussionType {
88
+ DISCUSSION
89
+ QUESTION
90
+ ANNOUNCEMENT
91
+ }
92
+
87
93
  enum SubmissionRecommendationState {
88
94
  NONE
89
95
  OPEN
@@ -227,6 +233,9 @@ model User {
227
233
  createdLabChats LabChat[] @relation("CreatedLabChats")
228
234
  comments Comment[]
229
235
  reactions Reaction[]
236
+ discussions Discussion[]
237
+ discussionReplies DiscussionReply[]
238
+ discussionReads DiscussionRead[]
230
239
  }
231
240
 
232
241
  model SchoolMembership {
@@ -311,6 +320,12 @@ model Class {
311
320
  departmentId String?
312
321
  tags Tag[] @relation("ClassTags")
313
322
  permissions Permission[] @relation("ClassPermissions")
323
+ discussions Discussion[]
324
+ discussionTags DiscussionTag[]
325
+
326
+ discussionsEnabled Boolean @default(true)
327
+ studentsCanCreateDiscussions Boolean @default(true)
328
+ studentsCanReply Boolean @default(true)
314
329
 
315
330
  @@index([schoolId])
316
331
  @@index([departmentId])
@@ -422,6 +437,12 @@ model File {
422
437
  announcement Announcement? @relation("AnnouncementAttachments", fields: [announcementId], references: [id], onDelete: Cascade)
423
438
  announcementId String?
424
439
 
440
+ discussion Discussion? @relation("DiscussionFiles", fields: [discussionId], references: [id], onDelete: Cascade)
441
+ discussionId String?
442
+
443
+ discussionReply DiscussionReply? @relation("DiscussionReplyFiles", fields: [discussionReplyId], references: [id], onDelete: Cascade)
444
+ discussionReplyId String?
445
+
425
446
  schools School[] @relation("SchoolLogo")
426
447
 
427
448
  schoolDevelopementProgram SchoolDevelopementProgram? @relation("SchoolDevelopementProgramSupportingDocumentation", fields: [schoolDevelopementProgramId], references: [id], onDelete: Cascade)
@@ -507,6 +528,74 @@ model Comment {
507
528
  @@index([submissionId])
508
529
  }
509
530
 
531
+ model Discussion {
532
+ id String @id @default(uuid())
533
+ class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
534
+ classId String
535
+ author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
536
+ authorId String
537
+ title String
538
+ body String
539
+ type DiscussionType @default(DISCUSSION)
540
+ isPinned Boolean @default(false)
541
+ isLocked Boolean @default(false)
542
+ allowReplies Boolean @default(true)
543
+ attachments File[] @relation("DiscussionFiles")
544
+ tags DiscussionTag[] @relation("DiscussionToTag")
545
+ replies DiscussionReply[]
546
+ reactions Reaction[]
547
+ reads DiscussionRead[]
548
+ createdAt DateTime @default(now())
549
+ updatedAt DateTime @updatedAt
550
+ lastActivityAt DateTime @default(now())
551
+
552
+ @@index([classId, isPinned, lastActivityAt])
553
+ @@index([classId, createdAt])
554
+ }
555
+
556
+ model DiscussionReply {
557
+ id String @id @default(uuid())
558
+ discussion Discussion @relation(fields: [discussionId], references: [id], onDelete: Cascade)
559
+ discussionId String
560
+ author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
561
+ authorId String
562
+ parent DiscussionReply? @relation("ReplyTree", fields: [parentId], references: [id], onDelete: Cascade)
563
+ parentId String?
564
+ children DiscussionReply[] @relation("ReplyTree")
565
+ body String
566
+ attachments File[] @relation("DiscussionReplyFiles")
567
+ reactions Reaction[]
568
+ isAcceptedAnswer Boolean @default(false)
569
+ createdAt DateTime @default(now())
570
+ modifiedAt DateTime? @updatedAt
571
+ deletedAt DateTime?
572
+
573
+ @@index([discussionId, createdAt])
574
+ @@index([parentId])
575
+ }
576
+
577
+ model DiscussionTag {
578
+ id String @id @default(uuid())
579
+ class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
580
+ classId String
581
+ name String
582
+ color String @default("#3B82F6")
583
+ discussions Discussion[] @relation("DiscussionToTag")
584
+
585
+ @@unique([classId, name])
586
+ }
587
+
588
+ model DiscussionRead {
589
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
590
+ userId String
591
+ discussion Discussion @relation(fields: [discussionId], references: [id], onDelete: Cascade)
592
+ discussionId String
593
+ lastReadAt DateTime @default(now())
594
+
595
+ @@id([userId, discussionId])
596
+ @@index([userId])
597
+ }
598
+
510
599
  model Reaction {
511
600
  id String @id @default(uuid())
512
601
  type ReactionType
@@ -516,12 +605,20 @@ model Reaction {
516
605
  announcementId String?
517
606
  comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
518
607
  commentId String?
608
+ discussion Discussion? @relation(fields: [discussionId], references: [id], onDelete: Cascade)
609
+ discussionId String?
610
+ discussionReply DiscussionReply? @relation(fields: [discussionReplyId], references: [id], onDelete: Cascade)
611
+ discussionReplyId String?
519
612
  createdAt DateTime @default(now())
520
613
 
521
614
  @@unique([userId, announcementId])
522
615
  @@unique([userId, commentId])
616
+ @@unique([userId, discussionId])
617
+ @@unique([userId, discussionReplyId])
523
618
  @@index([announcementId])
524
619
  @@index([commentId])
620
+ @@index([discussionId])
621
+ @@index([discussionReplyId])
525
622
  }
526
623
 
527
624
  model Submission {
package/src/index.ts CHANGED
@@ -58,7 +58,8 @@ app.use(cors({
58
58
  },
59
59
  credentials: true,
60
60
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
61
- allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'x-user'],
61
+ // Omit allowedHeaders so `cors` echoes `Access-Control-Request-Headers` on preflight.
62
+ // A fixed list breaks browsers that add headers (e.g. Sentry `baggage`, `sentry-trace`).
62
63
  preflightContinue: false,
63
64
  optionsSuccessStatus: 204,
64
65
  }));
@@ -221,6 +222,38 @@ app.post('/api/pusher/auth', async (req, res) => {
221
222
  }
222
223
  }
223
224
 
225
+ if (channel_name.startsWith('private-class-') && channel_name.endsWith('-discussions')) {
226
+ const classId = channel_name
227
+ .replace('private-class-', '')
228
+ .replace('-discussions', '');
229
+ const isMember = await prisma.class.findFirst({
230
+ where: {
231
+ id: classId,
232
+ discussionsEnabled: true,
233
+ OR: [
234
+ { students: { some: { id: user.id } } },
235
+ { teachers: { some: { id: user.id } } },
236
+ {
237
+ school: {
238
+ is: {
239
+ memberships: {
240
+ some: {
241
+ userId: user.id,
242
+ role: 'ADMIN',
243
+ status: 'ACTIVE',
244
+ },
245
+ },
246
+ },
247
+ },
248
+ },
249
+ ],
250
+ },
251
+ });
252
+ if (!isMember) {
253
+ return res.status(403).json({ error: 'Not a member of this class discussion channel' });
254
+ }
255
+ }
256
+
224
257
  // Verify channel access for private-class-* channels
225
258
  // if (channel_name.startsWith('private-class-')) {
226
259
  // const classId = channel_name.replace('private-class-', '');
@@ -586,7 +619,11 @@ app.options('/trpc/*', (req, res) => {
586
619
  res.setHeader('Access-Control-Allow-Origin', origin);
587
620
  res.setHeader('Access-Control-Allow-Credentials', 'true');
588
621
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
589
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, x-user');
622
+ const requestedHeaders = req.headers['access-control-request-headers'];
623
+ if (requestedHeaders) {
624
+ res.setHeader('Access-Control-Allow-Headers', requestedHeaders);
625
+ res.setHeader('Vary', 'Origin, Access-Control-Request-Headers');
626
+ }
590
627
  }
591
628
  res.sendStatus(204);
592
629
  });
@@ -9,6 +9,11 @@ const productionOrigins = [
9
9
  'https://www.studious.sh',
10
10
  'https://studious.sh',
11
11
  'https://dev.studious.sh',
12
+ // @todo: remove in actual prod
13
+ 'http://localhost:3000',
14
+ 'http://localhost:3001',
15
+ 'http://127.0.0.1:3000',
16
+ 'http://127.0.0.1:3001',
12
17
  ];
13
18
 
14
19
  const nonProductionOrigins = [
@@ -63,6 +63,8 @@ const fullSchema = baseSchema.extend({
63
63
  INFERENCE_API_BASE_URL: z.string().url().optional(),
64
64
  OPENAI_API_KEY: z.string().optional(),
65
65
  LOG_MODE: z.enum(['normal', 'verbose', 'quiet']).default('normal'),
66
+ /** Comma/whitespace-separated user UUIDs allowed to call `admin.*` tRPC (required in production). */
67
+ ADMIN_PANEL_USER_IDS: z.string().optional(),
66
68
  });
67
69
 
68
70
  // Test schema - only require what's needed for tests
@@ -91,6 +93,7 @@ const testSchema = baseSchema.extend({
91
93
  OPENAI_API_KEY: z.string().optional(),
92
94
  INFERENCE_API_BASE_URL: z.string().url().optional(),
93
95
  LOG_MODE: z.enum(['normal', 'verbose', 'quiet']).default('quiet'),
96
+ ADMIN_PANEL_USER_IDS: z.string().optional(),
94
97
  });
95
98
 
96
99
  // Use test schema in test mode, full schema otherwise
package/src/lib/pusher.ts CHANGED
@@ -12,6 +12,7 @@ const pusher = new Pusher({
12
12
  const chatChannel = (conversationId: string) => `private-conversation-${conversationId}`;
13
13
  const worksheetChannel = (worksheetResponseId: string) => `private-worksheet-${worksheetResponseId}`;
14
14
  const teacherChannel = (classId: string) => `private-teacher-${classId}`;
15
+ const discussionChannel = (classId: string) => `private-class-${classId}-discussions`;
15
16
  /**
16
17
  * Per-worksheet job channel used for asynchronous worksheet creation
17
18
  * (e.g. PDF → worksheet conversion). Distinct prefix from the student
@@ -19,4 +20,4 @@ const teacherChannel = (classId: string) => `private-teacher-${classId}`;
19
20
  */
20
21
  const worksheetJobChannel = (worksheetId: string) => `private-worksheet-job-${worksheetId}`;
21
22
 
22
- export { pusher, chatChannel, worksheetChannel, teacherChannel, worksheetJobChannel };
23
+ export { pusher, chatChannel, worksheetChannel, teacherChannel, discussionChannel, worksheetJobChannel };
@@ -180,6 +180,9 @@ const classIncludeForGet = {
180
180
  },
181
181
  },
182
182
  },
183
+ discussionsEnabled: true,
184
+ studentsCanCreateDiscussions: true,
185
+ studentsCanReply: true,
183
186
  } as const;
184
187
 
185
188
  const assignmentSelect = {
@@ -456,6 +456,34 @@ export function findStudentQuestionProgressByIdForTeacherAndResponse(
456
456
  });
457
457
  }
458
458
 
459
+ /**
460
+ * @returns Student question progress if requester is the answering student (class member) or a
461
+ * class teacher / school admin for that worksheet.
462
+ */
463
+ export function findStudentQuestionProgressByIdForStudentOrTeacher(
464
+ id: string,
465
+ userId: string
466
+ ) {
467
+ return prisma.studentQuestionProgress.findFirst({
468
+ where: {
469
+ id,
470
+ OR: [
471
+ {
472
+ studentId: userId,
473
+ question: { worksheet: worksheetClassMemberWhere(userId) },
474
+ },
475
+ {
476
+ question: { worksheet: worksheetTeacherWhere(userId) },
477
+ },
478
+ ],
479
+ },
480
+ include: {
481
+ question: true,
482
+ studentWorksheetResponse: true,
483
+ },
484
+ });
485
+ }
486
+
459
487
  /** Update student's question progress. */
460
488
  export function updateStudentQuestionProgress(
461
489
  id: string,
@@ -26,6 +26,8 @@ import { schoolRouter } from "./school.js";
26
26
  import { departmentRouter } from "./department.js";
27
27
  import { tagRouter } from "./tag.js";
28
28
  import { permissionsRouter } from "./permissions.js";
29
+ import { discussionRouter } from "./discussion.js";
30
+ import { adminRouter } from "./admin.js";
29
31
 
30
32
  export const appRouter = createTRPCRouter({
31
33
  class: classRouter,
@@ -53,6 +55,8 @@ export const appRouter = createTRPCRouter({
53
55
  department: departmentRouter,
54
56
  tag: tagRouter,
55
57
  permissions: permissionsRouter,
58
+ discussion: discussionRouter,
59
+ admin: adminRouter,
56
60
  });
57
61
 
58
62
  // Export type router type definition
@@ -0,0 +1,70 @@
1
+ import { z } from 'zod';
2
+ import { RunJobStatus, RunJobType } from '@prisma/client';
3
+ import { createTRPCRouter, adminProcedure } from '../trpc.js';
4
+ import {
5
+ getRunJobForAdmin,
6
+ listRunJobsForAdmin,
7
+ } from '../services/adminRunJob.js';
8
+ import {
9
+ listDepartmentsForAdmin,
10
+ listEarlyAccessRequestsForAdmin,
11
+ listSchoolDevelopmentProgramsForAdmin,
12
+ listSchoolsForAdmin,
13
+ } from '../services/adminInstitution.js';
14
+
15
+ export const adminRouter = createTRPCRouter({
16
+ listRunJobs: adminProcedure
17
+ .input(
18
+ z.object({
19
+ page: z.number().int().min(0).default(0),
20
+ pageSize: z.number().int().min(1).max(100).default(25),
21
+ status: z.nativeEnum(RunJobStatus).optional(),
22
+ type: z.nativeEnum(RunJobType).optional(),
23
+ }),
24
+ )
25
+ .query(({ input }) => listRunJobsForAdmin(input)),
26
+
27
+ getRunJob: adminProcedure
28
+ .input(z.object({ id: z.string().uuid() }))
29
+ .query(({ input }) => getRunJobForAdmin(input.id)),
30
+
31
+ listSchools: adminProcedure
32
+ .input(
33
+ z.object({
34
+ page: z.number().int().min(0).default(0),
35
+ pageSize: z.number().int().min(1).max(100).default(25),
36
+ query: z.string().optional(),
37
+ }),
38
+ )
39
+ .query(({ input }) => listSchoolsForAdmin(input)),
40
+
41
+ listDepartments: adminProcedure
42
+ .input(
43
+ z.object({
44
+ page: z.number().int().min(0).default(0),
45
+ pageSize: z.number().int().min(1).max(100).default(25),
46
+ schoolId: z.string().uuid().optional(),
47
+ }),
48
+ )
49
+ .query(({ input }) => listDepartmentsForAdmin(input)),
50
+
51
+ listSchoolDevelopmentPrograms: adminProcedure
52
+ .input(
53
+ z.object({
54
+ page: z.number().int().min(0).default(0),
55
+ pageSize: z.number().int().min(1).max(100).default(25),
56
+ status: z.string().optional(),
57
+ query: z.string().optional(),
58
+ }),
59
+ )
60
+ .query(({ input }) => listSchoolDevelopmentProgramsForAdmin(input)),
61
+
62
+ listEarlyAccessRequests: adminProcedure
63
+ .input(
64
+ z.object({
65
+ page: z.number().int().min(0).default(0),
66
+ pageSize: z.number().int().min(1).max(100).default(25),
67
+ }),
68
+ )
69
+ .query(({ input }) => listEarlyAccessRequestsForAdmin(input)),
70
+ });
@@ -60,6 +60,9 @@ export const classRouter = createTRPCRouter({
60
60
  subject: z.string().optional(),
61
61
  color: z.string().optional(),
62
62
  departmentId: z.string().nullable().optional(),
63
+ discussionsEnabled: z.boolean().optional(),
64
+ studentsCanCreateDiscussions: z.boolean().optional(),
65
+ studentsCanReply: z.boolean().optional(),
63
66
  })
64
67
  )
65
68
  .mutation(({ input }) => {