@goscribe/server 1.2.0 → 1.3.1
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/check-difficulty.cjs +14 -0
- package/check-questions.cjs +14 -0
- package/db-summary.cjs +22 -0
- package/dist/context.d.ts +5 -1
- package/dist/lib/activity_human_description.d.ts +13 -0
- package/dist/lib/activity_human_description.js +221 -0
- package/dist/lib/activity_human_description.test.d.ts +1 -0
- package/dist/lib/activity_human_description.test.js +16 -0
- package/dist/lib/activity_log_service.d.ts +87 -0
- package/dist/lib/activity_log_service.js +276 -0
- package/dist/lib/activity_log_service.test.d.ts +1 -0
- package/dist/lib/activity_log_service.test.js +27 -0
- package/dist/lib/ai-session.d.ts +15 -2
- package/dist/lib/ai-session.js +147 -85
- package/dist/lib/constants.d.ts +13 -0
- package/dist/lib/constants.js +12 -0
- package/dist/lib/email.d.ts +11 -0
- package/dist/lib/email.js +193 -0
- package/dist/lib/env.d.ts +13 -0
- package/dist/lib/env.js +16 -0
- package/dist/lib/inference.d.ts +4 -1
- package/dist/lib/inference.js +3 -3
- package/dist/lib/logger.d.ts +4 -4
- package/dist/lib/logger.js +30 -8
- package/dist/lib/notification-service.d.ts +152 -0
- package/dist/lib/notification-service.js +473 -0
- package/dist/lib/notification-service.test.d.ts +1 -0
- package/dist/lib/notification-service.test.js +87 -0
- package/dist/lib/prisma.d.ts +2 -1
- package/dist/lib/prisma.js +5 -1
- package/dist/lib/pusher.d.ts +23 -0
- package/dist/lib/pusher.js +69 -5
- package/dist/lib/retry.d.ts +15 -0
- package/dist/lib/retry.js +37 -0
- package/dist/lib/storage.js +2 -2
- package/dist/lib/stripe.d.ts +9 -0
- package/dist/lib/stripe.js +36 -0
- package/dist/lib/subscription_service.d.ts +37 -0
- package/dist/lib/subscription_service.js +654 -0
- package/dist/lib/usage_service.d.ts +26 -0
- package/dist/lib/usage_service.js +59 -0
- package/dist/lib/worksheet-generation.d.ts +91 -0
- package/dist/lib/worksheet-generation.js +95 -0
- package/dist/lib/worksheet-generation.test.d.ts +1 -0
- package/dist/lib/worksheet-generation.test.js +20 -0
- package/dist/lib/workspace-access.d.ts +18 -0
- package/dist/lib/workspace-access.js +13 -0
- package/dist/routers/_app.d.ts +1349 -253
- package/dist/routers/_app.js +10 -0
- package/dist/routers/admin.d.ts +361 -0
- package/dist/routers/admin.js +633 -0
- package/dist/routers/annotations.d.ts +219 -0
- package/dist/routers/annotations.js +187 -0
- package/dist/routers/auth.d.ts +88 -7
- package/dist/routers/auth.js +339 -19
- package/dist/routers/chat.d.ts +6 -12
- package/dist/routers/copilot.d.ts +199 -0
- package/dist/routers/copilot.js +571 -0
- package/dist/routers/flashcards.d.ts +47 -81
- package/dist/routers/flashcards.js +143 -27
- package/dist/routers/members.d.ts +36 -7
- package/dist/routers/members.js +200 -19
- package/dist/routers/notifications.d.ts +99 -0
- package/dist/routers/notifications.js +127 -0
- package/dist/routers/payment.d.ts +89 -0
- package/dist/routers/payment.js +403 -0
- package/dist/routers/podcast.d.ts +8 -13
- package/dist/routers/podcast.js +54 -31
- package/dist/routers/studyguide.d.ts +1 -29
- package/dist/routers/studyguide.js +80 -71
- package/dist/routers/worksheets.d.ts +105 -38
- package/dist/routers/worksheets.js +258 -68
- package/dist/routers/workspace.d.ts +139 -60
- package/dist/routers/workspace.js +455 -315
- package/dist/scripts/purge-deleted-users.d.ts +1 -0
- package/dist/scripts/purge-deleted-users.js +149 -0
- package/dist/server.js +130 -10
- package/dist/services/flashcard-progress.service.d.ts +18 -66
- package/dist/services/flashcard-progress.service.js +51 -42
- package/dist/trpc.d.ts +20 -21
- package/dist/trpc.js +150 -1
- package/mcq-test.cjs +36 -0
- package/package.json +9 -2
- package/prisma/migrations/20260413143206_init/migration.sql +873 -0
- package/prisma/schema.prisma +471 -324
- package/src/context.ts +4 -1
- package/src/lib/activity_human_description.test.ts +28 -0
- package/src/lib/activity_human_description.ts +239 -0
- package/src/lib/activity_log_service.test.ts +37 -0
- package/src/lib/activity_log_service.ts +353 -0
- package/src/lib/ai-session.ts +79 -51
- package/src/lib/email.ts +213 -29
- package/src/lib/env.ts +23 -6
- package/src/lib/inference.ts +2 -2
- package/src/lib/notification-service.test.ts +106 -0
- package/src/lib/notification-service.ts +677 -0
- package/src/lib/prisma.ts +6 -1
- package/src/lib/pusher.ts +86 -2
- package/src/lib/stripe.ts +39 -0
- package/src/lib/subscription_service.ts +722 -0
- package/src/lib/usage_service.ts +74 -0
- package/src/lib/worksheet-generation.test.ts +31 -0
- package/src/lib/worksheet-generation.ts +139 -0
- package/src/routers/_app.ts +9 -0
- package/src/routers/admin.ts +710 -0
- package/src/routers/annotations.ts +41 -0
- package/src/routers/auth.ts +338 -28
- package/src/routers/copilot.ts +719 -0
- package/src/routers/flashcards.ts +201 -68
- package/src/routers/members.ts +280 -80
- package/src/routers/notifications.ts +142 -0
- package/src/routers/payment.ts +448 -0
- package/src/routers/podcast.ts +112 -83
- package/src/routers/studyguide.ts +12 -0
- package/src/routers/worksheets.ts +289 -66
- package/src/routers/workspace.ts +329 -122
- package/src/scripts/purge-deleted-users.ts +167 -0
- package/src/server.ts +137 -11
- package/src/services/flashcard-progress.service.ts +49 -37
- package/src/trpc.ts +184 -5
- package/test-generate.js +30 -0
- package/test-ratio.cjs +9 -0
- package/zod-test.cjs +22 -0
- package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
- package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
- package/prisma/seed.mjs +0 -135
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { TRPCError } from '@trpc/server';
|
|
3
|
-
import { router, authedProcedure } from '../trpc.js';
|
|
3
|
+
import { router, authedProcedure, verifiedProcedure } from '../trpc.js';
|
|
4
4
|
import { supabaseClient } from '../lib/storage.js';
|
|
5
|
-
import { ArtifactType } from '
|
|
5
|
+
import { ArtifactType } from '../lib/constants.js';
|
|
6
6
|
import { aiSessionService } from '../lib/ai-session.js';
|
|
7
7
|
import PusherService from '../lib/pusher.js';
|
|
8
8
|
import { members } from './members.js';
|
|
9
9
|
import { logger } from '../lib/logger.js';
|
|
10
|
+
import { getUserStorageLimit } from '../lib/subscription_service.js';
|
|
11
|
+
import { getUserUsage, getUserPlanLimits } from '../lib/usage_service.js';
|
|
12
|
+
import { notifyArtifactFailed, notifyArtifactReady, notifyWorkspaceDeleted, } from '../lib/notification-service.js';
|
|
10
13
|
// Helper function to update and emit analysis progress
|
|
11
14
|
async function updateAnalysisProgress(db, workspaceId, progress) {
|
|
12
15
|
await db.workspace.update({
|
|
@@ -15,6 +18,48 @@ async function updateAnalysisProgress(db, workspaceId, progress) {
|
|
|
15
18
|
});
|
|
16
19
|
await PusherService.emitAnalysisProgress(workspaceId, progress);
|
|
17
20
|
}
|
|
21
|
+
const PIPELINE_STEPS = ['fileUpload', 'fileAnalysis', 'studyGuide', 'flashcards'];
|
|
22
|
+
function buildProgressSteps(currentStep, currentStatus, config, overrides) {
|
|
23
|
+
const stepIndex = PIPELINE_STEPS.indexOf(currentStep);
|
|
24
|
+
const steps = {};
|
|
25
|
+
for (let i = 0; i < PIPELINE_STEPS.length; i++) {
|
|
26
|
+
const step = PIPELINE_STEPS[i];
|
|
27
|
+
let status;
|
|
28
|
+
if (overrides?.[step]) {
|
|
29
|
+
status = overrides[step];
|
|
30
|
+
}
|
|
31
|
+
else if (i < stepIndex) {
|
|
32
|
+
status = 'completed';
|
|
33
|
+
}
|
|
34
|
+
else if (i === stepIndex) {
|
|
35
|
+
status = currentStatus;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
// Future steps: check if they're configured
|
|
39
|
+
if (step === 'studyGuide' && !config.generateStudyGuide) {
|
|
40
|
+
status = 'skipped';
|
|
41
|
+
}
|
|
42
|
+
else if (step === 'flashcards' && !config.generateFlashcards) {
|
|
43
|
+
status = 'skipped';
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
status = 'pending';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
steps[step] = { order: i + 1, status };
|
|
50
|
+
}
|
|
51
|
+
return steps;
|
|
52
|
+
}
|
|
53
|
+
function buildProgress(status, filename, fileType, currentStep, currentStepStatus, config, extra) {
|
|
54
|
+
return {
|
|
55
|
+
status,
|
|
56
|
+
filename,
|
|
57
|
+
fileType,
|
|
58
|
+
startedAt: new Date().toISOString(),
|
|
59
|
+
steps: buildProgressSteps(currentStep, currentStepStatus, config, extra),
|
|
60
|
+
...extra,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
18
63
|
// Helper function to calculate search relevance score
|
|
19
64
|
function calculateRelevance(query, ...texts) {
|
|
20
65
|
const queryLower = query.toLowerCase();
|
|
@@ -71,6 +116,38 @@ export const workspace = router({
|
|
|
71
116
|
});
|
|
72
117
|
return { workspaces, folders };
|
|
73
118
|
}),
|
|
119
|
+
/**
|
|
120
|
+
* Fetches the entire directory tree for the user.
|
|
121
|
+
* Includes Folders, Workspaces (files), and Uploads (sub-files).
|
|
122
|
+
*/
|
|
123
|
+
getTree: authedProcedure
|
|
124
|
+
.query(async ({ ctx }) => {
|
|
125
|
+
const userId = ctx.session.user.id;
|
|
126
|
+
// 1. Fetch all folders
|
|
127
|
+
const allFolders = await ctx.db.folder.findMany({
|
|
128
|
+
where: { ownerId: userId },
|
|
129
|
+
orderBy: { updatedAt: 'desc' },
|
|
130
|
+
});
|
|
131
|
+
// 2. Fetch all workspaces
|
|
132
|
+
const allWorkspaces = await ctx.db.workspace.findMany({
|
|
133
|
+
where: { ownerId: userId },
|
|
134
|
+
include: {
|
|
135
|
+
uploads: {
|
|
136
|
+
select: {
|
|
137
|
+
id: true,
|
|
138
|
+
name: true,
|
|
139
|
+
mimeType: true,
|
|
140
|
+
createdAt: true,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
orderBy: { updatedAt: 'desc' },
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
folders: allFolders,
|
|
148
|
+
workspaces: allWorkspaces,
|
|
149
|
+
};
|
|
150
|
+
}),
|
|
74
151
|
create: authedProcedure
|
|
75
152
|
.input(z.object({
|
|
76
153
|
name: z.string().min(1).max(100),
|
|
@@ -98,7 +175,10 @@ export const workspace = router({
|
|
|
98
175
|
},
|
|
99
176
|
},
|
|
100
177
|
});
|
|
101
|
-
aiSessionService.initSession(ws.id, ctx.session.user.id)
|
|
178
|
+
await aiSessionService.initSession(ws.id, ctx.session.user.id).catch((err) => {
|
|
179
|
+
logger.error('Failed to init AI session on workspace creation:', err);
|
|
180
|
+
});
|
|
181
|
+
await PusherService.emitLibraryUpdate(ctx.session.user.id);
|
|
102
182
|
return ws;
|
|
103
183
|
}),
|
|
104
184
|
createFolder: authedProcedure
|
|
@@ -116,16 +196,24 @@ export const workspace = router({
|
|
|
116
196
|
parentId: input.parentId ?? null,
|
|
117
197
|
},
|
|
118
198
|
});
|
|
199
|
+
await PusherService.emitLibraryUpdate(ctx.session.user.id);
|
|
119
200
|
return folder;
|
|
120
201
|
}),
|
|
121
202
|
updateFolder: authedProcedure
|
|
122
203
|
.input(z.object({
|
|
123
204
|
id: z.string(),
|
|
124
205
|
name: z.string().min(1).max(100).optional(),
|
|
125
|
-
|
|
206
|
+
markerColor: z.string().nullable().optional(),
|
|
126
207
|
}))
|
|
127
208
|
.mutation(async ({ ctx, input }) => {
|
|
128
|
-
const folder = await ctx.db.folder.update({
|
|
209
|
+
const folder = await ctx.db.folder.update({
|
|
210
|
+
where: { id: input.id },
|
|
211
|
+
data: {
|
|
212
|
+
name: input.name,
|
|
213
|
+
markerColor: input.markerColor
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
await PusherService.emitLibraryUpdate(ctx.session.user.id);
|
|
129
217
|
return folder;
|
|
130
218
|
}),
|
|
131
219
|
deleteFolder: authedProcedure
|
|
@@ -134,6 +222,7 @@ export const workspace = router({
|
|
|
134
222
|
}))
|
|
135
223
|
.mutation(async ({ ctx, input }) => {
|
|
136
224
|
const folder = await ctx.db.folder.delete({ where: { id: input.id } });
|
|
225
|
+
await PusherService.emitLibraryUpdate(ctx.session.user.id);
|
|
137
226
|
return folder;
|
|
138
227
|
}),
|
|
139
228
|
get: authedProcedure
|
|
@@ -166,15 +255,111 @@ export const workspace = router({
|
|
|
166
255
|
orderBy: { updatedAt: 'desc' },
|
|
167
256
|
});
|
|
168
257
|
const spaceLeft = await ctx.db.fileAsset.aggregate({
|
|
169
|
-
where: { workspaceId: { in: workspaces.map(ws => ws.id) }, userId: ctx.session.user.id },
|
|
258
|
+
where: { workspaceId: { in: workspaces.map((ws) => ws.id) }, userId: ctx.session.user.id },
|
|
170
259
|
_sum: { size: true },
|
|
171
260
|
});
|
|
261
|
+
const storageLimit = await getUserStorageLimit(ctx.session.user.id);
|
|
172
262
|
return {
|
|
173
263
|
workspaces: workspaces.length,
|
|
174
264
|
folders: folders.length,
|
|
175
265
|
lastUpdated: lastUpdated?.updatedAt,
|
|
176
266
|
spaceUsed: spaceLeft._sum?.size ?? 0,
|
|
177
|
-
spaceTotal:
|
|
267
|
+
spaceTotal: storageLimit,
|
|
268
|
+
};
|
|
269
|
+
}),
|
|
270
|
+
// Study analytics: streaks, flashcard mastery, worksheet accuracy
|
|
271
|
+
getStudyAnalytics: authedProcedure
|
|
272
|
+
.query(async ({ ctx }) => {
|
|
273
|
+
const userId = ctx.session.user.id;
|
|
274
|
+
// Gather all study activity dates
|
|
275
|
+
const flashcardProgress = await ctx.db.flashcardProgress.findMany({
|
|
276
|
+
where: { userId },
|
|
277
|
+
select: { lastStudiedAt: true },
|
|
278
|
+
});
|
|
279
|
+
const worksheetProgress = await ctx.db.worksheetQuestionProgress.findMany({
|
|
280
|
+
where: { userId },
|
|
281
|
+
select: { updatedAt: true, completedAt: true },
|
|
282
|
+
});
|
|
283
|
+
// Build a set of unique study days (YYYY-MM-DD)
|
|
284
|
+
const studyDays = new Set();
|
|
285
|
+
for (const fp of flashcardProgress) {
|
|
286
|
+
if (fp.lastStudiedAt) {
|
|
287
|
+
studyDays.add(fp.lastStudiedAt.toISOString().split('T')[0]);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
for (const wp of worksheetProgress) {
|
|
291
|
+
if (wp.completedAt) {
|
|
292
|
+
studyDays.add(wp.completedAt.toISOString().split('T')[0]);
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
studyDays.add(wp.updatedAt.toISOString().split('T')[0]);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Calculate streak (consecutive days ending today or yesterday)
|
|
299
|
+
const sortedDays = [...studyDays].sort().reverse();
|
|
300
|
+
let streak = 0;
|
|
301
|
+
if (sortedDays.length > 0) {
|
|
302
|
+
const today = new Date();
|
|
303
|
+
today.setHours(0, 0, 0, 0);
|
|
304
|
+
const yesterday = new Date(today);
|
|
305
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
306
|
+
const todayStr = today.toISOString().split('T')[0];
|
|
307
|
+
const yesterdayStr = yesterday.toISOString().split('T')[0];
|
|
308
|
+
// Streak only counts if the most recent study day is today or yesterday
|
|
309
|
+
if (sortedDays[0] === todayStr || sortedDays[0] === yesterdayStr) {
|
|
310
|
+
streak = 1;
|
|
311
|
+
for (let i = 1; i < sortedDays.length; i++) {
|
|
312
|
+
const current = new Date(sortedDays[i - 1]);
|
|
313
|
+
const prev = new Date(sortedDays[i]);
|
|
314
|
+
const diffDays = (current.getTime() - prev.getTime()) / (1000 * 60 * 60 * 24);
|
|
315
|
+
if (diffDays === 1) {
|
|
316
|
+
streak++;
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
// Weekly activity (last 7 days)
|
|
325
|
+
const weeklyActivity = [];
|
|
326
|
+
const today = new Date();
|
|
327
|
+
today.setHours(0, 0, 0, 0);
|
|
328
|
+
for (let i = 6; i >= 0; i--) {
|
|
329
|
+
const d = new Date(today);
|
|
330
|
+
d.setDate(d.getDate() - i);
|
|
331
|
+
const dayStr = d.toISOString().split('T')[0];
|
|
332
|
+
weeklyActivity.push(studyDays.has(dayStr));
|
|
333
|
+
}
|
|
334
|
+
// Flashcard stats
|
|
335
|
+
const totalCards = await ctx.db.flashcardProgress.count({ where: { userId } });
|
|
336
|
+
const masteredCards = await ctx.db.flashcardProgress.count({
|
|
337
|
+
where: { userId, masteryLevel: { gte: 80 } },
|
|
338
|
+
});
|
|
339
|
+
const dueCards = await ctx.db.flashcardProgress.count({
|
|
340
|
+
where: { userId, nextReviewAt: { lte: new Date() } },
|
|
341
|
+
});
|
|
342
|
+
// Worksheet stats
|
|
343
|
+
const completedQuestions = await ctx.db.worksheetQuestionProgress.count({
|
|
344
|
+
where: { userId, completedAt: { not: null } },
|
|
345
|
+
});
|
|
346
|
+
const correctQuestions = await ctx.db.worksheetQuestionProgress.count({
|
|
347
|
+
where: { userId, correct: true },
|
|
348
|
+
});
|
|
349
|
+
return {
|
|
350
|
+
streak,
|
|
351
|
+
totalStudyDays: studyDays.size,
|
|
352
|
+
weeklyActivity,
|
|
353
|
+
flashcards: {
|
|
354
|
+
total: totalCards,
|
|
355
|
+
mastered: masteredCards,
|
|
356
|
+
dueForReview: dueCards,
|
|
357
|
+
},
|
|
358
|
+
worksheets: {
|
|
359
|
+
completed: completedQuestions,
|
|
360
|
+
correct: correctQuestions,
|
|
361
|
+
accuracy: completedQuestions > 0 ? Math.round((correctQuestions / completedQuestions) * 100) : 0,
|
|
362
|
+
},
|
|
178
363
|
};
|
|
179
364
|
}),
|
|
180
365
|
update: authedProcedure
|
|
@@ -182,7 +367,7 @@ export const workspace = router({
|
|
|
182
367
|
id: z.string(),
|
|
183
368
|
name: z.string().min(1).max(100).optional(),
|
|
184
369
|
description: z.string().max(500).optional(),
|
|
185
|
-
|
|
370
|
+
markerColor: z.string().nullable().optional(),
|
|
186
371
|
icon: z.string().optional(),
|
|
187
372
|
}))
|
|
188
373
|
.mutation(async ({ ctx, input }) => {
|
|
@@ -196,10 +381,12 @@ export const workspace = router({
|
|
|
196
381
|
data: {
|
|
197
382
|
title: input.name ?? existed.title,
|
|
198
383
|
description: input.description,
|
|
199
|
-
|
|
384
|
+
// Preserve explicit null ("None color") instead of falling back.
|
|
385
|
+
markerColor: input.markerColor !== undefined ? input.markerColor : existed.markerColor,
|
|
200
386
|
icon: input.icon ?? existed.icon,
|
|
201
387
|
},
|
|
202
388
|
});
|
|
389
|
+
await PusherService.emitLibraryUpdate(ctx.session.user.id);
|
|
203
390
|
return updated;
|
|
204
391
|
}),
|
|
205
392
|
delete: authedProcedure
|
|
@@ -207,11 +394,37 @@ export const workspace = router({
|
|
|
207
394
|
id: z.string(),
|
|
208
395
|
}))
|
|
209
396
|
.mutation(async ({ ctx, input }) => {
|
|
397
|
+
const workspaceToDelete = await ctx.db.workspace.findFirst({
|
|
398
|
+
where: { id: input.id, ownerId: ctx.session.user.id },
|
|
399
|
+
select: {
|
|
400
|
+
id: true,
|
|
401
|
+
title: true,
|
|
402
|
+
ownerId: true,
|
|
403
|
+
members: {
|
|
404
|
+
select: { userId: true },
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
if (!workspaceToDelete)
|
|
409
|
+
throw new TRPCError({ code: 'NOT_FOUND' });
|
|
410
|
+
const actor = await ctx.db.user.findUnique({
|
|
411
|
+
where: { id: ctx.session.user.id },
|
|
412
|
+
select: { name: true, email: true },
|
|
413
|
+
});
|
|
414
|
+
const actorName = actor?.name || actor?.email || 'A user';
|
|
415
|
+
await notifyWorkspaceDeleted(ctx.db, {
|
|
416
|
+
recipientUserIds: workspaceToDelete.members.map((m) => m.userId),
|
|
417
|
+
actorUserId: ctx.session.user.id,
|
|
418
|
+
actorName,
|
|
419
|
+
workspaceId: workspaceToDelete.id,
|
|
420
|
+
workspaceTitle: workspaceToDelete.title,
|
|
421
|
+
});
|
|
210
422
|
const deleted = await ctx.db.workspace.deleteMany({
|
|
211
423
|
where: { id: input.id, ownerId: ctx.session.user.id },
|
|
212
424
|
});
|
|
213
425
|
if (deleted.count === 0)
|
|
214
426
|
throw new TRPCError({ code: 'NOT_FOUND' });
|
|
427
|
+
await PusherService.emitLibraryUpdate(ctx.session.user.id);
|
|
215
428
|
return true;
|
|
216
429
|
}),
|
|
217
430
|
getFolderInformation: authedProcedure
|
|
@@ -243,9 +456,11 @@ export const workspace = router({
|
|
|
243
456
|
if (!user || !user.email)
|
|
244
457
|
throw new TRPCError({ code: 'NOT_FOUND' });
|
|
245
458
|
const sharedWith = await ctx.db.workspace.findMany({ where: { members: { some: { userId: ctx.session.user.id } } } });
|
|
246
|
-
const invitations = await ctx.db.workspaceInvitation.findMany({
|
|
459
|
+
const invitations = await ctx.db.workspaceInvitation.findMany({
|
|
460
|
+
where: { email: user.email, acceptedAt: null }, include: {
|
|
247
461
|
workspace: true,
|
|
248
|
-
}
|
|
462
|
+
}
|
|
463
|
+
});
|
|
249
464
|
return { shared: sharedWith, invitations };
|
|
250
465
|
}),
|
|
251
466
|
uploadFiles: authedProcedure
|
|
@@ -262,6 +477,23 @@ export const workspace = router({
|
|
|
262
477
|
const ws = await ctx.db.workspace.findFirst({ where: { id: input.id, ownerId: ctx.session.user.id } });
|
|
263
478
|
if (!ws)
|
|
264
479
|
throw new TRPCError({ code: 'NOT_FOUND' });
|
|
480
|
+
// Check storage limit
|
|
481
|
+
const workspaces = await ctx.db.workspace.findMany({
|
|
482
|
+
where: { OR: [{ ownerId: ctx.session.user.id }, { sharedWith: { some: { id: ctx.session.user.id } } }] },
|
|
483
|
+
});
|
|
484
|
+
const spaceUsed = await ctx.db.fileAsset.aggregate({
|
|
485
|
+
where: { workspaceId: { in: workspaces.map((w) => w.id) }, userId: ctx.session.user.id },
|
|
486
|
+
_sum: { size: true },
|
|
487
|
+
});
|
|
488
|
+
const storageLimit = await getUserStorageLimit(ctx.session.user.id);
|
|
489
|
+
const totalSize = input.files.reduce((acc, file) => acc + file.size, 0);
|
|
490
|
+
if ((spaceUsed._sum?.size ?? 0) + totalSize > storageLimit) {
|
|
491
|
+
logger.warn(`Storage limit exceeded for user ${ctx.session.user.id}. Used: ${spaceUsed._sum?.size}, Tried to upload: ${totalSize}, Limit: ${storageLimit}`);
|
|
492
|
+
throw new TRPCError({
|
|
493
|
+
code: 'FORBIDDEN',
|
|
494
|
+
message: `Storage limit exceeded. Maximum allowed storage is ${(storageLimit / (1024 * 1024 * 1024)).toFixed(1)}GB.`
|
|
495
|
+
});
|
|
496
|
+
}
|
|
265
497
|
const results = [];
|
|
266
498
|
for (const file of input.files) {
|
|
267
499
|
// 1. Insert into DB
|
|
@@ -277,19 +509,19 @@ export const workspace = router({
|
|
|
277
509
|
// 2. Generate signed URL for direct upload
|
|
278
510
|
const objectKey = `${ctx.session.user.id}/${record.id}-${file.filename}`;
|
|
279
511
|
const { data: signedUrlData, error: signedUrlError } = await supabaseClient.storage
|
|
280
|
-
.from('
|
|
512
|
+
.from('media')
|
|
281
513
|
.createSignedUploadUrl(objectKey); // 5 minutes
|
|
282
514
|
if (signedUrlError) {
|
|
283
515
|
throw new TRPCError({
|
|
284
516
|
code: 'INTERNAL_SERVER_ERROR',
|
|
285
|
-
message: `Failed to
|
|
517
|
+
message: `Failed to upload file`
|
|
286
518
|
});
|
|
287
519
|
}
|
|
288
520
|
// 3. Update record with bucket info
|
|
289
521
|
await ctx.db.fileAsset.update({
|
|
290
522
|
where: { id: record.id },
|
|
291
523
|
data: {
|
|
292
|
-
bucket: '
|
|
524
|
+
bucket: 'media',
|
|
293
525
|
objectKey: objectKey,
|
|
294
526
|
},
|
|
295
527
|
});
|
|
@@ -321,7 +553,7 @@ export const workspace = router({
|
|
|
321
553
|
.from(file.bucket)
|
|
322
554
|
.remove([file.objectKey])
|
|
323
555
|
.catch((err) => {
|
|
324
|
-
|
|
556
|
+
logger.error(`Error deleting file ${file.objectKey} from bucket ${file.bucket}:`, err);
|
|
325
557
|
});
|
|
326
558
|
}
|
|
327
559
|
}
|
|
@@ -342,6 +574,22 @@ export const workspace = router({
|
|
|
342
574
|
size: z.number(),
|
|
343
575
|
}))
|
|
344
576
|
.query(async ({ ctx, input }) => {
|
|
577
|
+
// Check storage limit
|
|
578
|
+
const workspaces = await ctx.db.workspace.findMany({
|
|
579
|
+
where: { OR: [{ ownerId: ctx.session.user.id }, { sharedWith: { some: { id: ctx.session.user.id } } }] },
|
|
580
|
+
});
|
|
581
|
+
const spaceUsed = await ctx.db.fileAsset.aggregate({
|
|
582
|
+
where: { workspaceId: { in: workspaces.map((w) => w.id) }, userId: ctx.session.user.id },
|
|
583
|
+
_sum: { size: true },
|
|
584
|
+
});
|
|
585
|
+
const storageLimit = await getUserStorageLimit(ctx.session.user.id);
|
|
586
|
+
if ((spaceUsed._sum?.size ?? 0) + input.size > storageLimit) {
|
|
587
|
+
logger.warn(`Storage limit exceeded for user ${ctx.session.user.id}. Used: ${spaceUsed._sum?.size}, Tried to upload: ${input.size}, Limit: ${storageLimit}`);
|
|
588
|
+
throw new TRPCError({
|
|
589
|
+
code: 'FORBIDDEN',
|
|
590
|
+
message: `Storage limit exceeded. Maximum allowed storage is ${(storageLimit / (1024 * 1024 * 1024)).toFixed(1)}GB.`
|
|
591
|
+
});
|
|
592
|
+
}
|
|
345
593
|
const objectKey = `workspace_${ctx.session.user.id}/${input.workspaceId}-file_${input.filename}`;
|
|
346
594
|
const fileAsset = await ctx.db.fileAsset.create({
|
|
347
595
|
data: {
|
|
@@ -356,9 +604,10 @@ export const workspace = router({
|
|
|
356
604
|
});
|
|
357
605
|
const { data: signedUrlData, error: signedUrlError } = await supabaseClient.storage
|
|
358
606
|
.from('media')
|
|
359
|
-
.createSignedUploadUrl(objectKey
|
|
607
|
+
.createSignedUploadUrl(objectKey, { upsert: true });
|
|
360
608
|
if (signedUrlError) {
|
|
361
|
-
|
|
609
|
+
logger.error('Signed upload URL error:', signedUrlError);
|
|
610
|
+
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: `Failed to create upload URL: ${signedUrlError.message}` });
|
|
362
611
|
}
|
|
363
612
|
await ctx.db.workspace.update({
|
|
364
613
|
where: { id: input.workspaceId },
|
|
@@ -369,7 +618,7 @@ export const workspace = router({
|
|
|
369
618
|
uploadUrl: signedUrlData.signedUrl,
|
|
370
619
|
};
|
|
371
620
|
}),
|
|
372
|
-
uploadAndAnalyzeMedia:
|
|
621
|
+
uploadAndAnalyzeMedia: verifiedProcedure
|
|
373
622
|
.input(z.object({
|
|
374
623
|
workspaceId: z.string(),
|
|
375
624
|
files: z.array(z.object({
|
|
@@ -385,7 +634,7 @@ export const workspace = router({
|
|
|
385
634
|
where: { id: input.workspaceId, ownerId: ctx.session.user.id }
|
|
386
635
|
});
|
|
387
636
|
if (!workspace) {
|
|
388
|
-
|
|
637
|
+
logger.error('Workspace not found', { workspaceId: input.workspaceId, userId: ctx.session.user.id });
|
|
389
638
|
throw new TRPCError({ code: 'NOT_FOUND' });
|
|
390
639
|
}
|
|
391
640
|
// Check if analysis is already in progress
|
|
@@ -427,43 +676,13 @@ export const workspace = router({
|
|
|
427
676
|
where: { id: input.workspaceId },
|
|
428
677
|
data: { fileBeingAnalyzed: true },
|
|
429
678
|
});
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
filename: primaryFile.name,
|
|
433
|
-
fileType,
|
|
434
|
-
startedAt: new Date().toISOString(),
|
|
435
|
-
steps: {
|
|
436
|
-
fileUpload: { order: 1, status: 'pending' },
|
|
437
|
-
},
|
|
438
|
-
});
|
|
679
|
+
const genConfig = { generateStudyGuide: input.generateStudyGuide, generateFlashcards: input.generateFlashcards };
|
|
680
|
+
PusherService.emitAnalysisProgress(input.workspaceId, buildProgress('starting', primaryFile.name, fileType, 'fileUpload', 'pending', genConfig));
|
|
439
681
|
try {
|
|
440
|
-
await updateAnalysisProgress(ctx.db, input.workspaceId,
|
|
441
|
-
status: 'starting',
|
|
442
|
-
filename: primaryFile.name,
|
|
443
|
-
fileType,
|
|
444
|
-
startedAt: new Date().toISOString(),
|
|
445
|
-
steps: {
|
|
446
|
-
fileUpload: {
|
|
447
|
-
order: 1,
|
|
448
|
-
status: 'pending',
|
|
449
|
-
},
|
|
450
|
-
fileAnalysis: {
|
|
451
|
-
order: 2,
|
|
452
|
-
status: 'pending',
|
|
453
|
-
},
|
|
454
|
-
studyGuide: {
|
|
455
|
-
order: 3,
|
|
456
|
-
status: input.generateStudyGuide ? 'pending' : 'skipped',
|
|
457
|
-
},
|
|
458
|
-
flashcards: {
|
|
459
|
-
order: 4,
|
|
460
|
-
status: input.generateFlashcards ? 'pending' : 'skipped',
|
|
461
|
-
},
|
|
462
|
-
}
|
|
463
|
-
});
|
|
682
|
+
await updateAnalysisProgress(ctx.db, input.workspaceId, buildProgress('starting', primaryFile.name, fileType, 'fileUpload', 'pending', genConfig));
|
|
464
683
|
}
|
|
465
684
|
catch (error) {
|
|
466
|
-
|
|
685
|
+
logger.error('Failed to update analysis progress:', error);
|
|
467
686
|
await ctx.db.workspace.update({
|
|
468
687
|
where: { id: input.workspaceId },
|
|
469
688
|
data: { fileBeingAnalyzed: false },
|
|
@@ -471,30 +690,7 @@ export const workspace = router({
|
|
|
471
690
|
await PusherService.emitError(input.workspaceId, `Failed to update analysis progress: ${error}`, 'file_analysis');
|
|
472
691
|
throw error;
|
|
473
692
|
}
|
|
474
|
-
await updateAnalysisProgress(ctx.db, input.workspaceId,
|
|
475
|
-
status: 'uploading',
|
|
476
|
-
filename: primaryFile.name,
|
|
477
|
-
fileType,
|
|
478
|
-
startedAt: new Date().toISOString(),
|
|
479
|
-
steps: {
|
|
480
|
-
fileUpload: {
|
|
481
|
-
order: 1,
|
|
482
|
-
status: 'in_progress',
|
|
483
|
-
},
|
|
484
|
-
fileAnalysis: {
|
|
485
|
-
order: 2,
|
|
486
|
-
status: 'pending',
|
|
487
|
-
},
|
|
488
|
-
studyGuide: {
|
|
489
|
-
order: 3,
|
|
490
|
-
status: input.generateStudyGuide ? 'pending' : 'skipped',
|
|
491
|
-
},
|
|
492
|
-
flashcards: {
|
|
493
|
-
order: 4,
|
|
494
|
-
status: input.generateFlashcards ? 'pending' : 'skipped',
|
|
495
|
-
},
|
|
496
|
-
}
|
|
497
|
-
});
|
|
693
|
+
await updateAnalysisProgress(ctx.db, input.workspaceId, buildProgress('uploading', primaryFile.name, fileType, 'fileUpload', 'in_progress', genConfig));
|
|
498
694
|
// Process all files using the new process_file endpoint
|
|
499
695
|
for (const file of files) {
|
|
500
696
|
// TypeScript: We already validated bucket and objectKey exist above
|
|
@@ -511,7 +707,7 @@ export const workspace = router({
|
|
|
511
707
|
});
|
|
512
708
|
throw new TRPCError({
|
|
513
709
|
code: 'INTERNAL_SERVER_ERROR',
|
|
514
|
-
message: `Failed to
|
|
710
|
+
message: `Failed to upload file`
|
|
515
711
|
});
|
|
516
712
|
}
|
|
517
713
|
const fileUrl = signedUrlData.signedUrl;
|
|
@@ -539,30 +735,7 @@ export const workspace = router({
|
|
|
539
735
|
});
|
|
540
736
|
}
|
|
541
737
|
}
|
|
542
|
-
await updateAnalysisProgress(ctx.db, input.workspaceId,
|
|
543
|
-
status: 'analyzing',
|
|
544
|
-
filename: primaryFile.name,
|
|
545
|
-
fileType,
|
|
546
|
-
startedAt: new Date().toISOString(),
|
|
547
|
-
steps: {
|
|
548
|
-
fileUpload: {
|
|
549
|
-
order: 1,
|
|
550
|
-
status: 'completed',
|
|
551
|
-
},
|
|
552
|
-
fileAnalysis: {
|
|
553
|
-
order: 2,
|
|
554
|
-
status: 'in_progress',
|
|
555
|
-
},
|
|
556
|
-
studyGuide: {
|
|
557
|
-
order: 3,
|
|
558
|
-
status: input.generateStudyGuide ? 'pending' : 'skipped',
|
|
559
|
-
},
|
|
560
|
-
flashcards: {
|
|
561
|
-
order: 4,
|
|
562
|
-
status: input.generateFlashcards ? 'pending' : 'skipped',
|
|
563
|
-
},
|
|
564
|
-
}
|
|
565
|
-
});
|
|
738
|
+
await updateAnalysisProgress(ctx.db, input.workspaceId, buildProgress('analyzing', primaryFile.name, fileType, 'fileAnalysis', 'in_progress', genConfig));
|
|
566
739
|
try {
|
|
567
740
|
// Analyze all files - use PDF analysis if any file is a PDF, otherwise use image analysis
|
|
568
741
|
// const hasPDF = files.some(f => !f.mimeType.startsWith('image/'));
|
|
@@ -574,58 +747,14 @@ export const workspace = router({
|
|
|
574
747
|
// await aiSessionService.analyseImage(input.workspaceId, ctx.session.user.id, file.id);
|
|
575
748
|
// }
|
|
576
749
|
// }
|
|
577
|
-
await updateAnalysisProgress(ctx.db, input.workspaceId,
|
|
578
|
-
status: 'generating_artifacts',
|
|
579
|
-
filename: primaryFile.name,
|
|
580
|
-
fileType,
|
|
581
|
-
startedAt: new Date().toISOString(),
|
|
582
|
-
steps: {
|
|
583
|
-
fileUpload: {
|
|
584
|
-
order: 1,
|
|
585
|
-
status: 'completed',
|
|
586
|
-
},
|
|
587
|
-
fileAnalysis: {
|
|
588
|
-
order: 2,
|
|
589
|
-
status: 'completed',
|
|
590
|
-
},
|
|
591
|
-
studyGuide: {
|
|
592
|
-
order: 3,
|
|
593
|
-
status: input.generateStudyGuide ? 'pending' : 'skipped',
|
|
594
|
-
},
|
|
595
|
-
flashcards: {
|
|
596
|
-
order: 4,
|
|
597
|
-
status: input.generateFlashcards ? 'pending' : 'skipped',
|
|
598
|
-
},
|
|
599
|
-
}
|
|
600
|
-
});
|
|
750
|
+
await updateAnalysisProgress(ctx.db, input.workspaceId, buildProgress('generating_artifacts', primaryFile.name, fileType, 'studyGuide', 'pending', genConfig));
|
|
601
751
|
}
|
|
602
752
|
catch (error) {
|
|
603
|
-
|
|
604
|
-
await updateAnalysisProgress(ctx.db, input.workspaceId, {
|
|
605
|
-
status: 'error',
|
|
606
|
-
filename: primaryFile.name,
|
|
607
|
-
fileType,
|
|
753
|
+
logger.error('Failed to analyze files:', error);
|
|
754
|
+
await updateAnalysisProgress(ctx.db, input.workspaceId, buildProgress('error', primaryFile.name, fileType, 'fileAnalysis', 'error', genConfig, {
|
|
608
755
|
error: `Failed to analyze ${fileType}: ${error}`,
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
fileUpload: {
|
|
612
|
-
order: 1,
|
|
613
|
-
status: 'completed',
|
|
614
|
-
},
|
|
615
|
-
fileAnalysis: {
|
|
616
|
-
order: 2,
|
|
617
|
-
status: 'error',
|
|
618
|
-
},
|
|
619
|
-
studyGuide: {
|
|
620
|
-
order: 3,
|
|
621
|
-
status: 'skipped',
|
|
622
|
-
},
|
|
623
|
-
flashcards: {
|
|
624
|
-
order: 4,
|
|
625
|
-
status: 'skipped',
|
|
626
|
-
},
|
|
627
|
-
}
|
|
628
|
-
});
|
|
756
|
+
studyGuide: 'skipped', flashcards: 'skipped',
|
|
757
|
+
}));
|
|
629
758
|
await ctx.db.workspace.update({
|
|
630
759
|
where: { id: input.workspaceId },
|
|
631
760
|
data: { fileBeingAnalyzed: false },
|
|
@@ -640,164 +769,176 @@ export const workspace = router({
|
|
|
640
769
|
worksheet: null,
|
|
641
770
|
}
|
|
642
771
|
};
|
|
643
|
-
//
|
|
772
|
+
// Ensure AI session is initialized before generating artifacts
|
|
773
|
+
try {
|
|
774
|
+
await aiSessionService.initSession(input.workspaceId, ctx.session.user.id);
|
|
775
|
+
}
|
|
776
|
+
catch (initError) {
|
|
777
|
+
logger.error('Failed to init AI session (continuing with workspace context):', initError);
|
|
778
|
+
}
|
|
779
|
+
// Fetch current usage and limits to enforce plan restrictions for auto-generation
|
|
780
|
+
const [usage, limits] = await Promise.all([
|
|
781
|
+
getUserUsage(ctx.session.user.id),
|
|
782
|
+
getUserPlanLimits(ctx.session.user.id)
|
|
783
|
+
]);
|
|
784
|
+
// Generate artifacts - each step is isolated so failures don't block subsequent steps
|
|
644
785
|
if (input.generateStudyGuide) {
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
786
|
+
// Enforcement: Skip if limit reached
|
|
787
|
+
if (limits && usage.studyGuides >= limits.maxStudyGuides) {
|
|
788
|
+
await updateAnalysisProgress(ctx.db, input.workspaceId, buildProgress('skipped', primaryFile.name, fileType, 'studyGuide', 'skipped', genConfig));
|
|
789
|
+
await PusherService.emitError(input.workspaceId, 'Study guide skipped: Limit reached.', 'study_guide');
|
|
790
|
+
await notifyArtifactFailed(ctx.db, {
|
|
791
|
+
userId: ctx.session.user.id,
|
|
792
|
+
workspaceId: input.workspaceId,
|
|
793
|
+
artifactType: ArtifactType.STUDY_GUIDE,
|
|
794
|
+
message: 'Study guide was skipped because your plan limit was reached.',
|
|
795
|
+
}).catch(() => { });
|
|
796
|
+
}
|
|
797
|
+
else {
|
|
798
|
+
try {
|
|
799
|
+
await updateAnalysisProgress(ctx.db, input.workspaceId, buildProgress('generating_study_guide', primaryFile.name, fileType, 'studyGuide', 'in_progress', genConfig));
|
|
800
|
+
const content = await aiSessionService.generateStudyGuide(input.workspaceId, ctx.session.user.id);
|
|
801
|
+
let artifact = await ctx.db.artifact.findFirst({
|
|
802
|
+
where: { workspaceId: input.workspaceId, type: ArtifactType.STUDY_GUIDE },
|
|
803
|
+
});
|
|
804
|
+
if (!artifact) {
|
|
805
|
+
artifact = await ctx.db.artifact.create({
|
|
806
|
+
data: {
|
|
807
|
+
workspaceId: input.workspaceId,
|
|
808
|
+
type: ArtifactType.STUDY_GUIDE,
|
|
809
|
+
title: files.length === 1 ? `Study Guide - ${primaryFile.name}` : `Study Guide - ${files.length} files`,
|
|
810
|
+
createdById: ctx.session.user.id,
|
|
811
|
+
},
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
const lastVersion = await ctx.db.artifactVersion.findFirst({
|
|
815
|
+
where: { artifact: { workspaceId: input.workspaceId, type: ArtifactType.STUDY_GUIDE } },
|
|
816
|
+
orderBy: { version: 'desc' },
|
|
817
|
+
});
|
|
818
|
+
await ctx.db.artifactVersion.create({
|
|
819
|
+
data: { artifactId: artifact.id, version: lastVersion ? lastVersion.version + 1 : 1, content: content, createdById: ctx.session.user.id },
|
|
820
|
+
});
|
|
821
|
+
results.artifacts.studyGuide = artifact;
|
|
822
|
+
await PusherService.emitStudyGuideComplete(input.workspaceId, artifact);
|
|
823
|
+
await notifyArtifactReady(ctx.db, {
|
|
824
|
+
userId: ctx.session.user.id,
|
|
825
|
+
workspaceId: input.workspaceId,
|
|
826
|
+
artifactId: artifact.id,
|
|
827
|
+
artifactType: ArtifactType.STUDY_GUIDE,
|
|
828
|
+
title: artifact.title,
|
|
829
|
+
}).catch(() => { });
|
|
667
830
|
}
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
if (!artifact) {
|
|
674
|
-
const fileNames = files.map(f => f.name).join(', ');
|
|
675
|
-
artifact = await ctx.db.artifact.create({
|
|
676
|
-
data: {
|
|
831
|
+
catch (sgError) {
|
|
832
|
+
logger.error('Study guide generation failed after retries:', sgError);
|
|
833
|
+
await PusherService.emitError(input.workspaceId, 'Study guide generation failed. Please try regenerating later.', 'study_guide');
|
|
834
|
+
await notifyArtifactFailed(ctx.db, {
|
|
835
|
+
userId: ctx.session.user.id,
|
|
677
836
|
workspaceId: input.workspaceId,
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
}
|
|
837
|
+
artifactType: ArtifactType.STUDY_GUIDE,
|
|
838
|
+
message: 'Study guide generation failed. Please try regenerating later.',
|
|
839
|
+
}).catch(() => { });
|
|
840
|
+
// Continue to flashcards - don't abort the whole pipeline
|
|
841
|
+
}
|
|
683
842
|
}
|
|
684
|
-
const lastVersion = await ctx.db.artifactVersion.findFirst({
|
|
685
|
-
where: { artifact: { workspaceId: input.workspaceId, type: ArtifactType.STUDY_GUIDE } },
|
|
686
|
-
orderBy: { version: 'desc' },
|
|
687
|
-
});
|
|
688
|
-
await ctx.db.artifactVersion.create({
|
|
689
|
-
data: { artifactId: artifact.id, version: lastVersion ? lastVersion.version + 1 : 1, content: content, createdById: ctx.session.user.id },
|
|
690
|
-
});
|
|
691
|
-
results.artifacts.studyGuide = artifact;
|
|
692
843
|
}
|
|
693
844
|
if (input.generateFlashcards) {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
fileUpload: {
|
|
701
|
-
order: 1,
|
|
702
|
-
status: 'completed',
|
|
703
|
-
},
|
|
704
|
-
fileAnalysis: {
|
|
705
|
-
order: 2,
|
|
706
|
-
status: 'completed',
|
|
707
|
-
},
|
|
708
|
-
studyGuide: {
|
|
709
|
-
order: 3,
|
|
710
|
-
status: input.generateStudyGuide ? 'completed' : 'skipped',
|
|
711
|
-
},
|
|
712
|
-
flashcards: {
|
|
713
|
-
order: 4,
|
|
714
|
-
status: 'in_progress',
|
|
715
|
-
},
|
|
716
|
-
}
|
|
717
|
-
});
|
|
718
|
-
const content = await aiSessionService.generateFlashcardQuestions(input.workspaceId, ctx.session.user.id, 10, 'medium');
|
|
719
|
-
const artifact = await ctx.db.artifact.create({
|
|
720
|
-
data: {
|
|
845
|
+
// Enforcement: Skip if limit reached
|
|
846
|
+
if (limits && usage.flashcards >= limits.maxFlashcards) {
|
|
847
|
+
await updateAnalysisProgress(ctx.db, input.workspaceId, buildProgress('skipped', primaryFile.name, fileType, 'flashcards', 'skipped', genConfig));
|
|
848
|
+
await PusherService.emitError(input.workspaceId, 'Flashcards skipped: Limit reached.', 'flashcards');
|
|
849
|
+
await notifyArtifactFailed(ctx.db, {
|
|
850
|
+
userId: ctx.session.user.id,
|
|
721
851
|
workspaceId: input.workspaceId,
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
const card = flashcardData[i];
|
|
733
|
-
const front = card.term || card.front || card.question || card.prompt || `Question ${i + 1}`;
|
|
734
|
-
const back = card.definition || card.back || card.answer || card.solution || `Answer ${i + 1}`;
|
|
735
|
-
await ctx.db.flashcard.create({
|
|
852
|
+
artifactType: ArtifactType.FLASHCARD_SET,
|
|
853
|
+
message: 'Flashcards were skipped because your plan limit was reached.',
|
|
854
|
+
}).catch(() => { });
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
try {
|
|
858
|
+
const sgStatus = input.generateStudyGuide ? (results.artifacts.studyGuide ? 'completed' : 'error') : 'skipped';
|
|
859
|
+
await updateAnalysisProgress(ctx.db, input.workspaceId, buildProgress('generating_flashcards', primaryFile.name, fileType, 'flashcards', 'in_progress', genConfig, { studyGuide: sgStatus }));
|
|
860
|
+
const content = await aiSessionService.generateFlashcardQuestions(input.workspaceId, ctx.session.user.id, 10, 'medium');
|
|
861
|
+
const artifact = await ctx.db.artifact.create({
|
|
736
862
|
data: {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
tags: ['ai-generated', 'medium'],
|
|
863
|
+
workspaceId: input.workspaceId,
|
|
864
|
+
type: ArtifactType.FLASHCARD_SET,
|
|
865
|
+
title: files.length === 1 ? `Flashcards - ${primaryFile.name}` : `Flashcards - ${files.length} files`,
|
|
866
|
+
createdById: ctx.session.user.id,
|
|
742
867
|
},
|
|
743
868
|
});
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
869
|
+
// Parse JSON flashcard content
|
|
870
|
+
try {
|
|
871
|
+
const parsed = typeof content === 'string' ? JSON.parse(content) : content;
|
|
872
|
+
const flashcardData = Array.isArray(parsed) ? parsed : (parsed.flashcards || []);
|
|
873
|
+
for (let i = 0; i < Math.min(flashcardData.length, 10); i++) {
|
|
874
|
+
const card = flashcardData[i];
|
|
875
|
+
const front = card.term || card.front || card.question || card.prompt || `Question ${i + 1}`;
|
|
876
|
+
const back = card.definition || card.back || card.answer || card.solution || `Answer ${i + 1}`;
|
|
877
|
+
await ctx.db.flashcard.create({
|
|
878
|
+
data: {
|
|
879
|
+
artifactId: artifact.id,
|
|
880
|
+
front: front,
|
|
881
|
+
back: back,
|
|
882
|
+
order: i,
|
|
883
|
+
tags: ['ai-generated', 'medium'],
|
|
884
|
+
},
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
catch (parseError) {
|
|
889
|
+
console.error("Failed to parse flashcard JSON or create cards in workspace router:", parseError);
|
|
890
|
+
// Fallback to text parsing if JSON fails
|
|
891
|
+
const lines = content.split('\n').filter((line) => line.trim());
|
|
892
|
+
for (let i = 0; i < Math.min(lines.length, 10); i++) {
|
|
893
|
+
const line = lines[i];
|
|
894
|
+
if (line.includes(' - ')) {
|
|
895
|
+
const [front, back] = line.split(' - ');
|
|
896
|
+
await ctx.db.flashcard.create({
|
|
897
|
+
data: {
|
|
898
|
+
artifactId: artifact.id,
|
|
899
|
+
front: front.trim(),
|
|
900
|
+
back: back.trim(),
|
|
901
|
+
order: i,
|
|
902
|
+
tags: ['ai-generated', 'medium'],
|
|
903
|
+
},
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
}
|
|
763
907
|
}
|
|
908
|
+
results.artifacts.flashcards = artifact;
|
|
909
|
+
await PusherService.emitFlashcardComplete(input.workspaceId, artifact);
|
|
910
|
+
await notifyArtifactReady(ctx.db, {
|
|
911
|
+
userId: ctx.session.user.id,
|
|
912
|
+
workspaceId: input.workspaceId,
|
|
913
|
+
artifactId: artifact.id,
|
|
914
|
+
artifactType: ArtifactType.FLASHCARD_SET,
|
|
915
|
+
title: artifact.title,
|
|
916
|
+
}).catch(() => { });
|
|
917
|
+
}
|
|
918
|
+
catch (fcError) {
|
|
919
|
+
logger.error('Flashcard generation failed after retries:', fcError);
|
|
920
|
+
await PusherService.emitError(input.workspaceId, 'Flashcard generation failed. Please try regenerating later.', 'flashcards');
|
|
921
|
+
await notifyArtifactFailed(ctx.db, {
|
|
922
|
+
userId: ctx.session.user.id,
|
|
923
|
+
workspaceId: input.workspaceId,
|
|
924
|
+
artifactType: ArtifactType.FLASHCARD_SET,
|
|
925
|
+
message: 'Flashcard generation failed. Please try regenerating later.',
|
|
926
|
+
}).catch(() => { });
|
|
764
927
|
}
|
|
765
928
|
}
|
|
766
|
-
results.artifacts.flashcards = artifact;
|
|
767
929
|
}
|
|
768
930
|
await ctx.db.workspace.update({
|
|
769
931
|
where: { id: input.workspaceId },
|
|
770
932
|
data: { fileBeingAnalyzed: false },
|
|
771
933
|
});
|
|
772
934
|
await updateAnalysisProgress(ctx.db, input.workspaceId, {
|
|
773
|
-
|
|
774
|
-
filename: primaryFile.name,
|
|
775
|
-
fileType,
|
|
776
|
-
startedAt: new Date().toISOString(),
|
|
935
|
+
...buildProgress('completed', primaryFile.name, fileType, 'flashcards', 'completed', genConfig),
|
|
777
936
|
completedAt: new Date().toISOString(),
|
|
778
|
-
steps: {
|
|
779
|
-
fileUpload: {
|
|
780
|
-
order: 1,
|
|
781
|
-
status: 'completed',
|
|
782
|
-
},
|
|
783
|
-
fileAnalysis: {
|
|
784
|
-
order: 2,
|
|
785
|
-
status: 'completed',
|
|
786
|
-
},
|
|
787
|
-
studyGuide: {
|
|
788
|
-
order: 3,
|
|
789
|
-
status: input.generateStudyGuide ? 'completed' : 'skipped',
|
|
790
|
-
},
|
|
791
|
-
flashcards: {
|
|
792
|
-
order: 4,
|
|
793
|
-
status: input.generateFlashcards ? 'completed' : 'skipped',
|
|
794
|
-
},
|
|
795
|
-
}
|
|
796
937
|
});
|
|
797
938
|
return results;
|
|
798
939
|
}
|
|
799
940
|
catch (error) {
|
|
800
|
-
|
|
941
|
+
logger.error('Failed to update analysis progress:', error);
|
|
801
942
|
await ctx.db.workspace.update({
|
|
802
943
|
where: { id: input.workspaceId },
|
|
803
944
|
data: { fileBeingAnalyzed: false },
|
|
@@ -809,46 +950,45 @@ export const workspace = router({
|
|
|
809
950
|
search: authedProcedure
|
|
810
951
|
.input(z.object({
|
|
811
952
|
query: z.string(),
|
|
953
|
+
color: z.string().optional(),
|
|
812
954
|
limit: z.number().min(1).max(100).default(20),
|
|
813
955
|
}))
|
|
814
956
|
.query(async ({ ctx, input }) => {
|
|
815
|
-
const { query } = input;
|
|
957
|
+
const { query, color } = input;
|
|
958
|
+
// 1. Search Workspaces
|
|
816
959
|
const workspaces = await ctx.db.workspace.findMany({
|
|
817
960
|
where: {
|
|
818
961
|
ownerId: ctx.session.user.id,
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
{
|
|
827
|
-
description: {
|
|
828
|
-
contains: query,
|
|
829
|
-
mode: 'insensitive',
|
|
830
|
-
},
|
|
831
|
-
},
|
|
832
|
-
],
|
|
962
|
+
markerColor: color || undefined,
|
|
963
|
+
...(query ? {
|
|
964
|
+
OR: [
|
|
965
|
+
{ title: { contains: query, mode: 'insensitive' } },
|
|
966
|
+
{ description: { contains: query, mode: 'insensitive' } },
|
|
967
|
+
],
|
|
968
|
+
} : {}),
|
|
833
969
|
},
|
|
834
|
-
orderBy: {
|
|
835
|
-
|
|
970
|
+
orderBy: { updatedAt: 'desc' },
|
|
971
|
+
take: input.limit,
|
|
972
|
+
});
|
|
973
|
+
// 2. Search Folders
|
|
974
|
+
const folders = await ctx.db.folder.findMany({
|
|
975
|
+
where: {
|
|
976
|
+
ownerId: ctx.session.user.id,
|
|
977
|
+
markerColor: color || undefined,
|
|
978
|
+
...(query ? {
|
|
979
|
+
name: { contains: query, mode: 'insensitive' },
|
|
980
|
+
} : {}),
|
|
836
981
|
},
|
|
982
|
+
orderBy: { updatedAt: 'desc' },
|
|
837
983
|
take: input.limit,
|
|
838
984
|
});
|
|
839
|
-
//
|
|
840
|
-
const
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
matchedIn: ws.title.toLowerCase().includes(query.toLowerCase()) ? 'title' : 'description',
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
}));
|
|
850
|
-
await Promise.all(workspaceUpdates);
|
|
851
|
-
return workspaces;
|
|
985
|
+
// Combined results with type discriminator
|
|
986
|
+
const results = [
|
|
987
|
+
...workspaces.map((w) => ({ ...w, type: 'workspace' })),
|
|
988
|
+
...folders.map((f) => ({ ...f, type: 'folder', title: f.name })), // normalize name to title
|
|
989
|
+
].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
|
|
990
|
+
.slice(0, input.limit);
|
|
991
|
+
return results;
|
|
852
992
|
}),
|
|
853
993
|
// Members sub-router
|
|
854
994
|
members,
|