@studious-lms/server 1.1.24 → 1.1.26

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 (43) hide show
  1. package/dist/lib/fileUpload.d.ts.map +1 -1
  2. package/dist/lib/fileUpload.js +68 -11
  3. package/dist/lib/googleCloudStorage.d.ts +7 -0
  4. package/dist/lib/googleCloudStorage.d.ts.map +1 -1
  5. package/dist/lib/googleCloudStorage.js +19 -0
  6. package/dist/lib/notificationHandler.d.ts +25 -0
  7. package/dist/lib/notificationHandler.d.ts.map +1 -0
  8. package/dist/lib/notificationHandler.js +28 -0
  9. package/dist/routers/_app.d.ts +156 -22
  10. package/dist/routers/_app.d.ts.map +1 -1
  11. package/dist/routers/announcement.d.ts.map +1 -1
  12. package/dist/routers/announcement.js +26 -2
  13. package/dist/routers/assignment.d.ts +54 -3
  14. package/dist/routers/assignment.d.ts.map +1 -1
  15. package/dist/routers/assignment.js +265 -131
  16. package/dist/routers/auth.js +1 -1
  17. package/dist/routers/file.d.ts.map +1 -1
  18. package/dist/routers/file.js +9 -6
  19. package/dist/routers/labChat.d.ts.map +1 -1
  20. package/dist/routers/labChat.js +13 -5
  21. package/dist/routers/notifications.d.ts +8 -8
  22. package/dist/routers/section.d.ts +16 -0
  23. package/dist/routers/section.d.ts.map +1 -1
  24. package/dist/routers/section.js +139 -30
  25. package/dist/seedDatabase.d.ts +2 -2
  26. package/dist/seedDatabase.d.ts.map +1 -1
  27. package/dist/seedDatabase.js +2 -1
  28. package/dist/utils/logger.d.ts +1 -0
  29. package/dist/utils/logger.d.ts.map +1 -1
  30. package/dist/utils/logger.js +27 -2
  31. package/package.json +2 -2
  32. package/src/lib/fileUpload.ts +69 -11
  33. package/src/lib/googleCloudStorage.ts +19 -0
  34. package/src/lib/notificationHandler.ts +36 -0
  35. package/src/routers/announcement.ts +30 -2
  36. package/src/routers/assignment.ts +230 -82
  37. package/src/routers/auth.ts +1 -1
  38. package/src/routers/file.ts +10 -7
  39. package/src/routers/labChat.ts +15 -6
  40. package/src/routers/section.ts +158 -36
  41. package/src/seedDatabase.ts +2 -1
  42. package/src/utils/logger.ts +29 -2
  43. package/tests/setup.ts +3 -9
@@ -2,6 +2,8 @@ import { z } from "zod";
2
2
  import { createTRPCRouter, protectedClassMemberProcedure, protectedTeacherProcedure, protectedProcedure } from "../trpc.js";
3
3
  import { prisma } from "../lib/prisma.js";
4
4
  import { TRPCError } from "@trpc/server";
