@goscribe/server 1.1.7 → 1.3.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.
Files changed (56) hide show
  1. package/.env.example +43 -0
  2. package/check-difficulty.cjs +14 -0
  3. package/check-questions.cjs +14 -0
  4. package/db-summary.cjs +22 -0
  5. package/dist/routers/auth.js +1 -1
  6. package/mcq-test.cjs +36 -0
  7. package/package.json +10 -2
  8. package/prisma/migrations/20260413143206_init/migration.sql +873 -0
  9. package/prisma/schema.prisma +485 -292
  10. package/src/context.ts +4 -1
  11. package/src/lib/activity_human_description.test.ts +28 -0
  12. package/src/lib/activity_human_description.ts +239 -0
  13. package/src/lib/activity_log_service.test.ts +37 -0
  14. package/src/lib/activity_log_service.ts +353 -0
  15. package/src/lib/ai-session.ts +194 -112
  16. package/src/lib/constants.ts +14 -0
  17. package/src/lib/email.ts +230 -0
  18. package/src/lib/env.ts +23 -6
  19. package/src/lib/inference.ts +3 -3
  20. package/src/lib/logger.ts +26 -9
  21. package/src/lib/notification-service.test.ts +106 -0
  22. package/src/lib/notification-service.ts +677 -0
  23. package/src/lib/prisma.ts +6 -1
  24. package/src/lib/pusher.ts +90 -6
  25. package/src/lib/retry.ts +61 -0
  26. package/src/lib/storage.ts +2 -2
  27. package/src/lib/stripe.ts +39 -0
  28. package/src/lib/subscription_service.ts +722 -0
  29. package/src/lib/usage_service.ts +74 -0
  30. package/src/lib/worksheet-generation.test.ts +31 -0
  31. package/src/lib/worksheet-generation.ts +139 -0
  32. package/src/lib/workspace-access.ts +13 -0
  33. package/src/routers/_app.ts +11 -0
  34. package/src/routers/admin.ts +710 -0
  35. package/src/routers/annotations.ts +227 -0
  36. package/src/routers/auth.ts +432 -33
  37. package/src/routers/copilot.ts +719 -0
  38. package/src/routers/flashcards.ts +207 -80
  39. package/src/routers/members.ts +280 -80
  40. package/src/routers/notifications.ts +142 -0
  41. package/src/routers/payment.ts +448 -0
  42. package/src/routers/podcast.ts +133 -108
  43. package/src/routers/studyguide.ts +80 -74
  44. package/src/routers/worksheets.ts +300 -80
  45. package/src/routers/workspace.ts +538 -328
  46. package/src/scripts/purge-deleted-users.ts +167 -0
  47. package/src/server.ts +140 -12
  48. package/src/services/flashcard-progress.service.ts +52 -43
  49. package/src/trpc.ts +184 -5
  50. package/test-generate.js +30 -0
  51. package/test-ratio.cjs +9 -0
  52. package/zod-test.cjs +22 -0
  53. package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
  54. package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
  55. package/prisma/seed.mjs +0 -135
  56. package/src/routers/meetingsummary.ts +0 -416
