@goscribe/server 1.5.0 → 1.6.0
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/context.d.ts +14 -1
- package/dist/context.js +23 -2
- package/dist/lib/ai/index.d.ts +3 -2
- package/dist/lib/ai/index.js +3 -2
- package/dist/lib/ai/llm-client.d.ts +1 -0
- package/dist/lib/ai/llm-client.js +17 -0
- package/dist/routers/_app.d.ts +40 -80
- package/dist/routers/auth.js +1 -1
- package/dist/routers/flashcards.d.ts +12 -1
- package/dist/routers/payment.d.ts +1 -12
- package/dist/routers/workspace.d.ts +27 -67
- package/dist/routers/workspace.js +1 -0
- package/dist/services/billing/payment.service.d.ts +1 -12
- package/dist/services/billing/payment.service.js +3 -6
- package/dist/services/billing/usage.service.d.ts +30 -10
- package/dist/services/billing/usage.service.js +87 -15
- package/dist/services/content/copilot.service.js +15 -29
- package/dist/services/content/flashcard-progress.service.js +9 -9
- package/dist/services/content/flashcard.service.d.ts +45 -1
- package/dist/services/content/flashcard.service.js +81 -68
- package/dist/services/content/media-analysis.service.js +27 -27
- package/dist/services/content/worksheet-generation.service.test.js +2 -2
- package/dist/services/workspace/workspace.service.d.ts +23 -67
- package/dist/services/workspace/workspace.service.js +69 -62
- package/dist/trpc.d.ts +12 -4
- package/dist/trpc.js +5 -11
- package/package.json +1 -1
- package/src/context.ts +33 -3
- package/src/lib/ai/index.ts +3 -0
- package/src/lib/ai/llm-client.ts +23 -0
- package/src/routers/auth.ts +1 -1
- package/src/routers/workspace.ts +4 -0
- package/src/services/billing/payment.service.ts +3 -6
- package/src/services/billing/usage.service.ts +190 -77
- package/src/services/content/copilot.service.ts +23 -32
- package/src/services/content/flashcard-progress.service.ts +12 -9
- package/src/services/content/flashcard.service.ts +89 -66
- package/src/services/content/media-analysis.service.ts +34 -29
- package/src/services/content/worksheet-generation.service.test.ts +2 -2
- package/src/services/workspace/workspace.service.ts +73 -66
- package/src/trpc.ts +5 -13
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { prisma } from '../../lib/prisma.js';
|
|
2
|
+
import { workspaceAccessWhere } from '../../repositories/workspace.repository.js';
|
|
2
3
|
const FALLBACK_PLAN_LIMITS = {
|
|
3
4
|
id: 'fallback-free',
|
|
4
5
|
planId: 'fallback-free',
|
|
@@ -8,10 +9,37 @@ const FALLBACK_PLAN_LIMITS = {
|
|
|
8
9
|
maxPodcasts: 0,
|
|
9
10
|
maxStudyGuides: 1,
|
|
10
11
|
};
|
|
12
|
+
const CACHE_TTL_MS = 30000;
|
|
13
|
+
const usageCache = new Map();
|
|
14
|
+
const limitsCache = new Map();
|
|
15
|
+
const accountSummaryCache = new Map();
|
|
16
|
+
function readCache(map, userId) {
|
|
17
|
+
const entry = map.get(userId);
|
|
18
|
+
if (!entry || entry.expiresAt <= Date.now()) {
|
|
19
|
+
map.delete(userId);
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return entry.value;
|
|
23
|
+
}
|
|
24
|
+
function writeCache(map, userId, value) {
|
|
25
|
+
map.set(userId, { value, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
26
|
+
}
|
|
27
|
+
/** Bust cached usage/limit reads after creates, deletes, or billing changes. */
|
|
28
|
+
export function invalidateUserBillingCache(userId) {
|
|
29
|
+
usageCache.delete(userId);
|
|
30
|
+
limitsCache.delete(userId);
|
|
31
|
+
accountSummaryCache.delete(userId);
|
|
32
|
+
}
|
|
33
|
+
function workspaceAccessFilter(userId) {
|
|
34
|
+
return workspaceAccessWhere(userId);
|
|
35
|
+
}
|
|
11
36
|
/**
|
|
12
37
|
* Counts all resources consumed by a user across the platform.
|
|
13
38
|
*/
|
|
14
39
|
export async function getUserUsage(userId) {
|
|
40
|
+
const cached = readCache(usageCache, userId);
|
|
41
|
+
if (cached)
|
|
42
|
+
return cached;
|
|
15
43
|
const [flashcards, worksheets, studyGuides, podcasts, storageResult] = await Promise.all([
|
|
16
44
|
prisma.artifact.count({ where: { createdById: userId, type: 'FLASHCARD_SET' } }),
|
|
17
45
|
prisma.artifact.count({ where: { createdById: userId, type: 'WORKSHEET' } }),
|
|
@@ -19,53 +47,55 @@ export async function getUserUsage(userId) {
|
|
|
19
47
|
prisma.artifact.count({ where: { createdById: userId, type: 'PODCAST_EPISODE' } }),
|
|
20
48
|
prisma.fileAsset.aggregate({
|
|
21
49
|
_sum: { size: true },
|
|
22
|
-
where: { userId }
|
|
23
|
-
})
|
|
50
|
+
where: { userId },
|
|
51
|
+
}),
|
|
24
52
|
]);
|
|
25
|
-
|
|
53
|
+
const usage = {
|
|
26
54
|
flashcards,
|
|
27
55
|
worksheets,
|
|
28
56
|
studyGuides,
|
|
29
57
|
podcasts,
|
|
30
|
-
storageBytes: Number(storageResult._sum.size || 0)
|
|
58
|
+
storageBytes: Number(storageResult._sum.size || 0),
|
|
31
59
|
};
|
|
60
|
+
writeCache(usageCache, userId, usage);
|
|
61
|
+
return usage;
|
|
32
62
|
}
|
|
33
63
|
/**
|
|
34
64
|
* Retrieves the specific plan limits for a user based on their active subscription
|
|
35
65
|
* PLUS any extra credits purchased.
|
|
36
66
|
*/
|
|
37
67
|
export async function getUserPlanLimits(userId) {
|
|
68
|
+
const cached = readCache(limitsCache, userId);
|
|
69
|
+
if (cached)
|
|
70
|
+
return cached;
|
|
38
71
|
const [activeSub, freePlan, userCredits] = await Promise.all([
|
|
39
72
|
prisma.subscription.findFirst({
|
|
40
73
|
where: { userId, status: 'active' },
|
|
41
74
|
include: { plan: { include: { limit: true } } },
|
|
42
|
-
orderBy: { createdAt: 'desc' }
|
|
75
|
+
orderBy: { createdAt: 'desc' },
|
|
43
76
|
}),
|
|
44
77
|
prisma.plan.findFirst({
|
|
45
78
|
where: {
|
|
46
79
|
active: true,
|
|
47
80
|
price: 0,
|
|
48
|
-
limit: { isNot: null }
|
|
81
|
+
limit: { isNot: null },
|
|
49
82
|
},
|
|
50
83
|
include: { limit: true },
|
|
51
|
-
orderBy: { createdAt: 'asc' }
|
|
84
|
+
orderBy: { createdAt: 'asc' },
|
|
52
85
|
}),
|
|
53
86
|
prisma.userCredit.groupBy({
|
|
54
87
|
by: ['resourceType'],
|
|
55
88
|
where: { userId },
|
|
56
|
-
_sum: { amount: true }
|
|
57
|
-
})
|
|
89
|
+
_sum: { amount: true },
|
|
90
|
+
}),
|
|
58
91
|
]);
|
|
59
92
|
const baseLimit = activeSub?.plan?.limit ?? freePlan?.limit;
|
|
60
|
-
// Prefer subscription limits, then DB free-plan limits, then hardcoded safety fallback.
|
|
61
93
|
const base = baseLimit ?? FALLBACK_PLAN_LIMITS;
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
// Return the merged total limit
|
|
65
|
-
return {
|
|
94
|
+
const getCreditSum = (type) => userCredits.find((c) => c.resourceType === type)?._sum.amount || 0;
|
|
95
|
+
const limits = {
|
|
66
96
|
id: base.id,
|
|
67
97
|
planId: base.planId,
|
|
68
|
-
maxStorageBytes: BigInt(Number(base.maxStorageBytes) +
|
|
98
|
+
maxStorageBytes: BigInt(Number(base.maxStorageBytes) + getCreditSum('STORAGE') * 1024 * 1024),
|
|
69
99
|
maxWorksheets: base.maxWorksheets + getCreditSum('WORKSHEET'),
|
|
70
100
|
maxFlashcards: base.maxFlashcards + getCreditSum('FLASHCARD_SET'),
|
|
71
101
|
maxPodcasts: base.maxPodcasts + getCreditSum('PODCAST_EPISODE'),
|
|
@@ -74,4 +104,46 @@ export async function getUserPlanLimits(userId) {
|
|
|
74
104
|
updatedAt: baseLimit?.updatedAt || new Date(),
|
|
75
105
|
isFallbackPlan: !baseLimit,
|
|
76
106
|
};
|
|
107
|
+
writeCache(limitsCache, userId, limits);
|
|
108
|
+
return limits;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Sidebar/dashboard summary: stats + usage + limits in one parallel DB round-trip batch.
|
|
112
|
+
*/
|
|
113
|
+
export async function getAccountSummary(userId) {
|
|
114
|
+
const cached = readCache(accountSummaryCache, userId);
|
|
115
|
+
if (cached)
|
|
116
|
+
return cached;
|
|
117
|
+
const workspaceWhere = workspaceAccessFilter(userId);
|
|
118
|
+
const [workspaceMeta, folderCount, usage, limits, storageUsed] = await Promise.all([
|
|
119
|
+
prisma.workspace.aggregate({
|
|
120
|
+
where: workspaceWhere,
|
|
121
|
+
_count: { _all: true },
|
|
122
|
+
_max: { updatedAt: true },
|
|
123
|
+
}),
|
|
124
|
+
prisma.folder.count({ where: { ownerId: userId } }),
|
|
125
|
+
getUserUsage(userId),
|
|
126
|
+
getUserPlanLimits(userId),
|
|
127
|
+
prisma.fileAsset.aggregate({
|
|
128
|
+
where: {
|
|
129
|
+
userId,
|
|
130
|
+
workspace: workspaceWhere,
|
|
131
|
+
},
|
|
132
|
+
_sum: { size: true },
|
|
133
|
+
}),
|
|
134
|
+
]);
|
|
135
|
+
const summary = {
|
|
136
|
+
stats: {
|
|
137
|
+
workspaces: workspaceMeta._count._all,
|
|
138
|
+
folders: folderCount,
|
|
139
|
+
lastUpdated: workspaceMeta._max.updatedAt,
|
|
140
|
+
spaceUsed: Number(storageUsed._sum.size ?? 0),
|
|
141
|
+
spaceTotal: Number(limits.maxStorageBytes),
|
|
142
|
+
},
|
|
143
|
+
usage,
|
|
144
|
+
limits,
|
|
145
|
+
hasActivePlan: !limits.isFallbackPlan,
|
|
146
|
+
};
|
|
147
|
+
writeCache(accountSummaryCache, userId, summary);
|
|
148
|
+
return summary;
|
|
77
149
|
}
|
|
@@ -6,7 +6,7 @@ import { workspaceKbService } from '../workspace/workspace-kb.service.js';
|
|
|
6
6
|
import { sanitizeString } from '../../lib/validation.js';
|
|
7
7
|
import { workspaceAccessWhere } from '../../repositories/workspace.repository.js';
|
|
8
8
|
import PusherService from '../../lib/pusher.js';
|
|
9
|
-
import {
|
|
9
|
+
import { FlashcardService } from './flashcard.service.js';
|
|
10
10
|
export const copilotArtifactType = z.enum([
|
|
11
11
|
'study-guide',
|
|
12
12
|
'worksheet',
|
|
@@ -304,9 +304,6 @@ export class CopilotService extends BaseService {
|
|
|
304
304
|
async ask(userId, input) {
|
|
305
305
|
enforceRateLimit(userId, input.context.workspaceId);
|
|
306
306
|
await this.assertWorkspaceAccess(userId, input.context.workspaceId);
|
|
307
|
-
await PusherService.emitTaskComplete(input.context.workspaceId, 'copilot:thinking', {
|
|
308
|
-
status: 'started',
|
|
309
|
-
});
|
|
310
307
|
const history = await this.loadHistory(input.conversationId);
|
|
311
308
|
const model = await callCopilotModel({
|
|
312
309
|
mode: 'ask',
|
|
@@ -318,9 +315,6 @@ export class CopilotService extends BaseService {
|
|
|
318
315
|
model.rawContent ||
|
|
319
316
|
'I could not generate a response.';
|
|
320
317
|
const safeHighlights = normalizeHighlightInstructions(model.parsed?.highlights, input.context.documentPlainText ?? input.context.documentContent);
|
|
321
|
-
await PusherService.emitTaskComplete(input.context.workspaceId, 'copilot:response', {
|
|
322
|
-
status: 'completed',
|
|
323
|
-
});
|
|
324
318
|
await this.persistConversationExchange({
|
|
325
319
|
userId,
|
|
326
320
|
workspaceId: input.context.workspaceId,
|
|
@@ -333,9 +327,6 @@ export class CopilotService extends BaseService {
|
|
|
333
327
|
async explainSelection(userId, input) {
|
|
334
328
|
enforceRateLimit(userId, input.context.workspaceId);
|
|
335
329
|
await this.assertWorkspaceAccess(userId, input.context.workspaceId);
|
|
336
|
-
await PusherService.emitTaskComplete(input.context.workspaceId, 'copilot:thinking', {
|
|
337
|
-
status: 'started',
|
|
338
|
-
});
|
|
339
330
|
const question = input.message && input.message.trim().length > 0
|
|
340
331
|
? input.message
|
|
341
332
|
: 'Explain the selected text in simple study-friendly terms and include one practical example.';
|
|
@@ -349,9 +340,6 @@ export class CopilotService extends BaseService {
|
|
|
349
340
|
const answer = getAnswerFromParsedJSON(model.parsed) ||
|
|
350
341
|
model.rawContent ||
|
|
351
342
|
'I could not explain this selection.';
|
|
352
|
-
await PusherService.emitTaskComplete(input.context.workspaceId, 'copilot:response', {
|
|
353
|
-
status: 'completed',
|
|
354
|
-
});
|
|
355
343
|
await this.persistConversationExchange({
|
|
356
344
|
userId,
|
|
357
345
|
workspaceId: input.context.workspaceId,
|
|
@@ -413,22 +401,20 @@ export class CopilotService extends BaseService {
|
|
|
413
401
|
message: 'Copilot did not return valid flashcards.',
|
|
414
402
|
});
|
|
415
403
|
}
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
},
|
|
431
|
-
},
|
|
404
|
+
const flashcardService = new FlashcardService(this.db);
|
|
405
|
+
const primarySet = await flashcardService.ensurePrimarySet(userId, input.context.workspaceId);
|
|
406
|
+
const orderOffset = primarySet.flashcards.reduce((max, card) => Math.max(max, card.order), -1);
|
|
407
|
+
await this.db.flashcard.createMany({
|
|
408
|
+
data: cards.map((card, index) => ({
|
|
409
|
+
artifactId: primarySet.id,
|
|
410
|
+
front: sanitizeString(card.front, 200),
|
|
411
|
+
back: sanitizeString(card.back, 500),
|
|
412
|
+
order: orderOffset + 1 + index,
|
|
413
|
+
tags: ['copilot-generated'],
|
|
414
|
+
})),
|
|
415
|
+
});
|
|
416
|
+
const artifact = await this.db.artifact.findUniqueOrThrow({
|
|
417
|
+
where: { id: primarySet.id },
|
|
432
418
|
include: { flashcards: true },
|
|
433
419
|
});
|
|
434
420
|
await PusherService.emitTaskComplete(input.context.workspaceId, 'copilot:response', {
|
|
@@ -239,23 +239,23 @@ export class FlashcardProgressService extends BaseService {
|
|
|
239
239
|
async getDueFlashcards(userId, workspaceId) {
|
|
240
240
|
const now = new Date();
|
|
241
241
|
const LOW_MASTERY_THRESHOLD = 50; // Consider mastery < 50 as low
|
|
242
|
-
|
|
243
|
-
const latestArtifact = await this.db.artifact.findFirst({
|
|
242
|
+
const flashcardSets = await this.db.artifact.findMany({
|
|
244
243
|
where: {
|
|
245
244
|
workspaceId,
|
|
246
245
|
type: 'FLASHCARD_SET',
|
|
247
246
|
},
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
},
|
|
247
|
+
include: { _count: { select: { flashcards: true } } },
|
|
248
|
+
orderBy: { createdAt: 'asc' },
|
|
251
249
|
});
|
|
252
|
-
if (
|
|
250
|
+
if (flashcardSets.length === 0) {
|
|
253
251
|
return [];
|
|
254
252
|
}
|
|
255
|
-
|
|
253
|
+
const withCards = flashcardSets.filter((set) => set._count.flashcards > 0);
|
|
254
|
+
const candidates = withCards.length > 0 ? withCards : flashcardSets;
|
|
255
|
+
const primaryArtifact = candidates.reduce((best, current) => current._count.flashcards > best._count.flashcards ? current : best);
|
|
256
256
|
const allFlashcards = await this.db.flashcard.findMany({
|
|
257
257
|
where: {
|
|
258
|
-
artifactId:
|
|
258
|
+
artifactId: primaryArtifact.id,
|
|
259
259
|
},
|
|
260
260
|
include: {
|
|
261
261
|
artifact: true,
|
|
@@ -290,7 +290,7 @@ export class FlashcardProgressService extends BaseService {
|
|
|
290
290
|
}
|
|
291
291
|
],
|
|
292
292
|
flashcard: {
|
|
293
|
-
artifactId:
|
|
293
|
+
artifactId: primaryArtifact.id,
|
|
294
294
|
},
|
|
295
295
|
},
|
|
296
296
|
include: {
|
|
@@ -9,6 +9,39 @@ export declare const typedAnswerGradeSchema: z.ZodObject<{
|
|
|
9
9
|
}, z.core.$strip>;
|
|
10
10
|
export declare class FlashcardService extends BaseService {
|
|
11
11
|
constructor(db: PrismaClient);
|
|
12
|
+
private findPrimarySet;
|
|
13
|
+
/** Returns the primary set, creating one only when flashcard content is first added. */
|
|
14
|
+
ensurePrimarySet(userId: string, workspaceId: string): Promise<{
|
|
15
|
+
flashcards: {
|
|
16
|
+
id: string;
|
|
17
|
+
createdAt: Date;
|
|
18
|
+
artifactId: string;
|
|
19
|
+
tags: string[];
|
|
20
|
+
order: number;
|
|
21
|
+
front: string;
|
|
22
|
+
back: string;
|
|
23
|
+
acceptedAnswers: string[];
|
|
24
|
+
}[];
|
|
25
|
+
_count: {
|
|
26
|
+
flashcards: number;
|
|
27
|
+
};
|
|
28
|
+
} & {
|
|
29
|
+
id: string;
|
|
30
|
+
createdAt: Date;
|
|
31
|
+
updatedAt: Date;
|
|
32
|
+
workspaceId: string;
|
|
33
|
+
type: import("@prisma/client").$Enums.ArtifactType;
|
|
34
|
+
title: string;
|
|
35
|
+
isArchived: boolean;
|
|
36
|
+
difficulty: import("@prisma/client").$Enums.Difficulty | null;
|
|
37
|
+
estimatedTime: string | null;
|
|
38
|
+
createdById: string | null;
|
|
39
|
+
description: string | null;
|
|
40
|
+
generating: boolean;
|
|
41
|
+
generatingMetadata: import("@prisma/client/runtime/library").JsonValue | null;
|
|
42
|
+
worksheetConfig: import("@prisma/client/runtime/library").JsonValue | null;
|
|
43
|
+
imageObjectKey: string | null;
|
|
44
|
+
}>;
|
|
12
45
|
listSets(userId: string, workspaceId: string): Promise<({
|
|
13
46
|
versions: {
|
|
14
47
|
id: string;
|
|
@@ -64,7 +97,7 @@ export declare class FlashcardService extends BaseService {
|
|
|
64
97
|
back: string;
|
|
65
98
|
acceptedAnswers: string[];
|
|
66
99
|
})[]>;
|
|
67
|
-
isGenerating(userId: string, workspaceId: string): Promise<boolean
|
|
100
|
+
isGenerating(userId: string, workspaceId: string): Promise<boolean>;
|
|
68
101
|
createCard(userId: string, input: {
|
|
69
102
|
workspaceId: string;
|
|
70
103
|
front: string;
|
|
@@ -119,6 +152,17 @@ export declare class FlashcardService extends BaseService {
|
|
|
119
152
|
tags?: string[];
|
|
120
153
|
}): Promise<{
|
|
121
154
|
artifact: {
|
|
155
|
+
flashcards: {
|
|
156
|
+
id: string;
|
|
157
|
+
createdAt: Date;
|
|
158
|
+
artifactId: string;
|
|
159
|
+
tags: string[];
|
|
160
|
+
order: number;
|
|
161
|
+
front: string;
|
|
162
|
+
back: string;
|
|
163
|
+
acceptedAnswers: string[];
|
|
164
|
+
}[];
|
|
165
|
+
} & {
|
|
122
166
|
id: string;
|
|
123
167
|
createdAt: Date;
|
|
124
168
|
updatedAt: Date;
|
|
@@ -7,6 +7,7 @@ import { ai } from '../../lib/ai/index.js';
|
|
|
7
7
|
import { workspaceKbService } from '../workspace/workspace-kb.service.js';
|
|
8
8
|
import PusherService from '../../lib/pusher.js';
|
|
9
9
|
import { notifyArtifactFailed, notifyArtifactReady } from '../notifications/notification.service.js';
|
|
10
|
+
import { invalidateUserBillingCache } from '../billing/usage.service.js';
|
|
10
11
|
export const typedAnswerGradeSchema = z.object({
|
|
11
12
|
isCorrect: z.boolean(),
|
|
12
13
|
confidence: z.number().min(0).max(1),
|
|
@@ -41,6 +42,50 @@ export class FlashcardService extends BaseService {
|
|
|
41
42
|
constructor(db) {
|
|
42
43
|
super(db);
|
|
43
44
|
}
|
|
45
|
+
async findPrimarySet(userId, workspaceId) {
|
|
46
|
+
const sets = await this.db.artifact.findMany({
|
|
47
|
+
where: {
|
|
48
|
+
workspaceId,
|
|
49
|
+
type: ArtifactType.FLASHCARD_SET,
|
|
50
|
+
workspace: workspaceAccessWhere(userId),
|
|
51
|
+
},
|
|
52
|
+
include: {
|
|
53
|
+
flashcards: { orderBy: { order: 'asc' } },
|
|
54
|
+
_count: { select: { flashcards: true } },
|
|
55
|
+
},
|
|
56
|
+
orderBy: { createdAt: 'asc' },
|
|
57
|
+
});
|
|
58
|
+
if (sets.length === 0)
|
|
59
|
+
return null;
|
|
60
|
+
const withCards = sets.filter((set) => set._count.flashcards > 0);
|
|
61
|
+
const candidates = withCards.length > 0 ? withCards : sets;
|
|
62
|
+
return candidates.reduce((best, current) => current._count.flashcards > best._count.flashcards ? current : best);
|
|
63
|
+
}
|
|
64
|
+
/** Returns the primary set, creating one only when flashcard content is first added. */
|
|
65
|
+
async ensurePrimarySet(userId, workspaceId) {
|
|
66
|
+
const existing = await this.findPrimarySet(userId, workspaceId);
|
|
67
|
+
if (existing)
|
|
68
|
+
return existing;
|
|
69
|
+
const workspace = await this.db.workspace.findFirst({
|
|
70
|
+
where: { id: workspaceId, ...workspaceAccessWhere(userId) },
|
|
71
|
+
select: { id: true, title: true },
|
|
72
|
+
});
|
|
73
|
+
if (!workspace) {
|
|
74
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Workspace not found' });
|
|
75
|
+
}
|
|
76
|
+
return this.db.artifact.create({
|
|
77
|
+
data: {
|
|
78
|
+
workspaceId,
|
|
79
|
+
type: ArtifactType.FLASHCARD_SET,
|
|
80
|
+
title: 'Flashcards',
|
|
81
|
+
createdById: userId,
|
|
82
|
+
},
|
|
83
|
+
include: {
|
|
84
|
+
flashcards: { orderBy: { order: 'asc' } },
|
|
85
|
+
_count: { select: { flashcards: true } },
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
44
89
|
async listSets(userId, workspaceId) {
|
|
45
90
|
const workspace = await this.db.workspace.findFirst({
|
|
46
91
|
where: { id: workspaceId, ownerId: userId },
|
|
@@ -56,57 +101,37 @@ export class FlashcardService extends BaseService {
|
|
|
56
101
|
});
|
|
57
102
|
}
|
|
58
103
|
async listCards(userId, workspaceId) {
|
|
59
|
-
const set = await this.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
},
|
|
104
|
+
const set = await this.findPrimarySet(userId, workspaceId);
|
|
105
|
+
if (!set)
|
|
106
|
+
return [];
|
|
107
|
+
return this.db.flashcard.findMany({
|
|
108
|
+
where: { artifactId: set.id },
|
|
65
109
|
include: {
|
|
66
|
-
|
|
67
|
-
include: {
|
|
68
|
-
progress: { where: { userId } },
|
|
69
|
-
},
|
|
70
|
-
},
|
|
110
|
+
progress: { where: { userId } },
|
|
71
111
|
},
|
|
72
|
-
orderBy: {
|
|
112
|
+
orderBy: { order: 'asc' },
|
|
73
113
|
});
|
|
74
|
-
if (!set)
|
|
75
|
-
throw new TRPCError({ code: 'NOT_FOUND' });
|
|
76
|
-
return set.flashcards;
|
|
77
114
|
}
|
|
78
115
|
async isGenerating(userId, workspaceId) {
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
workspaceId,
|
|
82
|
-
type: ArtifactType.FLASHCARD_SET,
|
|
83
|
-
workspace: workspaceAccessWhere(userId),
|
|
84
|
-
},
|
|
85
|
-
orderBy: { createdAt: 'desc' },
|
|
86
|
-
});
|
|
87
|
-
return artifact?.generating;
|
|
116
|
+
const set = await this.findPrimarySet(userId, workspaceId);
|
|
117
|
+
return set?.generating ?? false;
|
|
88
118
|
}
|
|
89
119
|
async createCard(userId, input) {
|
|
90
|
-
const set = await this.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
},
|
|
95
|
-
include: { flashcards: true },
|
|
96
|
-
orderBy: { updatedAt: 'desc' },
|
|
97
|
-
});
|
|
98
|
-
if (!set)
|
|
99
|
-
throw new TRPCError({ code: 'NOT_FOUND' });
|
|
100
|
-
return this.db.flashcard.create({
|
|
120
|
+
const set = await this.ensurePrimarySet(userId, input.workspaceId);
|
|
121
|
+
const nextOrder = input.order ??
|
|
122
|
+
(set.flashcards.reduce((max, card) => Math.max(max, card.order), -1) + 1);
|
|
123
|
+
const card = await this.db.flashcard.create({
|
|
101
124
|
data: {
|
|
102
125
|
artifactId: set.id,
|
|
103
126
|
front: input.front,
|
|
104
127
|
back: input.back,
|
|
105
128
|
acceptedAnswers: normalizeAcceptedAnswers(input.acceptedAnswers),
|
|
106
129
|
tags: input.tags ?? [],
|
|
107
|
-
order:
|
|
130
|
+
order: nextOrder,
|
|
108
131
|
},
|
|
109
132
|
});
|
|
133
|
+
invalidateUserBillingCache(userId);
|
|
134
|
+
return card;
|
|
110
135
|
}
|
|
111
136
|
async updateCard(userId, input) {
|
|
112
137
|
const card = await this.db.flashcard.findFirst({
|
|
@@ -217,37 +242,23 @@ export class FlashcardService extends BaseService {
|
|
|
217
242
|
});
|
|
218
243
|
if (!workspace)
|
|
219
244
|
throw new TRPCError({ code: 'NOT_FOUND' });
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
workspaceId: input.workspaceId,
|
|
223
|
-
type: ArtifactType.FLASHCARD_SET,
|
|
224
|
-
},
|
|
225
|
-
select: { id: true, flashcards: true },
|
|
226
|
-
orderBy: { updatedAt: 'desc' },
|
|
227
|
-
});
|
|
245
|
+
const primarySet = await this.ensurePrimarySet(userId, input.workspaceId);
|
|
246
|
+
const orderOffset = primarySet.flashcards.reduce((max, card) => Math.max(max, card.order), -1);
|
|
228
247
|
try {
|
|
229
248
|
await PusherService.emitTaskComplete(input.workspaceId, 'flash_card_info', {
|
|
230
249
|
status: 'generating',
|
|
231
250
|
numCards: input.numCards,
|
|
232
251
|
difficulty: input.difficulty,
|
|
233
252
|
});
|
|
234
|
-
|
|
253
|
+
await this.db.artifact.update({
|
|
254
|
+
where: { id: primarySet.id },
|
|
235
255
|
data: {
|
|
236
|
-
workspaceId: input.workspaceId,
|
|
237
|
-
type: ArtifactType.FLASHCARD_SET,
|
|
238
|
-
title: input.title || `Flashcards - ${new Date().toLocaleString()}`,
|
|
239
|
-
createdById: userId,
|
|
240
256
|
generating: true,
|
|
241
257
|
generatingMetadata: {
|
|
242
258
|
quantity: input.numCards,
|
|
243
259
|
difficulty: input.difficulty.toLowerCase(),
|
|
244
260
|
},
|
|
245
|
-
|
|
246
|
-
create: flashcardCurrent?.flashcards.map((card) => ({
|
|
247
|
-
front: card.front,
|
|
248
|
-
back: card.back,
|
|
249
|
-
})),
|
|
250
|
-
},
|
|
261
|
+
...(input.title ? { title: input.title } : {}),
|
|
251
262
|
},
|
|
252
263
|
});
|
|
253
264
|
const ragContext = await workspaceKbService.retrieveContext(input.workspaceId, input.prompt ?? 'flashcard key concepts facts definitions', 8);
|
|
@@ -262,10 +273,10 @@ export class FlashcardService extends BaseService {
|
|
|
262
273
|
const back = card.definition || card.back || card.answer || card.solution || `Answer ${i + 1}`;
|
|
263
274
|
await this.db.flashcard.create({
|
|
264
275
|
data: {
|
|
265
|
-
artifactId:
|
|
276
|
+
artifactId: primarySet.id,
|
|
266
277
|
front,
|
|
267
278
|
back,
|
|
268
|
-
order: i,
|
|
279
|
+
order: orderOffset + 1 + i,
|
|
269
280
|
tags: input.tags ?? ['ai-generated', input.difficulty],
|
|
270
281
|
},
|
|
271
282
|
});
|
|
@@ -281,10 +292,10 @@ export class FlashcardService extends BaseService {
|
|
|
281
292
|
const [front, back] = line.split(' - ');
|
|
282
293
|
await this.db.flashcard.create({
|
|
283
294
|
data: {
|
|
284
|
-
artifactId:
|
|
295
|
+
artifactId: primarySet.id,
|
|
285
296
|
front: front.trim(),
|
|
286
297
|
back: back.trim(),
|
|
287
|
-
order: i,
|
|
298
|
+
order: orderOffset + 1 + i,
|
|
288
299
|
tags: input.tags ?? ['ai-generated', input.difficulty],
|
|
289
300
|
},
|
|
290
301
|
});
|
|
@@ -292,11 +303,12 @@ export class FlashcardService extends BaseService {
|
|
|
292
303
|
}
|
|
293
304
|
}
|
|
294
305
|
}
|
|
295
|
-
await
|
|
296
|
-
|
|
297
|
-
where: { id: artifact.id },
|
|
306
|
+
const artifact = await this.db.artifact.update({
|
|
307
|
+
where: { id: primarySet.id },
|
|
298
308
|
data: { generating: false },
|
|
309
|
+
include: { flashcards: true },
|
|
299
310
|
});
|
|
311
|
+
await PusherService.emitFlashcardComplete(input.workspaceId, artifact);
|
|
300
312
|
await notifyArtifactReady(this.db, {
|
|
301
313
|
userId,
|
|
302
314
|
workspaceId: input.workspaceId,
|
|
@@ -304,15 +316,16 @@ export class FlashcardService extends BaseService {
|
|
|
304
316
|
artifactType: ArtifactType.FLASHCARD_SET,
|
|
305
317
|
title: artifact.title,
|
|
306
318
|
}).catch(() => { });
|
|
319
|
+
invalidateUserBillingCache(userId);
|
|
307
320
|
return { artifact, createdCards };
|
|
308
321
|
}
|
|
309
322
|
catch (error) {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
323
|
+
await this.db.artifact
|
|
324
|
+
.update({
|
|
325
|
+
where: { id: primarySet.id },
|
|
326
|
+
data: { generating: false },
|
|
327
|
+
})
|
|
328
|
+
.catch(() => { });
|
|
316
329
|
await PusherService.emitError(input.workspaceId, `Failed to generate flashcards: ${error}`, 'flash_card_generation');
|
|
317
330
|
await notifyArtifactFailed(this.db, {
|
|
318
331
|
userId,
|