5
+ import { sendNotifications } from "../lib/notificationHandler.js";
6
+ import { logger } from "../utils/logger.js";
5
7
  const AnnouncementSelect = {
6
8
  id: true,
7
9
  teacher: {
@@ -38,9 +40,25 @@ export const announcementRouter = createTRPCRouter({
38
40
  remarks: z.string(),
39
41
  }))
40
42
  .mutation(async ({ ctx, input }) => {
43
+ const classId = input.classId;
44
+ const remarks = input.remarks;
45
+ const classData = await prisma.class.findUnique({
46
+ where: { id: classId },
47
+ include: {
48
+ students: {
49
+ select: { id: true }
50
+ }
51
+ }
52
+ });
53
+ if (!classData) {
54
+ throw new TRPCError({
55
+ code: "NOT_FOUND",
56
+ message: "Class not found",
57
+ });
58
+ }
41
59
  const announcement = await prisma.announcement.create({
42
60
  data: {
43
- remarks: input.remarks,
61
+ remarks: remarks,
44
62
  teacher: {
45
63
  connect: {
46
64
  id: ctx.user?.id,
@@ -48,12 +66,18 @@ export const announcementRouter = createTRPCRouter({
48
66
  },
49
67
  class: {
50
68
  connect: {
51
- id: input.classId,
69
+ id: classId,
52
70
  },
53
71
  },
54
72
  },
55
73
  select: AnnouncementSelect,
56
74
  });
75
+ sendNotifications(classData.students.map(student => student.id), {
76
+ title: `🔔 Announcement for ${classData.name}`,
77
+ content: remarks
78
+ }).catch(error => {
79
+ logger.error('Failed to send announcement notifications:');
80
+ });
57
81
  return {
58
82
  announcement,
59
83
  };
@@ -17,6 +17,37 @@ export declare const assignmentRouter: import("@trpc/server").TRPCBuiltRouter<{
17
17
  };
18
18
  transformer: false;
19
19
  }, import("@trpc/server").TRPCDecorateCreateRouterOptions<{
20
+ reorder: import("@trpc/server").TRPCMutationProcedure<{
21
+ input: {
22
+ [x: string]: unknown;
23
+ classId: string;
24
+ movedId: string;
25
+ position: "start" | "end" | "before" | "after";
26
+ targetId?: string | undefined;
27
+ };
28
+ output: {
29
+ type: import(".prisma/client").$Enums.AssignmentType;
30
+ id: string;
31
+ title: string;
32
+ dueDate: Date;
33
+ maxGrade: number | null;
34
+ classId: string;
35
+ eventId: string | null;
36
+ markSchemeId: string | null;
37
+ gradingBoundaryId: string | null;
38
+ instructions: string;
39
+ weight: number;
40
+ graded: boolean;
41
+ sectionId: string | null;
42
+ template: boolean;
43
+ createdAt: Date | null;
44
+ modifiedAt: Date | null;
45
+ teacherId: string;
46
+ inProgress: boolean;
47
+ order: number | null;
48
+ } | null;
49
+ meta: object;
50
+ }>;
20
51
  order: import("@trpc/server").TRPCMutationProcedure<{
21
52
  input: {
22
53
  [x: string]: unknown;
@@ -44,7 +75,7 @@ export declare const assignmentRouter: import("@trpc/server").TRPCBuiltRouter<{
44
75
  teacherId: string;
45
76
  inProgress: boolean;
46
77
  order: number | null;
47
- };
78
+ } | null;
48
79
  meta: object;
49
80
  }>;
50
81
  move: import("@trpc/server").TRPCMutationProcedure<{
@@ -52,7 +83,7 @@ export declare const assignmentRouter: import("@trpc/server").TRPCBuiltRouter<{
52
83
  [x: string]: unknown;
53
84
  classId: string;
54
85
  id: string;
55
- targetSectionId: string;
86
+ targetSectionId?: string | null | undefined;
56
87
  };
57
88
  output: {
58
89
  type: import(".prisma/client").$Enums.AssignmentType;
@@ -74,7 +105,7 @@ export declare const assignmentRouter: import("@trpc/server").TRPCBuiltRouter<{
74
105
  teacherId: string;
75
106
  inProgress: boolean;
76
107
  order: number | null;
77
- };
108
+ } | null;
78
109
  meta: object;
79
110
  }>;
80
111
  create: import("@trpc/server").TRPCMutationProcedure<{
@@ -474,6 +505,18 @@ export declare const assignmentRouter: import("@trpc/server").TRPCBuiltRouter<{
474
505
  student: {
475
506
  id: string;
476
507
  username: string;
508
+ profile: {
509
+ id: string;
510
+ location: string | null;
511
+ userId: string;
512
+ createdAt: Date;
513
+ displayName: string | null;
514
+ bio: string | null;
515
+ website: string | null;
516
+ profilePicture: string | null;
517
+ profilePictureThumbnail: string | null;
518
+ updatedAt: Date;
519
+ } | null;
477
520
  };
478
521
  attachments: {
479
522
  path: string;
@@ -594,8 +637,16 @@ export declare const assignmentRouter: import("@trpc/server").TRPCBuiltRouter<{
594
637
  id: string;
595
638
  username: string;
596
639
  profile: {
640
+ id: string;
641
+ location: string | null;
642
+ userId: string;
643
+ createdAt: Date;
597
644
  displayName: string | null;
645
+ bio: string | null;
646
+ website: string | null;
598
647
  profilePicture: string | null;
648
+ profilePictureThumbnail: string | null;
649
+ updatedAt: Date;
599
650
  } | null;
600
651
  };
601
652
  attachments: {
@@ -1 +1 @@
1
- {"version":3,"file":"assignment.d.ts","sourceRoot":"","sources":["../../src/routers/assignment.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,OAAO,EAA2B,KAAK,gBAAgB,EAAgE,MAAM,sBAAsB,CAAC;AAgIpJ,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2gE3B,CAAC"}
1
+ {"version":3,"file":"assignment.d.ts","sourceRoot":"","sources":["../../src/routers/assignment.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,OAAO,EAA2B,KAAK,gBAAgB,EAAgE,MAAM,sBAAsB,CAAC;AAqKpJ,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0nE3B,CAAC"}
@@ -4,6 +4,8 @@ import { TRPCError } from "@trpc/server";
4
4
  import { prisma } from "../lib/prisma.js";
5
5
  import { createDirectUploadFiles, confirmDirectUpload, updateUploadProgress } from "../lib/fileUpload.js";
6
6
  import { deleteFile } from "../lib/googleCloudStorage.js";
7
+ import { sendNotifications } from "../lib/notificationHandler.js";
8
+ import { logger } from "../utils/logger.js";
7
9
  // DEPRECATED: This schema is no longer used - files are uploaded directly to GCS
8
10
  // Use directFileSchema instead
9
11
  // New schema for direct file uploads (no base64 data)
@@ -114,7 +116,100 @@ const updateUploadProgressSchema = z.object({
114
116
  fileId: z.string(),
115
117
  progress: z.number().min(0).max(100),
116
118
  });
119
+ // Helper function to get unified list of sections and assignments for a class
120
+ async function getUnifiedList(tx, classId) {
121
+ const [sections, assignments] = await Promise.all([
122
+ tx.section.findMany({
123
+ where: { classId },
124
+ select: { id: true, order: true },
125
+ }),
126
+ tx.assignment.findMany({
127
+ where: { classId },
128
+ select: { id: true, order: true },
129
+ }),
130
+ ]);
131
+ // Combine and sort by order
132
+ const unified = [
133
+ ...sections.map((s) => ({ id: s.id, order: s.order, type: 'section' })),
134
+ ...assignments.map((a) => ({ id: a.id, order: a.order, type: 'assignment' })),
135
+ ].sort((a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER));
136
+ return unified;
137
+ }
138
+ // Helper function to normalize unified list to 1..n
139
+ async function normalizeUnifiedList(tx, classId, orderedItems) {
140
+ await Promise.all(orderedItems.map((item, index) => {
141
+ if (item.type === 'section') {
142
+ return tx.section.update({ where: { id: item.id }, data: { order: index + 1 } });
143
+ }
144
+ else {
145
+ return tx.assignment.update({ where: { id: item.id }, data: { order: index + 1 } });
146
+ }
147
+ }));
148
+ }
117
149
  export const assignmentRouter = createTRPCRouter({
150
+ // Reorder an assignment within the unified list (sections + assignments)
151
+ reorder: protectedTeacherProcedure
152
+ .input(z.object({
153
+ classId: z.string(),
154
+ movedId: z.string(),
155
+ // One of: place at start/end of unified list, or relative to targetId (can be section or assignment)
156
+ position: z.enum(['start', 'end', 'before', 'after']),
157
+ targetId: z.string().optional(), // Can be a section ID or assignment ID
158
+ }))
159
+ .mutation(async ({ ctx, input }) => {
160
+ const { classId, movedId, position, targetId } = input;
161
+ const moved = await prisma.assignment.findFirst({
162
+ where: { id: movedId, classId },
163
+ select: { id: true, classId: true },
164
+ });
165
+ if (!moved) {
166
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Assignment not found' });
167
+ }
168
+ if ((position === 'before' || position === 'after') && !targetId) {
169
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'targetId required for before/after' });
170
+ }
171
+ const result = await prisma.$transaction(async (tx) => {
172
+ const unified = await getUnifiedList(tx, classId);
173
+ // Find moved item and target in unified list
174
+ const movedIdx = unified.findIndex(item => item.id === movedId && item.type === 'assignment');
175
+ if (movedIdx === -1) {
176
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Assignment not found in unified list' });
177
+ }
178
+ // Build list without moved item
179
+ const withoutMoved = unified.filter(item => !(item.id === movedId && item.type === 'assignment'));
180
+ let next = [];
181
+ if (position === 'start') {
182
+ next = [{ id: movedId, type: 'assignment' }, ...withoutMoved.map(item => ({ id: item.id, type: item.type }))];
183
+ }
184
+ else if (position === 'end') {
185
+ next = [...withoutMoved.map(item => ({ id: item.id, type: item.type })), { id: movedId, type: 'assignment' }];
186
+ }
187
+ else {
188
+ const targetIdx = withoutMoved.findIndex(item => item.id === targetId);
189
+ if (targetIdx === -1) {
190
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'targetId not found in unified list' });
191
+ }
192
+ if (position === 'before') {
193
+ next = [
194
+ ...withoutMoved.slice(0, targetIdx).map(item => ({ id: item.id, type: item.type })),
195
+ { id: movedId, type: 'assignment' },
196
+ ...withoutMoved.slice(targetIdx).map(item => ({ id: item.id, type: item.type })),
197
+ ];
198
+ }
199
+ else {
200
+ next = [
201
+ ...withoutMoved.slice(0, targetIdx + 1).map(item => ({ id: item.id, type: item.type })),
202
+ { id: movedId, type: 'assignment' },
203
+ ...withoutMoved.slice(targetIdx + 1).map(item => ({ id: item.id, type: item.type })),
204
+ ];
205
+ }
206
+ }
207
+ // Normalize to 1..n
208
+ await normalizeUnifiedList(tx, classId, next);
209
+ return tx.assignment.findUnique({ where: { id: movedId } });
210
+ });
211
+ return result;
212
+ }),
118
213
  order: protectedTeacherProcedure
119
214
  .input(z.object({
120
215
  id: z.string(),
@@ -122,37 +217,49 @@ export const assignmentRouter = createTRPCRouter({
122
217
  order: z.number(),
123
218
  }))
124
219
  .mutation(async ({ ctx, input }) => {
220
+ // Deprecated: prefer `reorder`. For backward-compatibility, set the order then normalize unified list.
125
221
  const { id, order } = input;
126
- const assignment = await prisma.assignment.update({
222
+ const current = await prisma.assignment.findUnique({
127
223
  where: { id },
128
- data: { order },
224
+ select: { id: true, classId: true },
129
225
  });
130
- return assignment;
226
+ if (!current) {
227
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Assignment not found' });
228
+ }
229
+ const updated = await prisma.$transaction(async (tx) => {
230
+ await tx.assignment.update({ where: { id }, data: { order } });
231
+ // Normalize entire unified list
232
+ const unified = await getUnifiedList(tx, current.classId);
233
+ await normalizeUnifiedList(tx, current.classId, unified.map(item => ({ id: item.id, type: item.type })));
234
+ return tx.assignment.findUnique({ where: { id } });
235
+ });
236
+ return updated;
131
237
  }),
132
238
  move: protectedTeacherProcedure
133
239
  .input(z.object({
134
240
  id: z.string(),
135
241
  classId: z.string(),
136
- targetSectionId: z.string(),
242
+ targetSectionId: z.string().nullable().optional(),
137
243
  }))
138
244
  .mutation(async ({ ctx, input }) => {
139
- const { id, targetSectionId } = input;
140
- const assignments = await prisma.assignment.findMany({
141
- where: { sectionId: targetSectionId },
142
- });
143
- const stack = assignments.sort((a, b) => (a.order || 0) - (b.order || 0)).map((assignment, index) => ({
144
- id: assignment.id,
145
- order: index + 1,
146
- })).map((assignment) => ({
147
- where: { id: assignment.id },
148
- data: { order: assignment.order },
149
- }));
150
- await Promise.all(stack.map(({ where, data }) => prisma.assignment.update({ where, data })));
151
- const assignment = await prisma.assignment.update({
245
+ const { id } = input;
246
+ const targetSectionId = (input.targetSectionId ?? null) || null; // normalize empty string to null
247
+ const moved = await prisma.assignment.findUnique({
152
248
  where: { id },
153
- data: { sectionId: targetSectionId, order: 0 },
249
+ select: { id: true, classId: true, sectionId: true },
154
250
  });
155
- return assignment;
251
+ if (!moved) {
252
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Assignment not found' });
253
+ }
254
+ const updated = await prisma.$transaction(async (tx) => {
255
+ // Update sectionId first
256
+ await tx.assignment.update({ where: { id }, data: { sectionId: targetSectionId } });
257
+ // The unified list ordering remains the same, just the assignment's sectionId changed
258
+ // No need to reorder since we're keeping the same position in the unified list
259
+ // If frontend wants to change position, they should call reorder after move
260
+ return tx.assignment.findUnique({ where: { id } });
261
+ });
262
+ return updated;
156
263
  }),
157
264
  create: protectedProcedure
158
265
  .input(createAssignmentSchema)
@@ -195,100 +302,92 @@ export const assignmentRouter = createTRPCRouter({
195
302
  }, 0);
196
303
  }
197
304
  console.log(markSchemeId, gradingBoundaryId);
198
- // find all assignments in the section it is in (or none) and reorder them
199
- const assignments = await prisma.assignment.findMany({
200
- where: {
201
- classId: classId,
202
- ...(sectionId && {
203
- sectionId: sectionId,
204
- }),
205
- },
206
- });
207
- const stack = assignments.sort((a, b) => (a.order || 0) - (b.order || 0)).map((assignment, index) => ({
208
- id: assignment.id,
209
- order: index + 1,
210
- })).map((assignment) => ({
211
- where: { id: assignment.id },
212
- data: { order: assignment.order },
213
- }));
214
- // Create assignment with submissions for all students
215
- const assignment = await prisma.assignment.create({
216
- data: {
217
- title,
218
- instructions,
219
- dueDate: new Date(dueDate),
220
- maxGrade: markSchemeId ? computedMaxGrade : maxGrade,
221
- graded,
222
- weight,
223
- type,
224
- order: 0,
225
- inProgress: inProgress || false,
226
- class: {
227
- connect: { id: classId }
228
- },
229
- ...(sectionId && {
230
- section: {
231
- connect: { id: sectionId }
232
- }
233
- }),
234
- ...(markSchemeId && {
235
- markScheme: {
236
- connect: { id: markSchemeId }
237
- }
238
- }),
239
- ...(gradingBoundaryId && {
240
- gradingBoundary: {
241
- connect: { id: gradingBoundaryId }
242
- }
243
- }),
244
- submissions: {
245
- create: classData.students.map((student) => ({
246
- student: {
247
- connect: { id: student.id }
305
+ // Create assignment and place at top of its scope within a single transaction
306
+ const teacherId = ctx.user.id;
307
+ const assignment = await prisma.$transaction(async (tx) => {
308
+ const created = await tx.assignment.create({
309
+ data: {
310
+ title,
311
+ instructions,
312
+ dueDate: new Date(dueDate),
313
+ maxGrade: markSchemeId ? computedMaxGrade : maxGrade,
314
+ graded,
315
+ weight,
316
+ type,
317
+ order: 1,
318
+ inProgress: inProgress || false,
319
+ class: {
320
+ connect: { id: classId }
321
+ },
322
+ ...(sectionId && {
323
+ section: {
324
+ connect: { id: sectionId }
248
325
  }
249
- }))
250
- },
251
- teacher: {
252
- connect: { id: ctx.user.id }
253
- }
254
- },
255
- select: {
256
- id: true,
257
- title: true,
258
- instructions: true,
259
- dueDate: true,
260
- maxGrade: true,
261
- graded: true,
262
- weight: true,
263
- type: true,
264
- attachments: {
265
- select: {
266
- id: true,
267
- name: true,
268
- type: true,
269
- }
270
- },
271
- section: {
272
- select: {
273
- id: true,
274
- name: true
275
- }
276
- },
277
- teacher: {
278
- select: {
279
- id: true,
280
- username: true
326
+ }),
327
+ ...(markSchemeId && {
328
+ markScheme: {
329
+ connect: { id: markSchemeId }
330
+ }
331
+ }),
332
+ ...(gradingBoundaryId && {
333
+ gradingBoundary: {
334
+ connect: { id: gradingBoundaryId }
335
+ }
336
+ }),
337
+ submissions: {
338
+ create: classData.students.map((student) => ({
339
+ student: {
340
+ connect: { id: student.id }
341
+ }
342
+ }))
343
+ },
344
+ teacher: {
345
+ connect: { id: teacherId }
281
346
  }
282
347
  },
283
- class: {
284
- select: {
285
- id: true,
286
- name: true
348
+ select: {
349
+ id: true,
350
+ title: true,
351
+ instructions: true,
352
+ dueDate: true,
353
+ maxGrade: true,
354
+ graded: true,
355
+ weight: true,
356
+ type: true,
357
+ attachments: {
358
+ select: {
359
+ id: true,
360
+ name: true,
361
+ type: true,
362
+ }
363
+ },
364
+ section: {
365
+ select: {
366
+ id: true,
367
+ name: true
368
+ }
369
+ },
370
+ teacher: {
371
+ select: {
372
+ id: true,
373
+ username: true
374
+ }
375
+ },
376
+ class: {
377
+ select: {
378
+ id: true,
379
+ name: true
380
+ }
287
381
  }
288
382
  }
289
- }
383
+ });
384
+ // Insert new assignment at top of unified list and normalize
385
+ const unified = await getUnifiedList(tx, classId);
386
+ const withoutNew = unified.filter(item => !(item.id === created.id && item.type === 'assignment'));
387
+ const reindexed = [{ id: created.id, type: 'assignment' }, ...withoutNew.map(item => ({ id: item.id, type: item.type }))];
388
+ await normalizeUnifiedList(tx, classId, reindexed);
389
+ return created;
290
390
  });
291
- await Promise.all(stack.map(({ where, data }) => prisma.assignment.update({ where, data })));
292
391
  // NOTE: Files are now handled via direct upload endpoints
293
392
  // The files field in the schema is for metadata only
294
393
  // Actual file uploads should use getAssignmentUploadUrls endpoint
@@ -329,6 +428,14 @@ export const assignmentRouter = createTRPCRouter({
329
428
  }
330
429
  });
331
430
  }
431
+ sendNotifications(classData.students.map(student => student.id), {
432
+ title: `🔔 New assignment for ${classData.name}`,
433
+ content: `The assignment "${title}" has been created in ${classData.name}.\n
434
+ Due date: ${new Date(dueDate).toLocaleDateString()}.
435
+ [Link to assignment](/class/${classId}/assignments/${assignment.id})`
436
+ }).catch(error => {
437
+ logger.error('Failed to send assignment notifications:');
438
+ });
332
439
  return assignment;
333
440
  }),
334
441
  update: protectedProcedure
@@ -355,6 +462,7 @@ export const assignmentRouter = createTRPCRouter({
355
462
  type: true,
356
463
  path: true,
357
464
  size: true,
465
+ uploadStatus: true,
358
466
  thumbnail: {
359
467
  select: {
360
468
  path: true
@@ -382,6 +490,27 @@ export const assignmentRouter = createTRPCRouter({
382
490
  // Create direct upload files instead of processing base64
383
491
  uploadedFiles = await createDirectUploadFiles(files, ctx.user.id, undefined, input.id);
384
492
  }
493
+ // Delete removed attachments from storage before updating database
494
+ if (input.removedAttachments && input.removedAttachments.length > 0) {
495
+ const filesToDelete = assignment.attachments.filter((file) => input.removedAttachments.includes(file.id));
496
+ // Delete files from storage (only if they were actually uploaded)
497
+ await Promise.all(filesToDelete.map(async (file) => {
498
+ try {
499
+ // Only delete from GCS if the file was successfully uploaded
500
+ if (file.uploadStatus === 'COMPLETED') {
501
+ // Delete the main file
502
+ await deleteFile(file.path);
503
+ // Delete thumbnail if it exists
504
+ if (file.thumbnail?.path) {
505
+ await deleteFile(file.thumbnail.path);
506
+ }
507
+ }
508
+ }
509
+ catch (error) {
510
+ console.warn(`Failed to delete file ${file.path}:`, error);
511
+ }
512
+ }));
513
+ }
385
514
  // Update assignment
386
515
  const updatedAssignment = await prisma.assignment.update({
387
516
  where: { id },
@@ -536,14 +665,17 @@ export const assignmentRouter = createTRPCRouter({
536
665
  ...assignment.attachments,
537
666
  ...assignment.submissions.flatMap(sub => [...sub.attachments, ...sub.annotations])
538
667
  ];
539
- // Delete files from storage
668
+ // Delete files from storage (only if they were actually uploaded)
540
669
  await Promise.all(filesToDelete.map(async (file) => {
541
670
  try {
542
- // Delete the main file
543
- await deleteFile(file.path);
544
- // Delete thumbnail if it exists
545
- if (file.thumbnail) {
546
- await deleteFile(file.thumbnail.path);
671
+ // Only delete from GCS if the file was successfully uploaded
672
+ if (file.uploadStatus === 'COMPLETED') {
673
+ // Delete the main file
674
+ await deleteFile(file.path);
675
+ // Delete thumbnail if it exists
676
+ if (file.thumbnail) {
677
+ await deleteFile(file.thumbnail.path);
678
+ }
547
679
  }
548
680
  }
549
681
  catch (error) {
@@ -678,6 +810,7 @@ export const assignmentRouter = createTRPCRouter({
678
810
  select: {
679
811
  id: true,
680
812
  username: true,
813
+ profile: true,
681
814
  },
682
815
  },
683
816
  assignment: {
@@ -779,12 +912,7 @@ export const assignmentRouter = createTRPCRouter({
779
912
  select: {
780
913
  id: true,
781
914
  username: true,
782
- profile: {
783
- select: {
784
- displayName: true,
785
- profilePicture: true,
786
- }
787
- }
915
+ profile: true,
788
916
  },
789
917
  },
790
918
  assignment: {
@@ -956,14 +1084,17 @@ export const assignmentRouter = createTRPCRouter({
956
1084
  // Delete removed attachments if any
957
1085
  if (removedAttachments && removedAttachments.length > 0) {
958
1086
  const filesToDelete = submission.attachments.filter((file) => removedAttachments.includes(file.id));
959
- // Delete files from storage
1087
+ // Delete files from storage (only if they were actually uploaded)
960
1088
  await Promise.all(filesToDelete.map(async (file) => {
961
1089
  try {
962
- // Delete the main file
963
- await deleteFile(file.path);
964
- // Delete thumbnail if it exists
965
- if (file.thumbnail?.path) {
966
- await deleteFile(file.thumbnail.path);
1090
+ // Only delete from GCS if the file was successfully uploaded
1091
+ if (file.uploadStatus === 'COMPLETED') {
1092
+ // Delete the main file
1093
+ await deleteFile(file.path);
1094
+ // Delete thumbnail if it exists
1095
+ if (file.thumbnail?.path) {
1096
+ await deleteFile(file.thumbnail.path);
1097
+ }
967
1098
  }
968
1099
  }
969
1100
  catch (error) {
@@ -1204,14 +1335,17 @@ export const assignmentRouter = createTRPCRouter({
1204
1335
  // Delete removed attachments if any
1205
1336
  if (removedAttachments && removedAttachments.length > 0) {
1206
1337
  const filesToDelete = submission.annotations.filter((file) => removedAttachments.includes(file.id));
1207
- // Delete files from storage
1338
+ // Delete files from storage (only if they were actually uploaded)
1208
1339
  await Promise.all(filesToDelete.map(async (file) => {
1209
1340
  try {
1210
- // Delete the main file
1211
- await deleteFile(file.path);
1212
- // Delete thumbnail if it exists
1213
- if (file.thumbnail?.path) {
1214
- await deleteFile(file.thumbnail.path);
1341
+ // Only delete from GCS if the file was successfully uploaded
1342
+ if (file.uploadStatus === 'COMPLETED') {
1343
+ // Delete the main file
1344
+ await deleteFile(file.path);
1345
+ // Delete thumbnail if it exists
1346
+ if (file.thumbnail?.path) {
1347
+ await deleteFile(file.thumbnail.path);
1348
+ }
1215
1349
  }
1216
1350
  }
1217
1351
  catch (error) {