@studious-lms/server 1.1.24 → 1.2.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 (49) hide show
  1. package/dist/lib/fileUpload.d.ts +2 -2
  2. package/dist/lib/fileUpload.d.ts.map +1 -1
  3. package/dist/lib/fileUpload.js +76 -14
  4. package/dist/lib/googleCloudStorage.d.ts +7 -0
  5. package/dist/lib/googleCloudStorage.d.ts.map +1 -1
  6. package/dist/lib/googleCloudStorage.js +19 -0
  7. package/dist/lib/notificationHandler.d.ts +25 -0
  8. package/dist/lib/notificationHandler.d.ts.map +1 -0
  9. package/dist/lib/notificationHandler.js +28 -0
  10. package/dist/routers/_app.d.ts +818 -78
  11. package/dist/routers/_app.d.ts.map +1 -1
  12. package/dist/routers/announcement.d.ts +290 -3
  13. package/dist/routers/announcement.d.ts.map +1 -1
  14. package/dist/routers/announcement.js +896 -10
  15. package/dist/routers/assignment.d.ts +70 -4
  16. package/dist/routers/assignment.d.ts.map +1 -1
  17. package/dist/routers/assignment.js +265 -131
  18. package/dist/routers/auth.js +1 -1
  19. package/dist/routers/file.d.ts +2 -0
  20. package/dist/routers/file.d.ts.map +1 -1
  21. package/dist/routers/file.js +9 -6
  22. package/dist/routers/labChat.d.ts.map +1 -1
  23. package/dist/routers/labChat.js +13 -5
  24. package/dist/routers/notifications.d.ts +8 -8
  25. package/dist/routers/section.d.ts +16 -0
  26. package/dist/routers/section.d.ts.map +1 -1
  27. package/dist/routers/section.js +139 -30
  28. package/dist/seedDatabase.d.ts +2 -2
  29. package/dist/seedDatabase.d.ts.map +1 -1
  30. package/dist/seedDatabase.js +2 -1
  31. package/dist/utils/logger.d.ts +1 -0
  32. package/dist/utils/logger.d.ts.map +1 -1
  33. package/dist/utils/logger.js +27 -2
  34. package/package.json +2 -2
  35. package/prisma/migrations/20251109122857_annuoncements_comments/migration.sql +30 -0
  36. package/prisma/migrations/20251109135555_reactions_announcements_comments/migration.sql +35 -0
  37. package/prisma/schema.prisma +50 -0
  38. package/src/lib/fileUpload.ts +79 -14
  39. package/src/lib/googleCloudStorage.ts +19 -0
  40. package/src/lib/notificationHandler.ts +36 -0
  41. package/src/routers/announcement.ts +1007 -10
  42. package/src/routers/assignment.ts +230 -82
  43. package/src/routers/auth.ts +1 -1
  44. package/src/routers/file.ts +10 -7
  45. package/src/routers/labChat.ts +15 -6
  46. package/src/routers/section.ts +158 -36
  47. package/src/seedDatabase.ts +2 -1
  48. package/src/utils/logger.ts +29 -2
  49. package/tests/setup.ts +3 -9
@@ -31,6 +31,22 @@ export declare const sectionRouter: import("@trpc/server").TRPCBuiltRouter<{
31
31
  };
32
32
  meta: object;
33
33
  }>;