@@ -0,0 +1,74 @@
1
+ import { prisma } from './prisma.js';
2
+
3
+ export interface UserUsage {
4
+ flashcards: number;
5
+ worksheets: number;
6
+ studyGuides: number;
7
+ podcasts: number;
8
+ storageBytes: number;
9
+ }
10
+
11
+ /**
12
+ * Counts all resources consumed by a user across the platform.
13
+ */
14
+ export async function getUserUsage(userId: string): Promise<UserUsage> {
15
+ const [flashcards, worksheets, studyGuides, podcasts, storageResult] = await Promise.all([
16
+ prisma.artifact.count({ where: { createdById: userId, type: 'FLASHCARD_SET' } }),
17
+ prisma.artifact.count({ where: { createdById: userId, type: 'WORKSHEET' } }),
18
+ prisma.artifact.count({ where: { createdById: userId, type: 'STUDY_GUIDE' } }),
19
+ prisma.artifact.count({ where: { createdById: userId, type: 'PODCAST_EPISODE' } }),
20
+ prisma.fileAsset.aggregate({
21
+ _sum: { size: true },
22
+ where: { userId }
23
+ })
24
+ ]);
25
+
26
+ return {
27
+ flashcards,
28
+ worksheets,
29
+ studyGuides,
30
+ podcasts,
31
+ storageBytes: Number(storageResult._sum.size || 0)
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Retrieves the specific plan limits for a user based on their active subscription
37
+ * PLUS any extra credits purchased.
38
+ */
39
+ export async function getUserPlanLimits(userId: string) {
40
+ const [activeSub, userCredits] = await Promise.all([
41
+ prisma.subscription.findFirst({
42
+ where: { userId, status: 'active' },
43
+ include: { plan: { include: { limit: true } } },
44
+ orderBy: { createdAt: 'desc' }
45
+ }),
46
+ prisma.userCredit.groupBy({
47
+ by: ['resourceType'],
48
+ where: { userId },
49
+ _sum: { amount: true }
50
+ })
51
+ ]);
52
+
53
+ const baseLimit = activeSub?.plan?.limit;
54
+
55
+ // If no active subscription and no credits, user has no active limits
56
+ if (!baseLimit && userCredits.length === 0) return null;
57
+
58
+ // Fast lookup for credits
59
+ const getCreditSum = (type: string) =>
60
+ userCredits.find(c => c.resourceType === type)?._sum.amount || 0;
61
+
62
+ // Return the merged total limit
63
+ return {
64
+ id: baseLimit?.id || 'credits-only',
65
+ planId: baseLimit?.planId || 'credits-only',
66
+ maxStorageBytes: BigInt(Number(baseLimit?.maxStorageBytes || 0) + (getCreditSum('STORAGE') * 1024 * 1024)),
67
+ maxWorksheets: (baseLimit?.maxWorksheets || 0) + getCreditSum('WORKSHEET'),
68
+ maxFlashcards: (baseLimit?.maxFlashcards || 0) + getCreditSum('FLASHCARD_SET'),
69
+ maxPodcasts: (baseLimit?.maxPodcasts || 0) + getCreditSum('PODCAST_EPISODE'),
70
+ maxStudyGuides: (baseLimit?.maxStudyGuides || 0) + getCreditSum('STUDY_GUIDE'),
71
+ createdAt: baseLimit?.createdAt || new Date(),
72
+ updatedAt: baseLimit?.updatedAt || new Date(),
73
+ };
74
+ }
@@ -0,0 +1,31 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mergeWorksheetGenerationConfig, normalizeWorksheetProblemForDb } from './worksheet-generation.js';
4
+
5
+ test('mergeWorksheetGenerationConfig: override beats preset and legacy', () => {
6
+ const r = mergeWorksheetGenerationConfig(
7
+ { mode: 'practice', numQuestions: 5, difficulty: 'easy' },
8
+ { mode: 'quiz', numQuestions: 10, difficulty: 'medium' },
9
+ { numQuestions: 3, difficulty: 'hard' },
10
+ );
11
+ assert.equal(r.mode, 'quiz');
12
+ assert.equal(r.numQuestions, 10);
13
+ assert.equal(r.difficulty, 'medium');
14
+ });
15
+
16
+ test('normalizeWorksheetProblemForDb coerces MCQ answer to option index', () => {
17
+ const row = normalizeWorksheetProblemForDb(
18
+ {
19
+ question: 'Capital of France?',
20
+ answer: 'Paris',
21
+ type: 'MULTIPLE_CHOICE',
22
+ options: ['London', 'Paris', 'Berlin'],
23
+ },
24
+ 0,
25
+ 'MEDIUM',
26
+ true,
27
+ );
28
+ assert.equal(row.type, 'MULTIPLE_CHOICE');
29
+ assert.equal(row.answer, '1');
30
+ assert.ok(row.meta.options && row.meta.options.length >= 2);
31
+ });
@@ -0,0 +1,139 @@
1
+ import { z } from 'zod';
2
+
3
+ /** Persisted on Artifact.worksheetConfig and inside generatingMetadata while generating */
4
+ export const worksheetModeSchema = z.enum(['practice', 'quiz']);
5
+ export type WorksheetMode = z.infer<typeof worksheetModeSchema>;
6
+
7
+ export const worksheetDifficultyInputSchema = z.enum(['easy', 'medium', 'hard']);
8
+ export type WorksheetDifficultyInput = z.infer<typeof worksheetDifficultyInputSchema>;
9
+
10
+ export const questionTypeSchema = z.enum([
11
+ 'MULTIPLE_CHOICE',
12
+ 'TEXT',
13
+ 'NUMERIC',
14
+ 'TRUE_FALSE',
15
+ 'MATCHING',
16
+ 'FILL_IN_THE_BLANK',
17
+ ]);
18
+ export type WorksheetQuestionTypeConfig = z.infer<typeof questionTypeSchema>;
19
+
20
+ /** Shape stored in WorksheetPreset.config and used for generation */
21
+ export const worksheetPresetConfigSchema = z.object({
22
+ mode: worksheetModeSchema.default('practice'),
23
+ numQuestions: z.number().int().min(1).max(20).default(8),
24
+ difficulty: worksheetDifficultyInputSchema.default('medium'),
25
+ /** When omitted, quiz mode implies MCQ-only on the inference side */
26
+ questionTypes: z.array(questionTypeSchema).optional(),
27
+ /** 0–1 fraction of problems that should be MCQ (practice mode); ignored when quiz forces all MCQ */
28
+ mcqRatio: z.number().min(0).max(1).optional(),
29
+ });
30
+
31
+ export type WorksheetPresetConfig = z.infer<typeof worksheetPresetConfigSchema>;
32
+
33
+ export const worksheetPresetConfigPartialSchema = worksheetPresetConfigSchema.partial();
34
+
35
+ /** Resolved config used by generateFromPrompt background job */
36
+ export type ResolvedWorksheetGeneration = WorksheetPresetConfig & {
37
+ prompt: string;
38
+ title?: string;
39
+ estimatedTime?: string;
40
+ presetId?: string | null;
41
+ };
42
+
43
+ const defaultConfig: WorksheetPresetConfig = worksheetPresetConfigSchema.parse({});
44
+
45
+ export function parsePresetConfig(raw: unknown): WorksheetPresetConfig {
46
+ return worksheetPresetConfigSchema.parse(raw);
47
+ }
48
+
49
+ export function mergeWorksheetGenerationConfig(
50
+ presetConfig: Partial<WorksheetPresetConfig> | null | undefined,
51
+ configOverride: Partial<WorksheetPresetConfig> | null | undefined,
52
+ legacy: { numQuestions?: number; difficulty?: WorksheetDifficultyInput; mode?: WorksheetMode },
53
+ ): WorksheetPresetConfig {
54
+ // Precedence: legacy flat fields > configOverride > preset > defaults
55
+ // We place legacy AFTER configOverride because configOverride's Zod partial schema injects defaults (like medium)
56
+ // that shouldn't overwrite explicit explicit user choices passing via legacy parameters.
57
+ const merged = {
58
+ ...defaultConfig,
59
+ ...presetConfig,
60
+ ...configOverride,
61
+ ...(legacy.numQuestions !== undefined ? { numQuestions: legacy.numQuestions } : {}),
62
+ ...(legacy.difficulty !== undefined ? { difficulty: legacy.difficulty } : {}),
63
+ ...(legacy.mode !== undefined ? { mode: legacy.mode } : {}),
64
+ };
65
+ return worksheetPresetConfigSchema.parse(merged);
66
+ }
67
+
68
+ /** Normalize one LLM problem row before DB insert */
69
+ export function normalizeWorksheetProblemForDb(
70
+ problem: Record<string, unknown>,
71
+ index0: number,
72
+ difficultyUpper: 'EASY' | 'MEDIUM' | 'HARD',
73
+ forceMcq: boolean,
74
+ ): {
75
+ prompt: string;
76
+ answer: string | null;
77
+ type: string;
78
+ difficulty: 'EASY' | 'MEDIUM' | 'HARD';
79
+ order: number;
80
+ meta: { options?: string[]; markScheme?: unknown };
81
+ } {
82
+ const prompt =
83
+ (typeof problem.question === 'string' && problem.question) ||
84
+ (typeof problem.prompt === 'string' && problem.prompt) ||
85
+ `Question ${index0 + 1}`;
86
+
87
+ let type = String(problem.type || 'TEXT').toUpperCase().replace(/[\s-]/g, '_');
88
+ if (forceMcq) type = 'MULTIPLE_CHOICE';
89
+
90
+ let options: string[] = [];
91
+ if (Array.isArray(problem.options)) {
92
+ options = problem.options.filter((o): o is string => typeof o === 'string' && o.trim().length > 0);
93
+ }
94
+
95
+ let answer =
96
+ (typeof problem.answer === 'string' && problem.answer) ||
97
+ (typeof problem.solution === 'string' && problem.solution) ||
98
+ '';
99
+
100
+ if (type === 'MULTIPLE_CHOICE') {
101
+ if (options.length < 2) {
102
+ options = ['Option A', 'Option B', 'Option C', 'Option D'];
103
+ }
104
+ const correctIndex = resolveMcqCorrectIndex(answer, options);
105
+ answer = String(correctIndex);
106
+ }
107
+
108
+ return {
109
+ prompt,
110
+ answer: answer || null,
111
+ type,
112
+ difficulty: difficultyUpper,
113
+ order: index0,
114
+ meta: {
115
+ ...(options.length > 0 ? { options } : {}),
116
+ ...(problem.mark_scheme !== undefined ? { markScheme: problem.mark_scheme } : {}),
117
+ // Non-MCQ questions are AI-marked via markScheme; provide a safe fallback
118
+ ...(type === 'TEXT' && problem.mark_scheme === undefined
119
+ ? {
120
+ markScheme: {
121
+ points: [{ point: 1, requirements: 'Typed answer matches expected content.' }],
122
+ totalPoints: 1,
123
+ },
124
+ }
125
+ : {}),
126
+ },
127
+ };
128
+ }
129
+
130
+ function resolveMcqCorrectIndex(answer: string, options: string[]): number {
131
+ const trimmed = answer.trim();
132
+ const asNum = parseInt(trimmed, 10);
133
+ if (!Number.isNaN(asNum) && asNum >= 0 && asNum < options.length) {
134
+ return asNum;
135
+ }
136
+ const byText = options.findIndex((o) => o.trim() === trimmed);
137
+ if (byText >= 0) return byText;
138
+ return 0;
139
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Prisma `where` filter for workspace access checks.
3
+ * Allows access if the user is the owner OR a member of the workspace.
4
+ * Use this instead of `{ ownerId: userId }` to support collaborative workspaces.
5
+ */
6
+ export function workspaceAccessFilter(userId: string) {
7
+ return {
8
+ OR: [
9
+ { ownerId: userId },
10
+ { members: { some: { userId } } },
11
+ ],
12
+ };
13
+ }
@@ -8,6 +8,12 @@ import { studyguide } from './studyguide.js';
8
8
  import { podcast } from './podcast.js';
9
9
  import { chat } from './chat.js';
10
10
  import { members } from './members.js';
11
+ import { annotations } from './annotations.js';
12
+ import { admin } from './admin.js';
13
+
14
+ import { paymentRouter } from './payment.js';
15
+ import { copilot } from './copilot.js';
16
+ import { notifications } from './notifications.js';
11
17
 
12
18
  export const appRouter = router({
13
19
  auth,
@@ -17,6 +23,11 @@ export const appRouter = router({
17
23
  studyguide,
18
24
  podcast,
19
25
  chat,
26
+ annotations,
27
+ admin,
28
+ payment: paymentRouter,
29
+ copilot,
30
+ notifications,
20
31
  // Public member endpoints (for invitation acceptance)
21
32
  member: router({
22
33
  acceptInvite: members.acceptInvite,