@studious-lms/server 1.4.4 → 1.4.5
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.
- package/dist/index.js +3 -17
- package/dist/index.js.map +1 -1
- package/dist/models/conversation.d.ts +22 -0
- package/dist/models/conversation.d.ts.map +1 -1
- package/dist/models/conversation.js +13 -2
- package/dist/models/conversation.js.map +1 -1
- package/dist/models/labChat.d.ts +3 -0
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/message.d.ts +2 -0
- package/dist/models/message.d.ts.map +1 -1
- package/dist/models/newtonChat.d.ts +1 -0
- package/dist/models/newtonChat.d.ts.map +1 -1
- package/dist/routers/_app.d.ts +110 -0
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +4 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/conversation.d.ts +14 -0
- package/dist/routers/conversation.d.ts.map +1 -1
- package/dist/routers/conversation.js +6 -3
- package/dist/routers/conversation.js.map +1 -1
- package/dist/routers/labChat.d.ts +2 -0
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/search.d.ts +41 -0
- package/dist/routers/search.d.ts.map +1 -0
- package/dist/routers/search.js +15 -0
- package/dist/routers/search.js.map +1 -0
- package/dist/services/conversation.d.ts +8 -0
- package/dist/services/conversation.d.ts.map +1 -1
- package/dist/services/conversation.js +24 -6
- package/dist/services/conversation.js.map +1 -1
- package/dist/services/labChat.d.ts +2 -0
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +9 -2
- package/dist/services/message.js.map +1 -1
- package/dist/services/search.d.ts +21 -0
- package/dist/services/search.d.ts.map +1 -0
- package/dist/services/search.js +352 -0
- package/dist/services/search.js.map +1 -0
- package/package.json +1 -1
- package/prisma/migrations/20260417083000_add_conversation_member_hidden_at/migration.sql +2 -0
- package/prisma/schema.prisma +1 -0
- package/src/index.ts +1 -17
- package/src/models/conversation.ts +12 -0
- package/src/routers/_app.ts +2 -0
- package/src/routers/conversation.ts +7 -0
- package/src/routers/search.ts +14 -0
- package/src/services/conversation.ts +27 -3
- package/src/services/message.ts +8 -0
- package/src/services/search.ts +397 -0
- package/tests/routers/conversation.test.ts +114 -2
- package/tests/routers/search.test.ts +109 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { prisma } from "../lib/prisma.js";
|
|
2
|
+
|
|
3
|
+
type SearchScope =
|
|
4
|
+
| "class"
|
|
5
|
+
| "assignment"
|
|
6
|
+
| "event"
|
|
7
|
+
| "syllabus"
|
|
8
|
+
| "worksheet"
|
|
9
|
+
| "file"
|
|
10
|
+
| "announcement"
|
|
11
|
+
| "section";
|
|
12
|
+
|
|
13
|
+
type SearchResult = {
|
|
14
|
+
id: string;
|
|
15
|
+
type: SearchScope;
|
|
16
|
+
title: string;
|
|
17
|
+
subtitle: string;
|
|
18
|
+
classId: string;
|
|
19
|
+
className: string;
|
|
20
|
+
href: string;
|
|
21
|
+
matchedText?: string | null;
|
|
22
|
+
date?: Date | null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const SEARCH_LIMIT = 12;
|
|
26
|
+
|
|
27
|
+
const contains = (query: string) => ({
|
|
28
|
+
contains: query,
|
|
29
|
+
mode: "insensitive" as const,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const includesToken = (query: string, ...tokens: string[]) =>
|
|
33
|
+
tokens.some((token) => token.includes(query.toLowerCase()));
|
|
34
|
+
|
|
35
|
+
const excerpt = (value: string | null | undefined, query: string) => {
|
|
36
|
+
if (!value) return null;
|
|
37
|
+
const trimmed = value.replace(/\s+/g, " ").trim();
|
|
38
|
+
if (!trimmed) return null;
|
|
39
|
+
if (!query) return trimmed.slice(0, 140);
|
|
40
|
+
|
|
41
|
+
const index = trimmed.toLowerCase().indexOf(query.toLowerCase());
|
|
42
|
+
if (index < 0) return trimmed.slice(0, 140);
|
|
43
|
+
|
|
44
|
+
const start = Math.max(0, index - 48);
|
|
45
|
+
const end = Math.min(trimmed.length, index + query.length + 92);
|
|
46
|
+
return `${start > 0 ? "..." : ""}${trimmed.slice(start, end)}${end < trimmed.length ? "..." : ""}`;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const trimResults = (results: SearchResult[], limit: number) =>
|
|
50
|
+
results
|
|
51
|
+
.sort((a, b) => {
|
|
52
|
+
const priority: Record<SearchScope, number> = {
|
|
53
|
+
class: 0,
|
|
54
|
+
assignment: 1,
|
|
55
|
+
event: 2,
|
|
56
|
+
syllabus: 3,
|
|
57
|
+
worksheet: 4,
|
|
58
|
+
file: 5,
|
|
59
|
+
announcement: 6,
|
|
60
|
+
section: 7,
|
|
61
|
+
};
|
|
62
|
+
const priorityDiff = priority[a.type] - priority[b.type];
|
|
63
|
+
if (priorityDiff !== 0) return priorityDiff;
|
|
64
|
+
return (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0);
|
|
65
|
+
})
|
|
66
|
+
.slice(0, limit);
|
|
67
|
+
|
|
68
|
+
export async function searchGlobalClasses(
|
|
69
|
+
userId: string,
|
|
70
|
+
input: { query?: string; limit?: number }
|
|
71
|
+
) {
|
|
72
|
+
const rawQuery = input.query?.trim() ?? "";
|
|
73
|
+
const query = rawQuery.slice(0, 80);
|
|
74
|
+
const limit = Math.min(Math.max(input.limit ?? SEARCH_LIMIT, 1), 30);
|
|
75
|
+
|
|
76
|
+
const memberships = await prisma.class.findMany({
|
|
77
|
+
where: {
|
|
78
|
+
OR: [
|
|
79
|
+
{ teachers: { some: { id: userId } } },
|
|
80
|
+
{ students: { some: { id: userId } } },
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
select: {
|
|
84
|
+
id: true,
|
|
85
|
+
name: true,
|
|
86
|
+
section: true,
|
|
87
|
+
subject: true,
|
|
88
|
+
syllabus: true,
|
|
89
|
+
teachers: { where: { id: userId }, select: { id: true } },
|
|
90
|
+
students: { where: { id: userId }, select: { id: true } },
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const allClassIds = memberships.map((cls) => cls.id);
|
|
95
|
+
if (allClassIds.length === 0) {
|
|
96
|
+
return { query, results: [] as SearchResult[] };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const teacherClassIds = memberships
|
|
100
|
+
.filter((cls) => cls.teachers.length > 0)
|
|
101
|
+
.map((cls) => cls.id);
|
|
102
|
+
const studentClassIds = memberships
|
|
103
|
+
.filter((cls) => cls.students.length > 0 && cls.teachers.length === 0)
|
|
104
|
+
.map((cls) => cls.id);
|
|
105
|
+
const classById = new Map(memberships.map((cls) => [cls.id, cls]));
|
|
106
|
+
|
|
107
|
+
const queryFilters = query
|
|
108
|
+
? {
|
|
109
|
+
class: [
|
|
110
|
+
{ name: contains(query) },
|
|
111
|
+
{ section: contains(query) },
|
|
112
|
+
{ subject: contains(query) },
|
|
113
|
+
],
|
|
114
|
+
assignment: [
|
|
115
|
+
{ title: contains(query) },
|
|
116
|
+
{ instructions: contains(query) },
|
|
117
|
+
],
|
|
118
|
+
event: [
|
|
119
|
+
{ name: contains(query) },
|
|
120
|
+
{ location: contains(query) },
|
|
121
|
+
{ remarks: contains(query) },
|
|
122
|
+
],
|
|
123
|
+
worksheet: [{ name: contains(query) }],
|
|
124
|
+
file: [{ name: contains(query) }, { type: contains(query) }],
|
|
125
|
+
announcement: [{ remarks: contains(query) }],
|
|
126
|
+
section: [{ name: contains(query) }],
|
|
127
|
+
}
|
|
128
|
+
: null;
|
|
129
|
+
|
|
130
|
+
const assignmentAccess = [
|
|
131
|
+
...(teacherClassIds.length > 0
|
|
132
|
+
? [{ classId: { in: teacherClassIds } }]
|
|
133
|
+
: []),
|
|
134
|
+
...(studentClassIds.length > 0
|
|
135
|
+
? [
|
|
136
|
+
{
|
|
137
|
+
classId: { in: studentClassIds },
|
|
138
|
+
OR: [
|
|
139
|
+
{ assignedTo: { some: { id: userId } } },
|
|
140
|
+
{ assignedTo: { none: {} } },
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
]
|
|
144
|
+
: []),
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
const [
|
|
148
|
+
classes,
|
|
149
|
+
assignments,
|
|
150
|
+
events,
|
|
151
|
+
worksheets,
|
|
152
|
+
files,
|
|
153
|
+
announcements,
|
|
154
|
+
sections,
|
|
155
|
+
] = await Promise.all([
|
|
156
|
+
prisma.class.findMany({
|
|
157
|
+
where: {
|
|
158
|
+
id: { in: allClassIds },
|
|
159
|
+
...(queryFilters ? { OR: queryFilters.class } : {}),
|
|
160
|
+
},
|
|
161
|
+
select: { id: true, name: true, section: true, subject: true },
|
|
162
|
+
take: limit,
|
|
163
|
+
}),
|
|
164
|
+
prisma.assignment.findMany({
|
|
165
|
+
where: {
|
|
166
|
+
template: false,
|
|
167
|
+
AND: [
|
|
168
|
+
...(queryFilters ? [{ OR: queryFilters.assignment }] : []),
|
|
169
|
+
...(assignmentAccess.length > 0 ? [{ OR: assignmentAccess }] : []),
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
select: {
|
|
173
|
+
id: true,
|
|
174
|
+
title: true,
|
|
175
|
+
instructions: true,
|
|
176
|
+
dueDate: true,
|
|
177
|
+
modifiedAt: true,
|
|
178
|
+
classId: true,
|
|
179
|
+
class: { select: { name: true } },
|
|
180
|
+
},
|
|
181
|
+
orderBy: { dueDate: "asc" },
|
|
182
|
+
take: limit,
|
|
183
|
+
}),
|
|
184
|
+
prisma.event.findMany({
|
|
185
|
+
where: {
|
|
186
|
+
classId: { in: allClassIds },
|
|
187
|
+
...(queryFilters ? { OR: queryFilters.event } : {}),
|
|
188
|
+
},
|
|
189
|
+
select: {
|
|
190
|
+
id: true,
|
|
191
|
+
name: true,
|
|
192
|
+
location: true,
|
|
193
|
+
remarks: true,
|
|
194
|
+
startTime: true,
|
|
195
|
+
classId: true,
|
|
196
|
+
class: { select: { name: true } },
|
|
197
|
+
},
|
|
198
|
+
orderBy: { startTime: "asc" },
|
|
199
|
+
take: limit,
|
|
200
|
+
}),
|
|
201
|
+
prisma.worksheet.findMany({
|
|
202
|
+
where: {
|
|
203
|
+
classId: { in: allClassIds },
|
|
204
|
+
...(queryFilters && !includesToken(query, "worksheet", "worksheets")
|
|
205
|
+
? { OR: queryFilters.worksheet }
|
|
206
|
+
: {}),
|
|
207
|
+
},
|
|
208
|
+
select: {
|
|
209
|
+
id: true,
|
|
210
|
+
name: true,
|
|
211
|
+
updatedAt: true,
|
|
212
|
+
classId: true,
|
|
213
|
+
class: { select: { name: true } },
|
|
214
|
+
},
|
|
215
|
+
orderBy: { updatedAt: "desc" },
|
|
216
|
+
take: limit,
|
|
217
|
+
}),
|
|
218
|
+
prisma.file.findMany({
|
|
219
|
+
where: {
|
|
220
|
+
...(queryFilters ? { OR: queryFilters.file } : {}),
|
|
221
|
+
AND: [
|
|
222
|
+
{
|
|
223
|
+
OR: [
|
|
224
|
+
{ folder: { classId: { in: allClassIds } } },
|
|
225
|
+
{ classDraftId: { in: teacherClassIds } },
|
|
226
|
+
{
|
|
227
|
+
assignment: {
|
|
228
|
+
OR: assignmentAccess,
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
{ announcement: { classId: { in: allClassIds } } },
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
select: {
|
|
237
|
+
id: true,
|
|
238
|
+
name: true,
|
|
239
|
+
type: true,
|
|
240
|
+
uploadedAt: true,
|
|
241
|
+
folder: { select: { classId: true, class: { select: { name: true } } } },
|
|
242
|
+
classDraft: { select: { id: true, name: true } },
|
|
243
|
+
assignment: { select: { classId: true, class: { select: { name: true } } } },
|
|
244
|
+
announcement: { select: { classId: true, class: { select: { name: true } } } },
|
|
245
|
+
},
|
|
246
|
+
orderBy: { uploadedAt: "desc" },
|
|
247
|
+
take: limit,
|
|
248
|
+
}),
|
|
249
|
+
prisma.announcement.findMany({
|
|
250
|
+
where: {
|
|
251
|
+
classId: { in: allClassIds },
|
|
252
|
+
...(queryFilters ? { OR: queryFilters.announcement } : {}),
|
|
253
|
+
},
|
|
254
|
+
select: {
|
|
255
|
+
id: true,
|
|
256
|
+
remarks: true,
|
|
257
|
+
createdAt: true,
|
|
258
|
+
classId: true,
|
|
259
|
+
class: { select: { name: true } },
|
|
260
|
+
},
|
|
261
|
+
orderBy: { createdAt: "desc" },
|
|
262
|
+
take: limit,
|
|
263
|
+
}),
|
|
264
|
+
prisma.section.findMany({
|
|
265
|
+
where: {
|
|
266
|
+
classId: { in: allClassIds },
|
|
267
|
+
...(queryFilters && !includesToken(query, "section", "sections", "assignment", "assignments")
|
|
268
|
+
? { OR: queryFilters.section }
|
|
269
|
+
: {}),
|
|
270
|
+
},
|
|
271
|
+
select: {
|
|
272
|
+
id: true,
|
|
273
|
+
name: true,
|
|
274
|
+
classId: true,
|
|
275
|
+
class: { select: { name: true } },
|
|
276
|
+
},
|
|
277
|
+
take: limit,
|
|
278
|
+
}),
|
|
279
|
+
]);
|
|
280
|
+
|
|
281
|
+
const results: SearchResult[] = [
|
|
282
|
+
...classes.map((cls) => ({
|
|
283
|
+
id: cls.id,
|
|
284
|
+
type: "class" as const,
|
|
285
|
+
title: cls.name,
|
|
286
|
+
subtitle: [cls.subject, cls.section].filter(Boolean).join(" · "),
|
|
287
|
+
classId: cls.id,
|
|
288
|
+
className: cls.name,
|
|
289
|
+
href: `/class/${cls.id}`,
|
|
290
|
+
})),
|
|
291
|
+
...assignments.map((assignment) => ({
|
|
292
|
+
id: assignment.id,
|
|
293
|
+
type: "assignment" as const,
|
|
294
|
+
title: assignment.title,
|
|
295
|
+
subtitle: `${assignment.class.name} · Assignment`,
|
|
296
|
+
classId: assignment.classId,
|
|
297
|
+
className: assignment.class.name,
|
|
298
|
+
href: `/class/${assignment.classId}/assignment/${assignment.id}`,
|
|
299
|
+
matchedText: excerpt(assignment.instructions, query),
|
|
300
|
+
date: assignment.dueDate ?? assignment.modifiedAt,
|
|
301
|
+
})),
|
|
302
|
+
...events.map((event) => ({
|
|
303
|
+
id: event.id,
|
|
304
|
+
type: "event" as const,
|
|
305
|
+
title: event.name || "Untitled event",
|
|
306
|
+
subtitle: `${event.class?.name ?? "Class"} · Event`,
|
|
307
|
+
classId: event.classId!,
|
|
308
|
+
className: event.class?.name ?? "Class",
|
|
309
|
+
href: `/class/${event.classId}/attendance/${event.id}`,
|
|
310
|
+
matchedText: excerpt(event.location || event.remarks, query),
|
|
311
|
+
date: event.startTime,
|
|
312
|
+
})),
|
|
313
|
+
...memberships
|
|
314
|
+
.filter((cls) => {
|
|
315
|
+
if (!cls.syllabus) return false;
|
|
316
|
+
if (!query) return true;
|
|
317
|
+
return (
|
|
318
|
+
includesToken(query, "syllabus") ||
|
|
319
|
+
cls.name.toLowerCase().includes(query.toLowerCase()) ||
|
|
320
|
+
cls.subject.toLowerCase().includes(query.toLowerCase()) ||
|
|
321
|
+
cls.syllabus.toLowerCase().includes(query.toLowerCase())
|
|
322
|
+
);
|
|
323
|
+
})
|
|
324
|
+
.slice(0, limit)
|
|
325
|
+
.map((cls) => ({
|
|
326
|
+
id: `${cls.id}:syllabus`,
|
|
327
|
+
type: "syllabus" as const,
|
|
328
|
+
title: `${cls.name} syllabus`,
|
|
329
|
+
subtitle: `${cls.subject} · Syllabus`,
|
|
330
|
+
classId: cls.id,
|
|
331
|
+
className: cls.name,
|
|
332
|
+
href: `/class/${cls.id}/syllabus`,
|
|
333
|
+
matchedText: excerpt(cls.syllabus, query),
|
|
334
|
+
})),
|
|
335
|
+
...worksheets.map((worksheet) => ({
|
|
336
|
+
id: worksheet.id,
|
|
337
|
+
type: "worksheet" as const,
|
|
338
|
+
title: worksheet.name,
|
|
339
|
+
subtitle: `${worksheet.class?.name ?? "Class"} · Worksheet`,
|
|
340
|
+
classId: worksheet.classId,
|
|
341
|
+
className: worksheet.class?.name ?? "Class",
|
|
342
|
+
href: `/class/${worksheet.classId}/worksheets`,
|
|
343
|
+
date: worksheet.updatedAt,
|
|
344
|
+
})),
|
|
345
|
+
...files.flatMap((file) => {
|
|
346
|
+
const classId =
|
|
347
|
+
file.folder?.classId ??
|
|
348
|
+
file.classDraft?.id ??
|
|
349
|
+
file.assignment?.classId ??
|
|
350
|
+
file.announcement?.classId;
|
|
351
|
+
const className =
|
|
352
|
+
file.folder?.class?.name ??
|
|
353
|
+
file.classDraft?.name ??
|
|
354
|
+
file.assignment?.class?.name ??
|
|
355
|
+
file.announcement?.class?.name ??
|
|
356
|
+
(classId ? classById.get(classId)?.name : undefined);
|
|
357
|
+
|
|
358
|
+
if (!classId || !className) return [];
|
|
359
|
+
|
|
360
|
+
return [
|
|
361
|
+
{
|
|
362
|
+
id: file.id,
|
|
363
|
+
type: "file" as const,
|
|
364
|
+
title: file.name,
|
|
365
|
+
subtitle: `${className} · File`,
|
|
366
|
+
classId,
|
|
367
|
+
className,
|
|
368
|
+
href: `/class/${classId}/files`,
|
|
369
|
+
matchedText: file.type,
|
|
370
|
+
date: file.uploadedAt,
|
|
371
|
+
},
|
|
372
|
+
];
|
|
373
|
+
}),
|
|
374
|
+
...announcements.map((announcement) => ({
|
|
375
|
+
id: announcement.id,
|
|
376
|
+
type: "announcement" as const,
|
|
377
|
+
title: excerpt(announcement.remarks, query) ?? "Announcement",
|
|
378
|
+
subtitle: `${announcement.class.name} · Announcement`,
|
|
379
|
+
classId: announcement.classId,
|
|
380
|
+
className: announcement.class.name,
|
|
381
|
+
href: `/class/${announcement.classId}`,
|
|
382
|
+
matchedText: excerpt(announcement.remarks, query),
|
|
383
|
+
date: announcement.createdAt,
|
|
384
|
+
})),
|
|
385
|
+
...sections.map((section) => ({
|
|
386
|
+
id: section.id,
|
|
387
|
+
type: "section" as const,
|
|
388
|
+
title: section.name,
|
|
389
|
+
subtitle: `${section.class.name} · Section`,
|
|
390
|
+
classId: section.classId,
|
|
391
|
+
className: section.class.name,
|
|
392
|
+
href: `/class/${section.classId}/assignments`,
|
|
393
|
+
})),
|
|
394
|
+
];
|
|
395
|
+
|
|
396
|
+
return { query, results: trimResults(results, limit) };
|
|
397
|
+
}
|
|
@@ -1,17 +1,35 @@
|
|
|
1
1
|
import { test, expect, describe, beforeAll } from 'vitest';
|
|
2
|
+
import { prisma } from '../../src/lib/prisma';
|
|
2
3
|
import { user1Caller, user2Caller, user3Caller } from '../setup';
|
|
3
4
|
import { expectUnauth } from '../helpers';
|
|
4
5
|
|
|
5
6
|
describe('Conversation Router', () => {
|
|
6
7
|
let dmConversation: any;
|
|
7
8
|
let groupConversation: any;
|
|
9
|
+
let user1Id: string;
|
|
8
10
|
let user3Id: string;
|
|
11
|
+
let hideConversationCounter = 0;
|
|
9
12
|
|
|
10
13
|
beforeAll(async () => {
|
|
11
|
-
const
|
|
12
|
-
|
|
14
|
+
const [user1Profile, user3Profile] = await Promise.all([
|
|
15
|
+
user1Caller.user.getProfile(),
|
|
16
|
+
user3Caller.user.getProfile(),
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
user1Id = user1Profile.id;
|
|
20
|
+
user3Id = user3Profile.id;
|
|
13
21
|
});
|
|
14
22
|
|
|
23
|
+
async function createHideableConversation() {
|
|
24
|
+
hideConversationCounter += 1;
|
|
25
|
+
|
|
26
|
+
return user1Caller.conversation.create({
|
|
27
|
+
type: 'GROUP',
|
|
28
|
+
name: `Hide Test Group Chat ${hideConversationCounter}`,
|
|
29
|
+
memberIds: ['testuser2'],
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
15
33
|
describe('create', () => {
|
|
16
34
|
test('should create a DM conversation', async () => {
|
|
17
35
|
dmConversation = await user1Caller.conversation.create({
|
|
@@ -36,6 +54,13 @@ describe('Conversation Router', () => {
|
|
|
36
54
|
expect(groupConversation.name).toBe('Test Group Chat');
|
|
37
55
|
});
|
|
38
56
|
|
|
57
|
+
test('should create a hideable GROUP conversation', async () => {
|
|
58
|
+
const conversation = await createHideableConversation();
|
|
59
|
+
|
|
60
|
+
expect(conversation).toBeDefined();
|
|
61
|
+
expect(conversation.type).toBe('GROUP');
|
|
62
|
+
});
|
|
63
|
+
|
|
39
64
|
test('should fail to create duplicate DM', async () => {
|
|
40
65
|
await expect(
|
|
41
66
|
user1Caller.conversation.create({
|
|
@@ -125,6 +150,93 @@ describe('Conversation Router', () => {
|
|
|
125
150
|
});
|
|
126
151
|
});
|
|
127
152
|
|
|
153
|
+
describe('hide', () => {
|
|
154
|
+
test('should hide a conversation only for the requesting member', async () => {
|
|
155
|
+
const conversation = await createHideableConversation();
|
|
156
|
+
|
|
157
|
+
const result = await user1Caller.conversation.hide({
|
|
158
|
+
conversationId: conversation.id,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(result).toEqual({ success: true });
|
|
162
|
+
|
|
163
|
+
const membership = await prisma.conversationMember.findUniqueOrThrow({
|
|
164
|
+
where: {
|
|
165
|
+
userId_conversationId: {
|
|
166
|
+
userId: user1Id,
|
|
167
|
+
conversationId: conversation.id,
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
select: { hiddenAt: true },
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(membership.hiddenAt).not.toBeNull();
|
|
174
|
+
|
|
175
|
+
const user1Conversations = await user1Caller.conversation.list();
|
|
176
|
+
const user2Conversations = await user2Caller.conversation.list();
|
|
177
|
+
|
|
178
|
+
expect(user1Conversations.map((item) => item.id)).not.toContain(
|
|
179
|
+
conversation.id,
|
|
180
|
+
);
|
|
181
|
+
expect(user2Conversations.map((item) => item.id)).toContain(
|
|
182
|
+
conversation.id,
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('should keep hidden conversations accessible by id', async () => {
|
|
187
|
+
const conversation = await createHideableConversation();
|
|
188
|
+
|
|
189
|
+
await user1Caller.conversation.hide({
|
|
190
|
+
conversationId: conversation.id,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const hiddenConversation = await user1Caller.conversation.get({
|
|
194
|
+
conversationId: conversation.id,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(hiddenConversation.id).toBe(conversation.id);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('should unhide a hidden conversation after new activity', async () => {
|
|
201
|
+
const conversation = await createHideableConversation();
|
|
202
|
+
|
|
203
|
+
await user1Caller.conversation.hide({
|
|
204
|
+
conversationId: conversation.id,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
await user2Caller.message.send({
|
|
208
|
+
conversationId: conversation.id,
|
|
209
|
+
content: 'Bringing this conversation back',
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const membership = await prisma.conversationMember.findUniqueOrThrow({
|
|
213
|
+
where: {
|
|
214
|
+
userId_conversationId: {
|
|
215
|
+
userId: user1Id,
|
|
216
|
+
conversationId: conversation.id,
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
select: { hiddenAt: true },
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(membership.hiddenAt).toBeNull();
|
|
223
|
+
|
|
224
|
+
const conversations = await user1Caller.conversation.list();
|
|
225
|
+
|
|
226
|
+
expect(conversations.map((item) => item.id)).toContain(conversation.id);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('should fail for non-members', async () => {
|
|
230
|
+
const conversation = await createHideableConversation();
|
|
231
|
+
|
|
232
|
+
await expect(
|
|
233
|
+
user3Caller.conversation.hide({
|
|
234
|
+
conversationId: conversation.id,
|
|
235
|
+
}),
|
|
236
|
+
).rejects.toThrow('Not a member of this conversation');
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
128
240
|
describe('removeMember', () => {
|
|
129
241
|
test('should remove member from conversation', async () => {
|
|
130
242
|
await user1Caller.conversation.removeMember({
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, test, beforeAll } from "vitest";
|
|
2
|
+
import { expectUnauth } from "../helpers";
|
|
3
|
+
import { user1Caller, user2Caller, user3Caller } from "../setup";
|
|
4
|
+
|
|
5
|
+
describe("Search Router", () => {
|
|
6
|
+
let testClass: any;
|
|
7
|
+
let assignment: any;
|
|
8
|
+
let event: any;
|
|
9
|
+
let worksheet: any;
|
|
10
|
+
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
testClass = await user1Caller.class.create({
|
|
13
|
+
name: "Global Search Biology",
|
|
14
|
+
subject: "Biology",
|
|
15
|
+
section: "STU-65",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
await user1Caller.class.addStudent({
|
|
19
|
+
classId: testClass.id,
|
|
20
|
+
studentId: (await user2Caller.user.getProfile()).id,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
await user1Caller.class.updateSyllabus({
|
|
24
|
+
classId: testClass.id,
|
|
25
|
+
contents: "Photosynthesis lab policies and chlorophyll grading overview.",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
assignment = await user1Caller.assignment.create({
|
|
29
|
+
classId: testClass.id,
|
|
30
|
+
title: "Chlorophyll Response",
|
|
31
|
+
instructions: "Explain the photosynthesis command palette experiment.",
|
|
32
|
+
dueDate: new Date(Date.now() + 86400000).toISOString(),
|
|
33
|
+
type: "HOMEWORK",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
event = await user1Caller.event.create({
|
|
37
|
+
classId: testClass.id,
|
|
38
|
+
name: "Photosynthesis Attendance Lab",
|
|
39
|
+
location: "Room 65",
|
|
40
|
+
startTime: new Date(Date.now() + 3600000).toISOString(),
|
|
41
|
+
endTime: new Date(Date.now() + 7200000).toISOString(),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
worksheet = await user1Caller.worksheet.create({
|
|
45
|
+
classId: testClass.id,
|
|
46
|
+
name: "Chloroplast Worksheet",
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("returns matching class resources for a teacher", async () => {
|
|
51
|
+
const results = await user1Caller.search.global({
|
|
52
|
+
query: "photosynthesis",
|
|
53
|
+
limit: 20,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(results.query).toBe("photosynthesis");
|
|
57
|
+
expect(results.results).toEqual(
|
|
58
|
+
expect.arrayContaining([
|
|
59
|
+
expect.objectContaining({
|
|
60
|
+
type: "assignment",
|
|
61
|
+
id: assignment.id,
|
|
62
|
+
href: `/class/${testClass.id}/assignment/${assignment.id}`,
|
|
63
|
+
}),
|
|
64
|
+
expect.objectContaining({
|
|
65
|
+
type: "event",
|
|
66
|
+
id: event.event.id,
|
|
67
|
+
href: `/class/${testClass.id}/attendance/${event.event.id}`,
|
|
68
|
+
}),
|
|
69
|
+
expect.objectContaining({
|
|
70
|
+
type: "syllabus",
|
|
71
|
+
href: `/class/${testClass.id}/syllabus`,
|
|
72
|
+
}),
|
|
73
|
+
])
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("returns visible class resources for an enrolled student", async () => {
|
|
78
|
+
const results = await user2Caller.search.global({
|
|
79
|
+
query: "chloroplast",
|
|
80
|
+
limit: 20,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(results.results).toEqual(
|
|
84
|
+
expect.arrayContaining([
|
|
85
|
+
expect.objectContaining({
|
|
86
|
+
type: "worksheet",
|
|
87
|
+
id: worksheet.id,
|
|
88
|
+
href: `/class/${testClass.id}/worksheets`,
|
|
89
|
+
}),
|
|
90
|
+
])
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("does not expose resources to non-members", async () => {
|
|
95
|
+
const results = await user3Caller.search.global({
|
|
96
|
+
query: "photosynthesis",
|
|
97
|
+
limit: 20,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(results.results).not.toEqual(
|
|
101
|
+
expect.arrayContaining([
|
|
102
|
+
expect.objectContaining({ classId: testClass.id }),
|
|
103
|
+
])
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("fails without authentication", () =>
|
|
108
|
+
expectUnauth((c) => c.search.global({ query: "biology" })));
|
|
109
|
+
});
|