@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
package/src/routers/workspace.ts
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { TRPCError } from '@trpc/server';
|
|
3
|
-
import { router, publicProcedure, authedProcedure } from '../trpc.js';
|
|
3
|
+
import { router, publicProcedure, 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
10
|
import type { PrismaClient } from '@prisma/client';
|
|
11
|
+
import { getUserStorageLimit } from '../lib/subscription_service.js';
|
|
12
|
+
import { getUserUsage, getUserPlanLimits } from '../lib/usage_service.js';
|
|
13
|
+
import {
|
|
14
|
+
notifyArtifactFailed,
|
|
15
|
+
notifyArtifactReady,
|
|
16
|
+
notifyWorkspaceDeleted,
|
|
17
|
+
} from '../lib/notification-service.js';
|
|
11
18
|
|
|
12
19
|
// Helper function to update and emit analysis progress
|
|
13
20
|
async function updateAnalysisProgress(
|
|
@@ -145,6 +152,42 @@ export const workspace = router({
|
|
|
145
152
|
return { workspaces, folders };
|
|
146
153
|
}),
|
|
147
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Fetches the entire directory tree for the user.
|
|
157
|
+
* Includes Folders, Workspaces (files), and Uploads (sub-files).
|
|
158
|
+
*/
|
|
159
|
+
getTree: authedProcedure
|
|
160
|
+
.query(async ({ ctx }) => {
|
|
161
|
+
const userId = ctx.session.user.id;
|
|
162
|
+
|
|
163
|
+
// 1. Fetch all folders
|
|
164
|
+
const allFolders = await ctx.db.folder.findMany({
|
|
165
|
+
where: { ownerId: userId },
|
|
166
|
+
orderBy: { updatedAt: 'desc' },
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// 2. Fetch all workspaces
|
|
170
|
+
const allWorkspaces = await ctx.db.workspace.findMany({
|
|
171
|
+
where: { ownerId: userId },
|
|
172
|
+
include: {
|
|
173
|
+
uploads: {
|
|
174
|
+
select: {
|
|
175
|
+
id: true,
|
|
176
|
+
name: true,
|
|
177
|
+
mimeType: true,
|
|
178
|
+
createdAt: true,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
orderBy: { updatedAt: 'desc' },
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
folders: allFolders,
|
|
187
|
+
workspaces: allWorkspaces,
|
|
188
|
+
};
|
|
189
|
+
}),
|
|
190
|
+
|
|
148
191
|
create: authedProcedure
|
|
149
192
|
.input(z.object({
|
|
150
193
|
name: z.string().min(1).max(100),
|
|
@@ -173,7 +216,12 @@ export const workspace = router({
|
|
|
173
216
|
},
|
|
174
217
|
});
|
|
175
218
|
|
|
176
|
-
aiSessionService.initSession(ws.id, ctx.session.user.id)
|
|
219
|
+
await aiSessionService.initSession(ws.id, ctx.session.user.id).catch((err) => {
|
|
220
|
+
logger.error('Failed to init AI session on workspace creation:', err);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await PusherService.emitLibraryUpdate(ctx.session.user.id);
|
|
224
|
+
|
|
177
225
|
return ws;
|
|
178
226
|
}),
|
|
179
227
|
createFolder: authedProcedure
|
|
@@ -191,16 +239,28 @@ export const workspace = router({
|
|
|
191
239
|
parentId: input.parentId ?? null,
|
|
192
240
|
},
|
|
193
241
|
});
|
|
242
|
+
|
|
243
|
+
await PusherService.emitLibraryUpdate(ctx.session.user.id);
|
|
244
|
+
|
|
194
245
|
return folder;
|
|
195
246
|
}),
|
|
196
247
|
updateFolder: authedProcedure
|
|
197
248
|
.input(z.object({
|
|
198
249
|
id: z.string(),
|
|
199
250
|
name: z.string().min(1).max(100).optional(),
|
|
200
|
-
|
|
251
|
+
markerColor: z.string().nullable().optional(),
|
|
201
252
|
}))
|
|
202
253
|
.mutation(async ({ ctx, input }) => {
|
|
203
|
-
const folder = await ctx.db.folder.update({
|
|
254
|
+
const folder = await ctx.db.folder.update({
|
|
255
|
+
where: { id: input.id },
|
|
256
|
+
data: {
|
|
257
|
+
name: input.name,
|
|
258
|
+
markerColor: input.markerColor
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
await PusherService.emitLibraryUpdate(ctx.session.user.id);
|
|
263
|
+
|
|
204
264
|
return folder;
|
|
205
265
|
}),
|
|
206
266
|
deleteFolder: authedProcedure
|
|
@@ -209,6 +269,9 @@ export const workspace = router({
|
|
|
209
269
|
}))
|
|
210
270
|
.mutation(async ({ ctx, input }) => {
|
|
211
271
|
const folder = await ctx.db.folder.delete({ where: { id: input.id } });
|
|
272
|
+
|
|
273
|
+
await PusherService.emitLibraryUpdate(ctx.session.user.id);
|
|
274
|
+
|
|
212
275
|
return folder;
|
|
213
276
|
}),
|
|
214
277
|
get: authedProcedure
|
|
@@ -241,16 +304,18 @@ export const workspace = router({
|
|
|
241
304
|
});
|
|
242
305
|
|
|
243
306
|
const spaceLeft = await ctx.db.fileAsset.aggregate({
|
|
244
|
-
where: { workspaceId: { in: workspaces.map(ws => ws.id) }, userId: ctx.session.user.id },
|
|
307
|
+
where: { workspaceId: { in: workspaces.map((ws: any) => ws.id) }, userId: ctx.session.user.id },
|
|
245
308
|
_sum: { size: true },
|
|
246
309
|
});
|
|
247
310
|
|
|
311
|
+
const storageLimit = await getUserStorageLimit(ctx.session.user.id);
|
|
312
|
+
|
|
248
313
|
return {
|
|
249
314
|
workspaces: workspaces.length,
|
|
250
315
|
folders: folders.length,
|
|
251
316
|
lastUpdated: lastUpdated?.updatedAt,
|
|
252
317
|
spaceUsed: spaceLeft._sum?.size ?? 0,
|
|
253
|
-
spaceTotal:
|
|
318
|
+
spaceTotal: storageLimit,
|
|
254
319
|
};
|
|
255
320
|
}),
|
|
256
321
|
|
|
@@ -364,7 +429,7 @@ export const workspace = router({
|
|
|
364
429
|
id: z.string(),
|
|
365
430
|
name: z.string().min(1).max(100).optional(),
|
|
366
431
|
description: z.string().max(500).optional(),
|
|
367
|
-
|
|
432
|
+
markerColor: z.string().nullable().optional(),
|
|
368
433
|
icon: z.string().optional(),
|
|
369
434
|
}))
|
|
370
435
|
.mutation(async ({ ctx, input }) => {
|
|
@@ -377,10 +442,14 @@ export const workspace = router({
|
|
|
377
442
|
data: {
|
|
378
443
|
title: input.name ?? existed.title,
|
|
379
444
|
description: input.description,
|
|
380
|
-
|
|
445
|
+
// Preserve explicit null ("None color") instead of falling back.
|
|
446
|
+
markerColor: input.markerColor !== undefined ? input.markerColor : existed.markerColor,
|
|
381
447
|
icon: input.icon ?? existed.icon,
|
|
382
448
|
},
|
|
383
449
|
});
|
|
450
|
+
|
|
451
|
+
await PusherService.emitLibraryUpdate(ctx.session.user.id);
|
|
452
|
+
|
|
384
453
|
return updated;
|
|
385
454
|
}),
|
|
386
455
|
delete: authedProcedure
|
|
@@ -388,10 +457,41 @@ export const workspace = router({
|
|
|
388
457
|
id: z.string(),
|
|
389
458
|
}))
|
|
390
459
|
.mutation(async ({ ctx, input }) => {
|
|
460
|
+
const workspaceToDelete = await ctx.db.workspace.findFirst({
|
|
461
|
+
where: { id: input.id, ownerId: ctx.session.user.id },
|
|
462
|
+
select: {
|
|
463
|
+
id: true,
|
|
464
|
+
title: true,
|
|
465
|
+
ownerId: true,
|
|
466
|
+
members: {
|
|
467
|
+
select: { userId: true },
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
if (!workspaceToDelete) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
473
|
+
|
|
474
|
+
const actor = await ctx.db.user.findUnique({
|
|
475
|
+
where: { id: ctx.session.user.id },
|
|
476
|
+
select: { name: true, email: true },
|
|
477
|
+
});
|
|
478
|
+
const actorName = actor?.name || actor?.email || 'A user';
|
|
479
|
+
|
|
480
|
+
await notifyWorkspaceDeleted(ctx.db, {
|
|
481
|
+
recipientUserIds: workspaceToDelete.members.map((m) => m.userId),
|
|
482
|
+
actorUserId: ctx.session.user.id,
|
|
483
|
+
actorName,
|
|
484
|
+
workspaceId: workspaceToDelete.id,
|
|
485
|
+
workspaceTitle: workspaceToDelete.title,
|
|
486
|
+
});
|
|
487
|
+
|
|
391
488
|
const deleted = await ctx.db.workspace.deleteMany({
|
|
392
489
|
where: { id: input.id, ownerId: ctx.session.user.id },
|
|
393
490
|
});
|
|
394
491
|
if (deleted.count === 0) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
492
|
+
|
|
493
|
+
await PusherService.emitLibraryUpdate(ctx.session.user.id);
|
|
494
|
+
|
|
395
495
|
return true;
|
|
396
496
|
}),
|
|
397
497
|
getFolderInformation: authedProcedure
|
|
@@ -425,9 +525,11 @@ export const workspace = router({
|
|
|
425
525
|
const user = await ctx.db.user.findFirst({ where: { id: ctx.session.user.id } });
|
|
426
526
|
if (!user || !user.email) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
427
527
|
const sharedWith = await ctx.db.workspace.findMany({ where: { members: { some: { userId: ctx.session.user.id } } } });
|
|
428
|
-
const invitations = await ctx.db.workspaceInvitation.findMany({
|
|
429
|
-
|
|
430
|
-
|
|
528
|
+
const invitations = await ctx.db.workspaceInvitation.findMany({
|
|
529
|
+
where: { email: user.email, acceptedAt: null }, include: {
|
|
530
|
+
workspace: true,
|
|
531
|
+
}
|
|
532
|
+
});
|
|
431
533
|
|
|
432
534
|
return { shared: sharedWith, invitations };
|
|
433
535
|
}),
|
|
@@ -446,6 +548,24 @@ export const workspace = router({
|
|
|
446
548
|
// ensure workspace belongs to user
|
|
447
549
|
const ws = await ctx.db.workspace.findFirst({ where: { id: input.id, ownerId: ctx.session.user.id } });
|
|
448
550
|
if (!ws) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
551
|
+
|
|
552
|
+
// Check storage limit
|
|
553
|
+
const workspaces = await ctx.db.workspace.findMany({
|
|
554
|
+
where: { OR: [{ ownerId: ctx.session.user.id }, { sharedWith: { some: { id: ctx.session.user.id } } }] },
|
|
555
|
+
});
|
|
556
|
+
const spaceUsed = await ctx.db.fileAsset.aggregate({
|
|
557
|
+
where: { workspaceId: { in: workspaces.map((w: any) => w.id) }, userId: ctx.session.user.id },
|
|
558
|
+
_sum: { size: true },
|
|
559
|
+
});
|
|
560
|
+
const storageLimit = await getUserStorageLimit(ctx.session.user.id);
|
|
561
|
+
const totalSize = input.files.reduce((acc, file) => acc + file.size, 0);
|
|
562
|
+
if ((spaceUsed._sum?.size ?? 0) + totalSize > storageLimit) {
|
|
563
|
+
logger.warn(`Storage limit exceeded for user ${ctx.session.user.id}. Used: ${spaceUsed._sum?.size}, Tried to upload: ${totalSize}, Limit: ${storageLimit}`);
|
|
564
|
+
throw new TRPCError({
|
|
565
|
+
code: 'FORBIDDEN',
|
|
566
|
+
message: `Storage limit exceeded. Maximum allowed storage is ${(storageLimit / (1024 * 1024 * 1024)).toFixed(1)}GB.`
|
|
567
|
+
});
|
|
568
|
+
}
|
|
449
569
|
const results = [];
|
|
450
570
|
|
|
451
571
|
for (const file of input.files) {
|
|
@@ -463,7 +583,7 @@ export const workspace = router({
|
|
|
463
583
|
// 2. Generate signed URL for direct upload
|
|
464
584
|
const objectKey = `${ctx.session.user.id}/${record.id}-${file.filename}`;
|
|
465
585
|
const { data: signedUrlData, error: signedUrlError } = await supabaseClient.storage
|
|
466
|
-
.from('
|
|
586
|
+
.from('media')
|
|
467
587
|
.createSignedUploadUrl(objectKey); // 5 minutes
|
|
468
588
|
|
|
469
589
|
if (signedUrlError) {
|
|
@@ -477,7 +597,7 @@ export const workspace = router({
|
|
|
477
597
|
await ctx.db.fileAsset.update({
|
|
478
598
|
where: { id: record.id },
|
|
479
599
|
data: {
|
|
480
|
-
bucket: '
|
|
600
|
+
bucket: 'media',
|
|
481
601
|
objectKey: objectKey,
|
|
482
602
|
},
|
|
483
603
|
});
|
|
@@ -534,6 +654,23 @@ export const workspace = router({
|
|
|
534
654
|
size: z.number(),
|
|
535
655
|
}))
|
|
536
656
|
.query(async ({ ctx, input }) => {
|
|
657
|
+
// Check storage limit
|
|
658
|
+
const workspaces = await ctx.db.workspace.findMany({
|
|
659
|
+
where: { OR: [{ ownerId: ctx.session.user.id }, { sharedWith: { some: { id: ctx.session.user.id } } }] },
|
|
660
|
+
});
|
|
661
|
+
const spaceUsed = await ctx.db.fileAsset.aggregate({
|
|
662
|
+
where: { workspaceId: { in: workspaces.map((w: any) => w.id) }, userId: ctx.session.user.id },
|
|
663
|
+
_sum: { size: true },
|
|
664
|
+
});
|
|
665
|
+
const storageLimit = await getUserStorageLimit(ctx.session.user.id);
|
|
666
|
+
if ((spaceUsed._sum?.size ?? 0) + input.size > storageLimit) {
|
|
667
|
+
logger.warn(`Storage limit exceeded for user ${ctx.session.user.id}. Used: ${spaceUsed._sum?.size}, Tried to upload: ${input.size}, Limit: ${storageLimit}`);
|
|
668
|
+
throw new TRPCError({
|
|
669
|
+
code: 'FORBIDDEN',
|
|
670
|
+
message: `Storage limit exceeded. Maximum allowed storage is ${(storageLimit / (1024 * 1024 * 1024)).toFixed(1)}GB.`
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
537
674
|
const objectKey = `workspace_${ctx.session.user.id}/${input.workspaceId}-file_${input.filename}`;
|
|
538
675
|
const fileAsset = await ctx.db.fileAsset.create({
|
|
539
676
|
data: {
|
|
@@ -548,9 +685,10 @@ export const workspace = router({
|
|
|
548
685
|
});
|
|
549
686
|
const { data: signedUrlData, error: signedUrlError } = await supabaseClient.storage
|
|
550
687
|
.from('media')
|
|
551
|
-
.createSignedUploadUrl(objectKey
|
|
688
|
+
.createSignedUploadUrl(objectKey, { upsert: true });
|
|
552
689
|
if (signedUrlError) {
|
|
553
|
-
|
|
690
|
+
logger.error('Signed upload URL error:', signedUrlError);
|
|
691
|
+
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: `Failed to create upload URL: ${signedUrlError.message}` });
|
|
554
692
|
}
|
|
555
693
|
|
|
556
694
|
await ctx.db.workspace.update({
|
|
@@ -563,7 +701,7 @@ export const workspace = router({
|
|
|
563
701
|
uploadUrl: signedUrlData.signedUrl,
|
|
564
702
|
};
|
|
565
703
|
}),
|
|
566
|
-
uploadAndAnalyzeMedia:
|
|
704
|
+
uploadAndAnalyzeMedia: verifiedProcedure
|
|
567
705
|
.input(z.object({
|
|
568
706
|
workspaceId: z.string(),
|
|
569
707
|
files: z.array(z.object({
|
|
@@ -686,7 +824,7 @@ export const workspace = router({
|
|
|
686
824
|
currentFileType,
|
|
687
825
|
maxPages
|
|
688
826
|
);
|
|
689
|
-
|
|
827
|
+
|
|
690
828
|
if (processResult.status === 'error') {
|
|
691
829
|
logger.error(`Failed to process file ${file.name}:`, processResult.error);
|
|
692
830
|
// Continue processing other files even if one fails
|
|
@@ -758,108 +896,179 @@ export const workspace = router({
|
|
|
758
896
|
}
|
|
759
897
|
};
|
|
760
898
|
|
|
899
|
+
// Ensure AI session is initialized before generating artifacts
|
|
900
|
+
try {
|
|
901
|
+
await aiSessionService.initSession(input.workspaceId, ctx.session.user.id);
|
|
902
|
+
} catch (initError) {
|
|
903
|
+
logger.error('Failed to init AI session (continuing with workspace context):', initError);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Fetch current usage and limits to enforce plan restrictions for auto-generation
|
|
907
|
+
const [usage, limits] = await Promise.all([
|
|
908
|
+
getUserUsage(ctx.session.user.id),
|
|
909
|
+
getUserPlanLimits(ctx.session.user.id)
|
|
910
|
+
]);
|
|
911
|
+
|
|
761
912
|
// Generate artifacts - each step is isolated so failures don't block subsequent steps
|
|
762
913
|
if (input.generateStudyGuide) {
|
|
763
|
-
|
|
914
|
+
// Enforcement: Skip if limit reached
|
|
915
|
+
if (limits && usage.studyGuides >= limits.maxStudyGuides) {
|
|
764
916
|
await updateAnalysisProgress(ctx.db, input.workspaceId,
|
|
765
|
-
buildProgress('
|
|
917
|
+
buildProgress('skipped', primaryFile.name, fileType, 'studyGuide', 'skipped', genConfig)
|
|
766
918
|
);
|
|
919
|
+
await PusherService.emitError(input.workspaceId, 'Study guide skipped: Limit reached.', 'study_guide');
|
|
920
|
+
await notifyArtifactFailed(ctx.db, {
|
|
921
|
+
userId: ctx.session.user.id,
|
|
922
|
+
workspaceId: input.workspaceId,
|
|
923
|
+
artifactType: ArtifactType.STUDY_GUIDE,
|
|
924
|
+
message: 'Study guide was skipped because your plan limit was reached.',
|
|
925
|
+
}).catch(() => {});
|
|
926
|
+
} else {
|
|
927
|
+
try {
|
|
928
|
+
await updateAnalysisProgress(ctx.db, input.workspaceId,
|
|
929
|
+
buildProgress('generating_study_guide', primaryFile.name, fileType, 'studyGuide', 'in_progress', genConfig)
|
|
930
|
+
);
|
|
767
931
|
|
|
768
|
-
|
|
932
|
+
const content = await aiSessionService.generateStudyGuide(input.workspaceId, ctx.session.user.id);
|
|
769
933
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
});
|
|
773
|
-
if (!artifact) {
|
|
774
|
-
artifact = await ctx.db.artifact.create({
|
|
775
|
-
data: {
|
|
776
|
-
workspaceId: input.workspaceId,
|
|
777
|
-
type: ArtifactType.STUDY_GUIDE,
|
|
778
|
-
title: files.length === 1 ? `Study Guide - ${primaryFile.name}` : `Study Guide - ${files.length} files`,
|
|
779
|
-
createdById: ctx.session.user.id,
|
|
780
|
-
},
|
|
934
|
+
let artifact = await ctx.db.artifact.findFirst({
|
|
935
|
+
where: { workspaceId: input.workspaceId, type: ArtifactType.STUDY_GUIDE },
|
|
781
936
|
});
|
|
782
|
-
|
|
937
|
+
if (!artifact) {
|
|
938
|
+
artifact = await ctx.db.artifact.create({
|
|
939
|
+
data: {
|
|
940
|
+
workspaceId: input.workspaceId,
|
|
941
|
+
type: ArtifactType.STUDY_GUIDE,
|
|
942
|
+
title: files.length === 1 ? `Study Guide - ${primaryFile.name}` : `Study Guide - ${files.length} files`,
|
|
943
|
+
createdById: ctx.session.user.id,
|
|
944
|
+
},
|
|
945
|
+
});
|
|
946
|
+
}
|
|
783
947
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
948
|
+
const lastVersion = await ctx.db.artifactVersion.findFirst({
|
|
949
|
+
where: { artifact: { workspaceId: input.workspaceId, type: ArtifactType.STUDY_GUIDE } },
|
|
950
|
+
orderBy: { version: 'desc' },
|
|
951
|
+
});
|
|
788
952
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
953
|
+
await ctx.db.artifactVersion.create({
|
|
954
|
+
data: { artifactId: artifact.id, version: lastVersion ? lastVersion.version + 1 : 1, content: content, createdById: ctx.session.user.id },
|
|
955
|
+
});
|
|
792
956
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
957
|
+
results.artifacts.studyGuide = artifact;
|
|
958
|
+
await PusherService.emitStudyGuideComplete(input.workspaceId, artifact);
|
|
959
|
+
await notifyArtifactReady(ctx.db, {
|
|
960
|
+
userId: ctx.session.user.id,
|
|
961
|
+
workspaceId: input.workspaceId,
|
|
962
|
+
artifactId: artifact.id,
|
|
963
|
+
artifactType: ArtifactType.STUDY_GUIDE,
|
|
964
|
+
title: artifact.title,
|
|
965
|
+
}).catch(() => {});
|
|
966
|
+
} catch (sgError) {
|
|
967
|
+
logger.error('Study guide generation failed after retries:', sgError);
|
|
968
|
+
await PusherService.emitError(input.workspaceId, 'Study guide generation failed. Please try regenerating later.', 'study_guide');
|
|
969
|
+
await notifyArtifactFailed(ctx.db, {
|
|
970
|
+
userId: ctx.session.user.id,
|
|
971
|
+
workspaceId: input.workspaceId,
|
|
972
|
+
artifactType: ArtifactType.STUDY_GUIDE,
|
|
973
|
+
message: 'Study guide generation failed. Please try regenerating later.',
|
|
974
|
+
}).catch(() => {});
|
|
975
|
+
// Continue to flashcards - don't abort the whole pipeline
|
|
976
|
+
}
|
|
798
977
|
}
|
|
799
978
|
}
|
|
800
979
|
|
|
801
980
|
if (input.generateFlashcards) {
|
|
802
|
-
|
|
803
|
-
|
|
981
|
+
// Enforcement: Skip if limit reached
|
|
982
|
+
if (limits && usage.flashcards >= limits.maxFlashcards) {
|
|
804
983
|
await updateAnalysisProgress(ctx.db, input.workspaceId,
|
|
805
|
-
buildProgress('
|
|
806
|
-
{ studyGuide: sgStatus } as any)
|
|
984
|
+
buildProgress('skipped', primaryFile.name, fileType, 'flashcards', 'skipped', genConfig)
|
|
807
985
|
);
|
|
986
|
+
await PusherService.emitError(input.workspaceId, 'Flashcards skipped: Limit reached.', 'flashcards');
|
|
987
|
+
await notifyArtifactFailed(ctx.db, {
|
|
988
|
+
userId: ctx.session.user.id,
|
|
989
|
+
workspaceId: input.workspaceId,
|
|
990
|
+
artifactType: ArtifactType.FLASHCARD_SET,
|
|
991
|
+
message: 'Flashcards were skipped because your plan limit was reached.',
|
|
992
|
+
}).catch(() => {});
|
|
993
|
+
} else {
|
|
994
|
+
try {
|
|
995
|
+
const sgStatus = input.generateStudyGuide ? (results.artifacts.studyGuide ? 'completed' : 'error') : 'skipped';
|
|
996
|
+
await updateAnalysisProgress(ctx.db, input.workspaceId,
|
|
997
|
+
buildProgress('generating_flashcards', primaryFile.name, fileType, 'flashcards', 'in_progress', genConfig,
|
|
998
|
+
{ studyGuide: sgStatus } as any)
|
|
999
|
+
);
|
|
808
1000
|
|
|
809
|
-
|
|
1001
|
+
const content = await aiSessionService.generateFlashcardQuestions(input.workspaceId, ctx.session.user.id, 10, 'medium');
|
|
810
1002
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
1003
|
+
const artifact = await ctx.db.artifact.create({
|
|
1004
|
+
data: {
|
|
1005
|
+
workspaceId: input.workspaceId,
|
|
1006
|
+
type: ArtifactType.FLASHCARD_SET,
|
|
1007
|
+
title: files.length === 1 ? `Flashcards - ${primaryFile.name}` : `Flashcards - ${files.length} files`,
|
|
1008
|
+
createdById: ctx.session.user.id,
|
|
1009
|
+
},
|
|
1010
|
+
});
|
|
819
1011
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
1012
|
+
// Parse JSON flashcard content
|
|
1013
|
+
try {
|
|
1014
|
+
const parsed = typeof content === 'string' ? JSON.parse(content) : content;
|
|
1015
|
+
const flashcardData = Array.isArray(parsed) ? parsed : (parsed.flashcards || []);
|
|
823
1016
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1017
|
+
for (let i = 0; i < Math.min(flashcardData.length, 10); i++) {
|
|
1018
|
+
const card = flashcardData[i];
|
|
1019
|
+
const front = card.term || card.front || card.question || card.prompt || `Question ${i + 1}`;
|
|
1020
|
+
const back = card.definition || card.back || card.answer || card.solution || `Answer ${i + 1}`;
|
|
828
1021
|
|
|
829
|
-
await ctx.db.flashcard.create({
|
|
830
|
-
data: {
|
|
831
|
-
artifactId: artifact.id,
|
|
832
|
-
front: front,
|
|
833
|
-
back: back,
|
|
834
|
-
order: i,
|
|
835
|
-
tags: ['ai-generated', 'medium'],
|
|
836
|
-
},
|
|
837
|
-
});
|
|
838
|
-
}
|
|
839
|
-
} catch (parseError) {
|
|
840
|
-
// Fallback to text parsing if JSON fails
|
|
841
|
-
const lines = content.split('\n').filter((line: string) => line.trim());
|
|
842
|
-
for (let i = 0; i < Math.min(lines.length, 10); i++) {
|
|
843
|
-
const line = lines[i];
|
|
844
|
-
if (line.includes(' - ')) {
|
|
845
|
-
const [front, back] = line.split(' - ');
|
|
846
1022
|
await ctx.db.flashcard.create({
|
|
847
1023
|
data: {
|
|
848
1024
|
artifactId: artifact.id,
|
|
849
|
-
front: front
|
|
850
|
-
back: back
|
|
1025
|
+
front: front,
|
|
1026
|
+
back: back,
|
|
851
1027
|
order: i,
|
|
852
1028
|
tags: ['ai-generated', 'medium'],
|
|
853
1029
|
},
|
|
854
1030
|
});
|
|
855
1031
|
}
|
|
1032
|
+
} catch (parseError) {
|
|
1033
|
+
console.error("Failed to parse flashcard JSON or create cards in workspace router:", parseError);
|
|
1034
|
+
// Fallback to text parsing if JSON fails
|
|
1035
|
+
const lines = content.split('\n').filter((line: string) => line.trim());
|
|
1036
|
+
for (let i = 0; i < Math.min(lines.length, 10); i++) {
|
|
1037
|
+
const line = lines[i];
|
|
1038
|
+
if (line.includes(' - ')) {
|
|
1039
|
+
const [front, back] = line.split(' - ');
|
|
1040
|
+
await ctx.db.flashcard.create({
|
|
1041
|
+
data: {
|
|
1042
|
+
artifactId: artifact.id,
|
|
1043
|
+
front: front.trim(),
|
|
1044
|
+
back: back.trim(),
|
|
1045
|
+
order: i,
|
|
1046
|
+
tags: ['ai-generated', 'medium'],
|
|
1047
|
+
},
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
856
1051
|
}
|
|
857
|
-
}
|
|
858
1052
|
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
1053
|
+
results.artifacts.flashcards = artifact;
|
|
1054
|
+
await PusherService.emitFlashcardComplete(input.workspaceId, artifact);
|
|
1055
|
+
await notifyArtifactReady(ctx.db, {
|
|
1056
|
+
userId: ctx.session.user.id,
|
|
1057
|
+
workspaceId: input.workspaceId,
|
|
1058
|
+
artifactId: artifact.id,
|
|
1059
|
+
artifactType: ArtifactType.FLASHCARD_SET,
|
|
1060
|
+
title: artifact.title,
|
|
1061
|
+
}).catch(() => {});
|
|
1062
|
+
} catch (fcError) {
|
|
1063
|
+
logger.error('Flashcard generation failed after retries:', fcError);
|
|
1064
|
+
await PusherService.emitError(input.workspaceId, 'Flashcard generation failed. Please try regenerating later.', 'flashcards');
|
|
1065
|
+
await notifyArtifactFailed(ctx.db, {
|
|
1066
|
+
userId: ctx.session.user.id,
|
|
1067
|
+
workspaceId: input.workspaceId,
|
|
1068
|
+
artifactType: ArtifactType.FLASHCARD_SET,
|
|
1069
|
+
message: 'Flashcard generation failed. Please try regenerating later.',
|
|
1070
|
+
}).catch(() => {});
|
|
1071
|
+
}
|
|
863
1072
|
}
|
|
864
1073
|
}
|
|
865
1074
|
|
|
@@ -886,51 +1095,49 @@ export const workspace = router({
|
|
|
886
1095
|
search: authedProcedure
|
|
887
1096
|
.input(z.object({
|
|
888
1097
|
query: z.string(),
|
|
1098
|
+
color: z.string().optional(),
|
|
889
1099
|
limit: z.number().min(1).max(100).default(20),
|
|
890
1100
|
}))
|
|
891
1101
|
.query(async ({ ctx, input }) => {
|
|
892
|
-
const { query } = input;
|
|
1102
|
+
const { query, color } = input;
|
|
1103
|
+
|
|
1104
|
+
// 1. Search Workspaces
|
|
893
1105
|
const workspaces = await ctx.db.workspace.findMany({
|
|
894
1106
|
where: {
|
|
895
1107
|
ownerId: ctx.session.user.id,
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
{
|
|
904
|
-
description: {
|
|
905
|
-
contains: query,
|
|
906
|
-
mode: 'insensitive',
|
|
907
|
-
},
|
|
908
|
-
},
|
|
909
|
-
],
|
|
910
|
-
},
|
|
911
|
-
orderBy: {
|
|
912
|
-
updatedAt: 'desc',
|
|
1108
|
+
markerColor: color || undefined,
|
|
1109
|
+
...(query ? {
|
|
1110
|
+
OR: [
|
|
1111
|
+
{ title: { contains: query, mode: 'insensitive' } },
|
|
1112
|
+
{ description: { contains: query, mode: 'insensitive' } },
|
|
1113
|
+
],
|
|
1114
|
+
} : {}),
|
|
913
1115
|
},
|
|
1116
|
+
orderBy: { updatedAt: 'desc' },
|
|
914
1117
|
take: input.limit,
|
|
915
1118
|
});
|
|
916
1119
|
|
|
917
|
-
//
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
);
|
|
1120
|
+
// 2. Search Folders
|
|
1121
|
+
const folders = await ctx.db.folder.findMany({
|
|
1122
|
+
where: {
|
|
1123
|
+
ownerId: ctx.session.user.id,
|
|
1124
|
+
markerColor: color || undefined,
|
|
1125
|
+
...(query ? {
|
|
1126
|
+
name: { contains: query, mode: 'insensitive' },
|
|
1127
|
+
} : {}),
|
|
1128
|
+
},
|
|
1129
|
+
orderBy: { updatedAt: 'desc' },
|
|
1130
|
+
take: input.limit,
|
|
1131
|
+
});
|
|
930
1132
|
|
|
931
|
-
|
|
1133
|
+
// Combined results with type discriminator
|
|
1134
|
+
const results = [
|
|
1135
|
+
...workspaces.map((w: any) => ({ ...w, type: 'workspace' as const })),
|
|
1136
|
+
...folders.map((f: any) => ({ ...f, type: 'folder' as const, title: f.name })), // normalize name to title
|
|
1137
|
+
].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
|
|
1138
|
+
.slice(0, input.limit);
|
|
932
1139
|
|
|
933
|
-
return
|
|
1140
|
+
return results;
|
|
934
1141
|
}),
|
|
935
1142
|
|
|
936
1143
|
// Members sub-router
|