34
+ reorder: import("@trpc/server").TRPCMutationProcedure<{
35
+ input: {
36
+ classId: string;
37
+ movedId: string;
38
+ position: "start" | "end" | "before" | "after";
39
+ targetId?: string | undefined;
40
+ };
41
+ output: {
42
+ id: string;
43
+ name: string;
44
+ color: string | null;
45
+ classId: string;
46
+ order: number | null;
47
+ } | null;
48
+ meta: object;
49
+ }>;
34
50
  update: import("@trpc/server").TRPCMutationProcedure<{
35
51
  input: {
36
52
  id: string;
@@ -1 +1 @@
1
- {"version":3,"file":"section.d.ts","sourceRoot":"","sources":["../../src/routers/section.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAuBxB,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwMxB,CAAC"}
1
+ {"version":3,"file":"section.d.ts","sourceRoot":"","sources":["../../src/routers/section.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAuBxB,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkUxB,CAAC"}
@@ -56,34 +56,119 @@ export const sectionRouter = createTRPCRouter({
56
56
  }),
57
57
  },
58
58
  });
59
- // find all root items in the class and reorder them
60
- const sections = await prisma.section.findMany({
61
- where: {
62
- classId: input.classId,
63
- },
64
- });
65
- const assignments = await prisma.assignment.findMany({
66
- where: {
67
- classId: input.classId,
68
- sectionId: null,
69
- },
70
- });
71
- const stack = [...sections, ...assignments].sort((a, b) => (a.order || 0) - (b.order || 0)).map((item, index) => ({
72
- id: item.id,
73
- order: index + 1,
74
- })).map((item) => ({
75
- where: { id: item.id },
76
- data: { order: item.order },
77
- }));
78
- // Update sections and assignments with their new order
79
- await Promise.all([
80
- ...stack.filter(item => sections.some(s => s.id === item.where.id))
81
- .map(({ where, data }) => prisma.section.update({ where, data })),
82
- ...stack.filter(item => assignments.some(a => a.id === item.where.id))
83
- .map(({ where, data }) => prisma.assignment.update({ where, data }))
59
+ // Insert new section at top of unified list (sections + assignments) and normalize
60
+ const [sections, assignments] = await Promise.all([
61
+ prisma.section.findMany({
62
+ where: { classId: input.classId },
63
+ select: { id: true, order: true },
64
+ }),
65
+ prisma.assignment.findMany({
66
+ where: { classId: input.classId },
67
+ select: { id: true, order: true },
68
+ }),
84
69
  ]);
70
+ const unified = [
71
+ ...sections.map(s => ({ id: s.id, order: s.order, type: 'section' })),
72
+ ...assignments.map(a => ({ id: a.id, order: a.order, type: 'assignment' })),
73
+ ].sort((a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER));
74
+ const withoutNew = unified.filter(item => !(item.id === section.id && item.type === 'section'));
75
+ const reindexed = [{ id: section.id, type: 'section' }, ...withoutNew.map(item => ({ id: item.id, type: item.type }))];
76
+ await Promise.all(reindexed.map((item, index) => {
77
+ if (item.type === 'section') {
78
+ return prisma.section.update({ where: { id: item.id }, data: { order: index + 1 } });
79
+ }
80
+ else {
81
+ return prisma.assignment.update({ where: { id: item.id }, data: { order: index + 1 } });
82
+ }
83
+ }));
85
84
  return section;
86
85
  }),
86
+ reorder: protectedProcedure
87
+ .input(z.object({
88
+ classId: z.string(),
89
+ movedId: z.string(), // Section ID
90
+ // One of: place at start/end of unified list, or relative to targetId (can be section or assignment)
91
+ position: z.enum(['start', 'end', 'before', 'after']),
92
+ targetId: z.string().optional(), // Can be a section ID or assignment ID
93
+ }))
94
+ .mutation(async ({ ctx, input }) => {
95
+ if (!ctx.user) {
96
+ throw new TRPCError({
97
+ code: "UNAUTHORIZED",
98
+ message: "User must be authenticated",
99
+ });
100
+ }
101
+ const { classId, movedId, position, targetId } = input;
102
+ const moved = await prisma.section.findFirst({
103
+ where: { id: movedId, classId },
104
+ select: { id: true, classId: true },
105
+ });
106
+ if (!moved) {
107
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Section not found' });
108
+ }
109
+ if ((position === 'before' || position === 'after') && !targetId) {
110
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'targetId required for before/after' });
111
+ }
112
+ const result = await prisma.$transaction(async (tx) => {
113
+ const [sections, assignments] = await Promise.all([
114
+ tx.section.findMany({
115
+ where: { classId },
116
+ select: { id: true, order: true },
117
+ }),
118
+ tx.assignment.findMany({
119
+ where: { classId },
120
+ select: { id: true, order: true },
121
+ }),
122
+ ]);
123
+ const unified = [
124
+ ...sections.map(s => ({ id: s.id, order: s.order, type: 'section' })),
125
+ ...assignments.map(a => ({ id: a.id, order: a.order, type: 'assignment' })),
126
+ ].sort((a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER));
127
+ const movedIdx = unified.findIndex(item => item.id === movedId && item.type === 'section');
128
+ if (movedIdx === -1) {
129
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Section not found in unified list' });
130
+ }
131
+ const withoutMoved = unified.filter(item => !(item.id === movedId && item.type === 'section'));
132
+ let next = [];
133
+ if (position === 'start') {
134
+ next = [{ id: movedId, type: 'section' }, ...withoutMoved.map(item => ({ id: item.id, type: item.type }))];
135
+ }
136
+ else if (position === 'end') {
137
+ next = [...withoutMoved.map(item => ({ id: item.id, type: item.type })), { id: movedId, type: 'section' }];
138
+ }
139
+ else {
140
+ const targetIdx = withoutMoved.findIndex(item => item.id === targetId);
141
+ if (targetIdx === -1) {
142
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'targetId not found in unified list' });
143
+ }
144
+ if (position === 'before') {
145
+ next = [
146
+ ...withoutMoved.slice(0, targetIdx).map(item => ({ id: item.id, type: item.type })),
147
+ { id: movedId, type: 'section' },
148
+ ...withoutMoved.slice(targetIdx).map(item => ({ id: item.id, type: item.type })),
149
+ ];
150
+ }
151
+ else {
152
+ next = [
153
+ ...withoutMoved.slice(0, targetIdx + 1).map(item => ({ id: item.id, type: item.type })),
154
+ { id: movedId, type: 'section' },
155
+ ...withoutMoved.slice(targetIdx + 1).map(item => ({ id: item.id, type: item.type })),
156
+ ];
157
+ }
158
+ }
159
+ // Normalize to 1..n
160
+ await Promise.all(next.map((item, index) => {
161
+ if (item.type === 'section') {
162
+ return tx.section.update({ where: { id: item.id }, data: { order: index + 1 } });
163
+ }
164
+ else {
165
+ return tx.assignment.update({ where: { id: item.id }, data: { order: index + 1 } });
166
+ }
167
+ }));
168
+ return tx.section.findUnique({ where: { id: movedId } });
169
+ });
170
+ return result;
171
+ }),
87
172
  update: protectedProcedure
88
173
  .input(updateSectionSchema)
89
174
  .mutation(async ({ ctx, input }) => {
@@ -151,11 +236,35 @@ export const sectionRouter = createTRPCRouter({
151
236
  message: "Class not found or you are not a teacher",
152
237
  });
153
238
  }
154
- await prisma.section.update({
155
- where: { id: input.id },
156
- data: {
157
- order: input.order,
158
- },
239
+ // Update order and normalize unified list
240
+ await prisma.$transaction(async (tx) => {
241
+ await tx.section.update({
242
+ where: { id: input.id },
243
+ data: { order: input.order },
244
+ });
245
+ // Normalize entire unified list
246
+ const [sections, assignments] = await Promise.all([
247
+ tx.section.findMany({
248
+ where: { classId: input.classId },
249
+ select: { id: true, order: true },
250
+ }),
251
+ tx.assignment.findMany({
252
+ where: { classId: input.classId },
253
+ select: { id: true, order: true },
254
+ }),
255
+ ]);
256
+ const unified = [
257
+ ...sections.map(s => ({ id: s.id, order: s.order, type: 'section' })),
258
+ ...assignments.map(a => ({ id: a.id, order: a.order, type: 'assignment' })),
259
+ ].sort((a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER));
260
+ await Promise.all(unified.map((item, index) => {
261
+ if (item.type === 'section') {
262
+ return tx.section.update({ where: { id: item.id }, data: { order: index + 1 } });
263
+ }
264
+ else {
265
+ return tx.assignment.update({ where: { id: item.id }, data: { order: index + 1 } });
266
+ }
267
+ }));
159
268
  });
160
269
  return { id: input.id };
161
270
  }),
@@ -14,9 +14,9 @@ export declare function addNotification(userId: string, title: string, content:
14
14
  title: string;
15
15
  content: string;
16
16
  createdAt: Date;
17
- read: boolean;
18
- receiverId: string;
19
17
  senderId: string | null;
18
+ receiverId: string;
19
+ read: boolean;
20
20
  }>;
21
21
  export declare const seedDatabase: () => Promise<void>;
22
22
  //# sourceMappingURL=seedDatabase.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"seedDatabase.d.ts","sourceRoot":"","sources":["../src/seedDatabase.ts"],"names":[],"mappings":"AAIA,wBAAsB,aAAa,kBAsClC;AAED,wBAAsB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;;;;;;;;;GAOjF;AAED,wBAAsB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;;;;;;;;GAQnF;AAED,eAAO,MAAM,YAAY,qBAq+CxB,CAAC"}
1
+ {"version":3,"file":"seedDatabase.d.ts","sourceRoot":"","sources":["../src/seedDatabase.ts"],"names":[],"mappings":"AAIA,wBAAsB,aAAa,kBAuClC;AAED,wBAAsB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;;;;;;;;;GAOjF;AAED,wBAAsB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;;;;;;;;GAQnF;AAED,eAAO,MAAM,YAAY,qBAq+CxB,CAAC"}
@@ -4,6 +4,7 @@ import { logger } from "./utils/logger.js";
4
4
  export async function clearDatabase() {
5
5
  // Delete in order to respect foreign key constraints
6
6
  // Delete notifications first (they reference users)
7
+ logger.info('Clearing database');
7
8
  await prisma.notification.deleteMany();
8
9
  // Delete chat-related records
9
10
  await prisma.mention.deleteMany();
@@ -77,7 +78,7 @@ export const seedDatabase = async () => {
77
78
  ]);
78
79
  // 3. Create Students (realistic names)
79
80
  const students = await Promise.all([
80
- createUser('alex.martinez@student.riverside.edu', 'student123', 'alex.martinez'),
81
+ createUser('alex.martinez@student.rverside.eidu', 'student123', 'alex.martinez'),
81
82
  createUser('sophia.williams@student.riverside.edu', 'student123', 'sophia.williams'),
82
83
  createUser('james.brown@student.riverside.edu', 'student123', 'james.brown'),
83
84
  createUser('olivia.taylor@student.riverside.edu', 'student123', 'olivia.taylor'),
@@ -10,6 +10,7 @@ declare class Logger {
10
10
  private isDevelopment;
11
11
  private mode;
12
12
  private levelColors;
13
+ private levelBgColors;
13
14
  private levelEmojis;
14
15
  private constructor();
15
16
  static getInstance(): Logger;
@@ -1 +1 @@
1
- {"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../../src/utils/logger.ts"],"names":[],"mappings":"AAAA,oBAAY,QAAQ;IAClB,IAAI,SAAS;IACb,IAAI,SAAS;IACb,KAAK,UAAU;IACf,KAAK,UAAU;CAChB;AAED,KAAK,OAAO,GAAG,QAAQ,GAAG,SAAS,GAAG,QAAQ,GAAG,SAAS,CAAC;AAwB3D,cAAM,MAAM;IACV,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAS;IAChC,OAAO,CAAC,aAAa,CAAU;IAC/B,OAAO,CAAC,IAAI,CAAU;IACtB,OAAO,CAAC,WAAW,CAA2B;IAC9C,OAAO,CAAC,WAAW,CAA2B;IAE9C,OAAO;WAuBO,WAAW,IAAI,MAAM;IAO5B,OAAO,CAAC,IAAI,EAAE,OAAO;IAI5B,OAAO,CAAC,SAAS;IAsBjB,OAAO,CAAC,aAAa;IAiBrB,OAAO,CAAC,GAAG;IAqCJ,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;IAInD,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;IAInD,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;IAIpD,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;CAG5D;AAED,eAAO,MAAM,MAAM,QAAuB,CAAC"}
1
+ {"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../../src/utils/logger.ts"],"names":[],"mappings":"AAAA,oBAAY,QAAQ;IAClB,IAAI,SAAS;IACb,IAAI,SAAS;IACb,KAAK,UAAU;IACf,KAAK,UAAU;CAChB;AAED,KAAK,OAAO,GAAG,QAAQ,GAAG,SAAS,GAAG,QAAQ,GAAG,SAAS,CAAC;AAyC3D,cAAM,MAAM;IACV,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAS;IAChC,OAAO,CAAC,aAAa,CAAU;IAC/B,OAAO,CAAC,IAAI,CAAU;IACtB,OAAO,CAAC,WAAW,CAA2B;IAC9C,OAAO,CAAC,aAAa,CAA2B;IAChD,OAAO,CAAC,WAAW,CAA2B;IAE9C,OAAO;WA8BO,WAAW,IAAI,MAAM;IAO5B,OAAO,CAAC,IAAI,EAAE,OAAO;IAI5B,OAAO,CAAC,SAAS;IAsBjB,OAAO,CAAC,aAAa;IAmBrB,OAAO,CAAC,GAAG;IAqCJ,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;IAInD,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;IAInD,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;IAIpD,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;CAG5D;AAED,eAAO,MAAM,MAAM,QAAuB,CAAC"}
@@ -17,7 +17,24 @@ const colors = {
17
17
  magenta: '\x1b[35m',
18
18
  cyan: '\x1b[36m',
19
19
  white: '\x1b[37m',
20
- gray: '\x1b[90m'
20
+ gray: '\x1b[90m',
21
+ // Background colors
22
+ bgRed: '\x1b[41m',
23
+ bgGreen: '\x1b[42m',
24
+ bgYellow: '\x1b[43m',
25
+ bgBlue: '\x1b[44m',
26
+ bgMagenta: '\x1b[45m',
27
+ bgCyan: '\x1b[46m',
28
+ bgWhite: '\x1b[47m',
29
+ bgGray: '\x1b[100m',
30
+ // Bright background colors
31
+ bgBrightRed: '\x1b[101m',
32
+ bgBrightGreen: '\x1b[102m',
33
+ bgBrightYellow: '\x1b[103m',
34
+ bgBrightBlue: '\x1b[104m',
35
+ bgBrightMagenta: '\x1b[105m',
36
+ bgBrightCyan: '\x1b[106m',
37
+ bgBrightWhite: '\x1b[107m'
21
38
  };
22
39
  class Logger {
23
40
  constructor() {
@@ -30,6 +47,12 @@ class Logger {
30
47
  [LogLevel.ERROR]: colors.red,
31
48
  [LogLevel.DEBUG]: colors.magenta
32
49
  };
50
+ this.levelBgColors = {
51
+ [LogLevel.INFO]: colors.bgBlue,
52
+ [LogLevel.WARN]: colors.bgYellow,
53
+ [LogLevel.ERROR]: colors.bgRed,
54
+ [LogLevel.DEBUG]: colors.bgMagenta
55
+ };
33
56
  this.levelEmojis = {
34
57
  [LogLevel.INFO]: 'ℹ️',
35
58
  [LogLevel.WARN]: '⚠️',
@@ -70,9 +93,11 @@ class Logger {
70
93
  formatMessage(logMessage) {
71
94
  const { level, message, timestamp, context } = logMessage;
72
95
  const color = this.levelColors[level];
96
+ const bgColor = this.levelBgColors[level];
73
97
  const emoji = this.levelEmojis[level];
74
98
  const timestampStr = colors.gray + `[${timestamp}]` + colors.reset;
75
- const levelStr = color + `[${level.toUpperCase()}]` + colors.reset;
99
+ // Use background color for level badge like Vitest
100
+ const levelStr = colors.white + bgColor + ` ${level.toUpperCase()} ` + colors.reset;
76
101
  const emojiStr = emoji + ' ';
77
102
  const messageStr = colors.bright + message + colors.reset;
78
103
  const contextStr = context
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studious-lms/server",
3
- "version": "1.1.24",
3
+ "version": "1.2.26",
4
4
  "description": "Backend server for Studious application",
5
5
  "main": "dist/exportType.js",
6
6
  "types": "dist/exportType.d.ts",
@@ -18,7 +18,7 @@
18
18
  "generate": "npx prisma generate",
19
19
  "prepublishOnly": "npm run generate && npm run build",
20
20
  "test": "vitest",
21
- "seed": "node dist/seedDatabase.js"
21
+ "seed": "tsx src/seedDatabase.ts"
22
22
  },
23
23
  "dependencies": {
24
24
  "@google-cloud/storage": "^7.16.0",
@@ -0,0 +1,30 @@
1
+ -- AlterTable
2
+ ALTER TABLE "public"."Announcement" ADD COLUMN "modifiedAt" TIMESTAMP(3);
3
+
4
+ -- AlterTable
5
+ ALTER TABLE "public"."File" ADD COLUMN "announcementId" TEXT;
6
+
7
+ -- CreateTable
8
+ CREATE TABLE "public"."AnnouncementComment" (
9
+ "id" TEXT NOT NULL,
10
+ "content" TEXT NOT NULL,
11
+ "authorId" TEXT NOT NULL,
12
+ "announcementId" TEXT NOT NULL,
13
+ "parentCommentId" TEXT,
14
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
15
+ "modifiedAt" TIMESTAMP(3),
16
+
17
+ CONSTRAINT "AnnouncementComment_pkey" PRIMARY KEY ("id")
18
+ );
19
+
20
+ -- AddForeignKey
21
+ ALTER TABLE "public"."File" ADD CONSTRAINT "File_announcementId_fkey" FOREIGN KEY ("announcementId") REFERENCES "public"."Announcement"("id") ON DELETE CASCADE ON UPDATE CASCADE;
22
+
23
+ -- AddForeignKey
24
+ ALTER TABLE "public"."AnnouncementComment" ADD CONSTRAINT "AnnouncementComment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
25
+
26
+ -- AddForeignKey
27
+ ALTER TABLE "public"."AnnouncementComment" ADD CONSTRAINT "AnnouncementComment_announcementId_fkey" FOREIGN KEY ("announcementId") REFERENCES "public"."Announcement"("id") ON DELETE CASCADE ON UPDATE CASCADE;
28
+
29
+ -- AddForeignKey
30
+ ALTER TABLE "public"."AnnouncementComment" ADD CONSTRAINT "AnnouncementComment_parentCommentId_fkey" FOREIGN KEY ("parentCommentId") REFERENCES "public"."AnnouncementComment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,35 @@
1
+ -- CreateEnum
2
+ CREATE TYPE "public"."ReactionType" AS ENUM ('THUMBSUP', 'CELEBRATE', 'CARE', 'HEART', 'IDEA', 'HAPPY');
3
+
4
+ -- CreateTable
5
+ CREATE TABLE "public"."Reaction" (
6
+ "id" TEXT NOT NULL,
7
+ "type" "public"."ReactionType" NOT NULL,
8
+ "userId" TEXT NOT NULL,
9
+ "announcementId" TEXT,
10
+ "commentId" TEXT,
11
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
12
+
13
+ CONSTRAINT "Reaction_pkey" PRIMARY KEY ("id")
14
+ );
15
+
16
+ -- CreateIndex
17
+ CREATE INDEX "Reaction_announcementId_idx" ON "public"."Reaction"("announcementId");
18
+
19
+ -- CreateIndex
20
+ CREATE INDEX "Reaction_commentId_idx" ON "public"."Reaction"("commentId");
21
+
22
+ -- CreateIndex
23
+ CREATE UNIQUE INDEX "Reaction_userId_announcementId_key" ON "public"."Reaction"("userId", "announcementId");
24
+
25
+ -- CreateIndex
26
+ CREATE UNIQUE INDEX "Reaction_userId_commentId_key" ON "public"."Reaction"("userId", "commentId");
27
+
28
+ -- AddForeignKey
29
+ ALTER TABLE "public"."Reaction" ADD CONSTRAINT "Reaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
30
+
31
+ -- AddForeignKey
32
+ ALTER TABLE "public"."Reaction" ADD CONSTRAINT "Reaction_announcementId_fkey" FOREIGN KEY ("announcementId") REFERENCES "public"."Announcement"("id") ON DELETE CASCADE ON UPDATE CASCADE;
33
+
34
+ -- AddForeignKey
35
+ ALTER TABLE "public"."Reaction" ADD CONSTRAINT "Reaction_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "public"."AnnouncementComment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -42,6 +42,15 @@ enum UploadStatus {
42
42
  CANCELLED
43
43
  }
44
44
 
45
+ enum ReactionType {
46
+ THUMBSUP
47
+ CELEBRATE
48
+ CARE
49
+ HEART
50
+ IDEA
51
+ HAPPY
52
+ }
53
+
45
54
  model School {
46
55
  id String @id @default(uuid())
47
56
  name String
@@ -89,6 +98,8 @@ model User {
89
98
  sentMessages Message[] @relation("SentMessages")
90
99
  mentions Mention[] @relation("UserMentions")
91
100
  createdLabChats LabChat[] @relation("CreatedLabChats")
101
+ announcementComments AnnouncementComment[]
102
+ reactions Reaction[]
92
103
 
93
104
  }
94
105
 
@@ -207,6 +218,9 @@ model File {
207
218
  messageId String?
208
219
  message Message? @relation("MessageAttachments", fields: [messageId], references: [id], onDelete: Cascade)
209
220
 
221
+ announcement Announcement? @relation("AnnouncementAttachments", fields: [announcementId], references: [id], onDelete: Cascade)
222
+ announcementId String?
223
+
210
224
  schools School[]
211
225
 
212
226
  schoolDevelopementProgram SchoolDevelopementProgram? @relation("SchoolDevelopementProgramSupportingDocumentation", fields: [schoolDevelopementProgramId], references: [id], onDelete: Cascade)
@@ -251,8 +265,44 @@ model Announcement {
251
265
  teacher User @relation(fields: [teacherId], references: [id])
252
266
  teacherId String
253
267
  createdAt DateTime @default(now())
268
+ modifiedAt DateTime? @updatedAt
254
269
  class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
255
270
  classId String
271
+ attachments File[] @relation("AnnouncementAttachments")
272
+ comments AnnouncementComment[]
273
+ reactions Reaction[]
274
+ }
275
+
276
+ model AnnouncementComment {
277
+ id String @id @default(uuid())
278
+ content String
279
+ author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
280
+ authorId String
281
+ announcement Announcement @relation(fields: [announcementId], references: [id], onDelete: Cascade)
282
+ announcementId String
283
+ parentComment AnnouncementComment? @relation("CommentReplies", fields: [parentCommentId], references: [id], onDelete: Cascade)
284
+ parentCommentId String?
285
+ replies AnnouncementComment[] @relation("CommentReplies")
286
+ reactions Reaction[]
287
+ createdAt DateTime @default(now())
288
+ modifiedAt DateTime? @updatedAt
289
+ }
290
+
291
+ model Reaction {
292
+ id String @id @default(uuid())
293
+ type ReactionType
294
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
295
+ userId String
296
+ announcement Announcement? @relation(fields: [announcementId], references: [id], onDelete: Cascade)
297
+ announcementId String?
298
+ comment AnnouncementComment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
299
+ commentId String?
300
+ createdAt DateTime @default(now())
301
+
302
+ @@unique([userId, announcementId])
303
+ @@unique([userId, commentId])
304
+ @@index([announcementId])
305
+ @@index([commentId])
256
306
  }
257
307
 
258
308
  model Submission {
@@ -1,6 +1,6 @@
1
1
  import { TRPCError } from "@trpc/server";
2
2
  import { v4 as uuidv4 } from "uuid";
3
- import { getSignedUrl } from "./googleCloudStorage.js";
3
+ import { getSignedUrl, objectExists } from "./googleCloudStorage.js";
4
4
  import { generateMediaThumbnail } from "./thumbnailGenerator.js";
5
5
  import { prisma } from "./prisma.js";
6
6
  import { logger } from "../utils/logger.js";
@@ -102,7 +102,8 @@ export async function createDirectUploadFile(
102
102
  userId: string,
103
103
  directory?: string,
104
104
  assignmentId?: string,
105
- submissionId?: string
105
+ submissionId?: string,
106
+ announcementId?: string
106
107
  ): Promise<DirectUploadFile> {
107
108
  try {
108
109
  // Validate file extension matches MIME type
@@ -167,6 +168,11 @@ export async function createDirectUploadFile(
167
168
  submission: {
168
169
  connect: { id: submissionId }
169
170
  }
171
+ }),
172
+ ...(announcementId && {
173
+ announcement: {
174
+ connect: { id: announcementId }
175
+ }
170
176
  })
171
177
  },
172
178
  });
@@ -182,7 +188,11 @@ export async function createDirectUploadFile(
182
188
  uploadSessionId
183
189
  };
184
190
  } catch (error) {
185
- console.error('Error creating direct upload file:', error);
191
+ logger.error('Error creating direct upload file:', {error: error instanceof Error ? {
192
+ name: error.name,
193
+ message: error.message,
194
+ stack: error.stack,
195
+ } : error});
186
196
  throw new TRPCError({
187
197
  code: 'INTERNAL_SERVER_ERROR',
188
198
  message: 'Failed to create direct upload file',
@@ -202,17 +212,53 @@ export async function confirmDirectUpload(
202
212
  errorMessage?: string
203
213
  ): Promise<void> {
204
214
  try {
215
+ // First fetch the file record to get the object path
216
+ const fileRecord = await prisma.file.findUnique({
217
+ where: { id: fileId },
218
+ select: { path: true }
219
+ });
220
+
221
+ if (!fileRecord) {
222
+ throw new TRPCError({
223
+ code: 'NOT_FOUND',
224
+ message: 'File record not found',
225
+ });
226
+ }
227
+
228
+ let actualUploadSuccess = uploadSuccess;
229
+ let actualErrorMessage = errorMessage;
230
+
231
+ // If uploadSuccess is true, verify the object actually exists in GCS
232
+ if (uploadSuccess) {
233
+ try {
234
+ const exists = await objectExists(process.env.GOOGLE_CLOUD_BUCKET_NAME!, fileRecord.path);
235
+ if (!exists) {
236
+ actualUploadSuccess = false;
237
+ actualErrorMessage = 'File upload reported as successful but object not found in Google Cloud Storage';
238
+ logger.error(`File upload verification failed for ${fileId}: object ${fileRecord.path} not found in GCS`);
239
+ }
240
+ } catch (error) {
241
+ logger.error(`Error verifying file existence in GCS for ${fileId}:`, {error: error instanceof Error ? {
242
+ name: error.name,
243
+ message: error.message,
244
+ stack: error.stack,
245
+ } : error});
246
+ actualUploadSuccess = false;
247
+ actualErrorMessage = 'Failed to verify file existence in Google Cloud Storage';
248
+ }
249
+ }
250
+
205
251
  const updateData: any = {
206
- uploadStatus: uploadSuccess ? 'COMPLETED' : 'FAILED',
207
- uploadProgress: uploadSuccess ? 100 : 0,
252
+ uploadStatus: actualUploadSuccess ? 'COMPLETED' : 'FAILED',
253
+ uploadProgress: actualUploadSuccess ? 100 : 0,
208
254
  };
209
255
 
210
- if (!uploadSuccess && errorMessage) {
211
- updateData.uploadError = errorMessage;
256
+ if (!actualUploadSuccess && actualErrorMessage) {
257
+ updateData.uploadError = actualErrorMessage;
212
258
  updateData.uploadRetryCount = { increment: 1 };
213
259
  }
214
260
 
215
- if (uploadSuccess) {
261
+ if (actualUploadSuccess) {
216
262
  updateData.uploadedAt = new Date();
217
263
  }
218
264
 
@@ -221,7 +267,7 @@ export async function confirmDirectUpload(
221
267
  data: updateData
222
268
  });
223
269
  } catch (error) {
224
- console.error('Error confirming direct upload:', error);
270
+ logger.error('Error confirming direct upload:', {error});
225
271
  throw new TRPCError({
226
272
  code: 'INTERNAL_SERVER_ERROR',
227
273
  message: 'Failed to confirm upload',
@@ -239,15 +285,29 @@ export async function updateUploadProgress(
239
285
  progress: number
240
286
  ): Promise<void> {
241
287
  try {
288
+ // await prisma.file.update({
289
+ // where: { id: fileId },
290
+ // data: {
291
+ // uploadStatus: 'UPLOADING',
292
+ // uploadProgress: Math.min(100, Math.max(0, progress))
293
+ // }
294
+ // });
295
+ const current = await prisma.file.findUnique({ where: { id: fileId }, select: { uploadStatus: true } });
296
+ if (!current || ['COMPLETED','FAILED','CANCELLED'].includes(current.uploadStatus as string)) return;
297
+ const clamped = Math.min(100, Math.max(0, progress));
242
298
  await prisma.file.update({
243
299
  where: { id: fileId },
244
300
  data: {
245
301
  uploadStatus: 'UPLOADING',
246
- uploadProgress: Math.min(100, Math.max(0, progress))
302
+ uploadProgress: clamped
247
303
  }
248
304
  });
249
305
  } catch (error) {
250
- console.error('Error updating upload progress:', error);
306
+ logger.error('Error updating upload progress:', {error: error instanceof Error ? {
307
+ name: error.name,
308
+ message: error.message,
309
+ stack: error.stack,
310
+ } : error});
251
311
  throw new TRPCError({
252
312
  code: 'INTERNAL_SERVER_ERROR',
253
313
  message: 'Failed to update upload progress',
@@ -269,15 +329,20 @@ export async function createDirectUploadFiles(
269
329
  userId: string,
270
330
  directory?: string,
271
331
  assignmentId?: string,
272
- submissionId?: string
332
+ submissionId?: string,
333
+ announcementId?: string
273
334
  ): Promise<DirectUploadFile[]> {
274
335
  try {
275
336
  const uploadPromises = files.map(file =>
276
- createDirectUploadFile(file, userId, directory, assignmentId, submissionId)
337
+ createDirectUploadFile(file, userId, directory, assignmentId, submissionId, announcementId)
277
338
  );
278
339
  return await Promise.all(uploadPromises);
279
340
  } catch (error) {
280
- console.error('Error creating direct upload files:', error);
341
+ logger.error('Error creating direct upload files:', {error: error instanceof Error ? {
342
+ name: error.name,
343
+ message: error.message,
344
+ stack: error.stack,
345
+ } : error});
281
346
  throw new TRPCError({
282
347
  code: 'INTERNAL_SERVER_ERROR',
283
348
  message: 'Failed to create direct upload files',