@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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { PrismaClient } from '@prisma/client';
|
|
2
|
+
import { supabaseClient } from '../lib/storage.js';
|
|
3
|
+
import { logger } from '../lib/logger.js';
|
|
4
|
+
import { notifyAdminsAccountPermanentlyDeleted } from '../lib/notification-service.js';
|
|
5
|
+
import { ActivityLogCategory, ActivityLogStatus, } from '@prisma/client';
|
|
6
|
+
import { recordExplicitActivity } from '../lib/activity_log_service.js';
|
|
7
|
+
const db = new PrismaClient();
|
|
8
|
+
async function purgeDeletedUsers() {
|
|
9
|
+
try {
|
|
10
|
+
const startedAt = Date.now();
|
|
11
|
+
let status = ActivityLogStatus.SUCCESS;
|
|
12
|
+
let errorCode = null;
|
|
13
|
+
let purgedUsers = 0;
|
|
14
|
+
logger.info('Starting scheduled purge for deleted users...');
|
|
15
|
+
// Find users whose deletedAt is older than 30 days
|
|
16
|
+
const thirtyDaysAgo = new Date();
|
|
17
|
+
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
18
|
+
const usersToPurge = await db.user.findMany({
|
|
19
|
+
where: {
|
|
20
|
+
deletedAt: {
|
|
21
|
+
lte: thirtyDaysAgo,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
select: {
|
|
25
|
+
id: true,
|
|
26
|
+
name: true,
|
|
27
|
+
email: true,
|
|
28
|
+
profilePicture: {
|
|
29
|
+
select: {
|
|
30
|
+
objectKey: true,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
if (usersToPurge.length === 0) {
|
|
36
|
+
logger.info('No users found past the 30-day grace period.');
|
|
37
|
+
await recordExplicitActivity({
|
|
38
|
+
db,
|
|
39
|
+
actorUserId: null,
|
|
40
|
+
action: 'cron.purgeDeletedUsers',
|
|
41
|
+
category: ActivityLogCategory.SYSTEM,
|
|
42
|
+
status: ActivityLogStatus.SUCCESS,
|
|
43
|
+
errorCode: null,
|
|
44
|
+
durationMs: Date.now() - startedAt,
|
|
45
|
+
forceWrite: true,
|
|
46
|
+
metadata: {
|
|
47
|
+
usersFound: 0,
|
|
48
|
+
usersPurged: 0,
|
|
49
|
+
graceDays: 30,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
logger.info(`Found ${usersToPurge.length} user(s) to purge. Processing...`);
|
|
55
|
+
for (const user of usersToPurge) {
|
|
56
|
+
logger.info(`Purging user: ${user.id} (${user.email})`);
|
|
57
|
+
try {
|
|
58
|
+
// 1. Delete associated files from Supabase Storage
|
|
59
|
+
// (Prisma cascade will delete the FileAsset DB record, but we need to delete the actual file first)
|
|
60
|
+
// Let's get all file assets owned by the user
|
|
61
|
+
const userFiles = await db.fileAsset.findMany({
|
|
62
|
+
where: { userId: user.id },
|
|
63
|
+
select: { objectKey: true, bucket: true },
|
|
64
|
+
});
|
|
65
|
+
for (const file of userFiles) {
|
|
66
|
+
if (file.objectKey && file.bucket) {
|
|
67
|
+
try {
|
|
68
|
+
const { error } = await supabaseClient.storage
|
|
69
|
+
.from(file.bucket)
|
|
70
|
+
.remove([file.objectKey]);
|
|
71
|
+
if (error) {
|
|
72
|
+
logger.error(`Failed to delete file from Supabase: ${file.objectKey}`, error);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
logger.info(`Deleted file from Supabase: ${file.objectKey}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (storageError) {
|
|
79
|
+
logger.error(`Exception during Supabase deletion for ${file.objectKey}:`, storageError);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
await notifyAdminsAccountPermanentlyDeleted(db, {
|
|
84
|
+
id: user.id,
|
|
85
|
+
name: user.name,
|
|
86
|
+
email: user.email,
|
|
87
|
+
}).catch(() => { });
|
|
88
|
+
// 2. Finally, delete the User from the database
|
|
89
|
+
// (Prisma onDelete: Cascade will handle almost everything else depending on schema)
|
|
90
|
+
await db.user.delete({
|
|
91
|
+
where: { id: user.id },
|
|
92
|
+
});
|
|
93
|
+
purgedUsers += 1;
|
|
94
|
+
logger.info(`Successfully purged user: ${user.id}`);
|
|
95
|
+
}
|
|
96
|
+
catch (userError) {
|
|
97
|
+
status = ActivityLogStatus.FAILURE;
|
|
98
|
+
errorCode =
|
|
99
|
+
userError instanceof Error ? userError.message : String(userError);
|
|
100
|
+
logger.error(`Failed to purge user ${user.id}:`, userError);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (status === ActivityLogStatus.SUCCESS) {
|
|
104
|
+
logger.info('Purge process completed successfully.');
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
logger.warn('Purge process completed with failures.');
|
|
108
|
+
}
|
|
109
|
+
await recordExplicitActivity({
|
|
110
|
+
db,
|
|
111
|
+
actorUserId: null,
|
|
112
|
+
action: 'cron.purgeDeletedUsers',
|
|
113
|
+
category: ActivityLogCategory.SYSTEM,
|
|
114
|
+
status,
|
|
115
|
+
errorCode,
|
|
116
|
+
durationMs: Date.now() - startedAt,
|
|
117
|
+
forceWrite: true,
|
|
118
|
+
metadata: {
|
|
119
|
+
usersFound: usersToPurge.length,
|
|
120
|
+
usersPurged: purgedUsers,
|
|
121
|
+
graceDays: 30,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
const startedAt = Date.now();
|
|
127
|
+
await recordExplicitActivity({
|
|
128
|
+
db,
|
|
129
|
+
actorUserId: null,
|
|
130
|
+
action: 'cron.purgeDeletedUsers',
|
|
131
|
+
category: ActivityLogCategory.SYSTEM,
|
|
132
|
+
status: ActivityLogStatus.FAILURE,
|
|
133
|
+
errorCode: error instanceof Error ? error.message : String(error),
|
|
134
|
+
durationMs: Date.now() - startedAt,
|
|
135
|
+
forceWrite: true,
|
|
136
|
+
}).catch(() => { });
|
|
137
|
+
logger.error('Critical error during user purge process:', error);
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
await db.$disconnect();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Run the script
|
|
144
|
+
purgeDeletedUsers().then(() => {
|
|
145
|
+
process.exit(0);
|
|
146
|
+
}).catch((error) => {
|
|
147
|
+
console.error("Unhandled error:", error);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
});
|
package/dist/server.js
CHANGED
|
@@ -7,20 +7,24 @@ import compression from 'compression';
|
|
|
7
7
|
import * as trpcExpress from '@trpc/server/adapters/express';
|
|
8
8
|
import { appRouter } from './routers/_app.js';
|
|
9
9
|
import { createContext } from './context.js';
|
|
10
|
+
import { prisma } from './lib/prisma.js';
|
|
10
11
|
import { logger } from './lib/logger.js';
|
|
11
12
|
import { supabaseClient } from './lib/storage.js';
|
|
13
|
+
import { ActivityLogCategory, ActivityLogStatus, } from '@prisma/client';
|
|
14
|
+
import { recordExplicitActivity } from './lib/activity_log_service.js';
|
|
12
15
|
const PORT = process.env.PORT ? Number(process.env.PORT) : 3001;
|
|
13
16
|
async function main() {
|
|
14
17
|
const app = express();
|
|
15
18
|
// Middlewares
|
|
16
|
-
app.use(helmet());
|
|
17
19
|
app.use(cors({
|
|
18
|
-
origin:
|
|
20
|
+
origin: ['https://www.scribe.study', 'https://scribe.study', 'http://localhost:3000', 'http://localhost:3002'],
|
|
19
21
|
credentials: true, // allow cookies
|
|
20
22
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
21
23
|
allowedHeaders: ['Content-Type', 'Authorization', 'Cookie', 'Set-Cookie'],
|
|
22
24
|
exposedHeaders: ['Set-Cookie'],
|
|
25
|
+
preflightContinue: false, // Important: stop further handling of OPTIONS
|
|
23
26
|
}));
|
|
27
|
+
app.use(helmet());
|
|
24
28
|
// Custom morgan middleware with logger integration
|
|
25
29
|
app.use(morgan('combined', {
|
|
26
30
|
stream: {
|
|
@@ -30,6 +34,112 @@ async function main() {
|
|
|
30
34
|
}
|
|
31
35
|
}));
|
|
32
36
|
app.use(compression());
|
|
37
|
+
// Stripe Webhook (Must be before express.json() for raw body)
|
|
38
|
+
app.post('/stripe/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
|
|
39
|
+
const sig = req.headers['stripe-signature'];
|
|
40
|
+
const { stripe } = await import('./lib/stripe.js');
|
|
41
|
+
const { env } = await import('./lib/env.js');
|
|
42
|
+
const subscriptionService = await import('./lib/subscription_service.js');
|
|
43
|
+
let event;
|
|
44
|
+
try {
|
|
45
|
+
if (!sig || !env.STRIPE_WEBHOOK_SECRET) {
|
|
46
|
+
throw new Error('Missing stripe-signature or STRIPE_WEBHOOK_SECRET');
|
|
47
|
+
}
|
|
48
|
+
event = stripe?.webhooks.constructEvent(req.body, sig, env.STRIPE_WEBHOOK_SECRET);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
logger.error(`Webhook signature verification failed: ${err.message}`, 'STRIPE');
|
|
52
|
+
await recordExplicitActivity({
|
|
53
|
+
db: prisma,
|
|
54
|
+
actorUserId: null,
|
|
55
|
+
action: 'stripe.webhook.signatureVerificationFailed',
|
|
56
|
+
category: ActivityLogCategory.SYSTEM,
|
|
57
|
+
status: ActivityLogStatus.FAILURE,
|
|
58
|
+
errorCode: err?.message ?? 'unknown',
|
|
59
|
+
durationMs: 0,
|
|
60
|
+
forceWrite: true,
|
|
61
|
+
}).catch(() => { });
|
|
62
|
+
return res.status(400).send(`Webhook Error: ${err.message}`);
|
|
63
|
+
}
|
|
64
|
+
// Handle the event
|
|
65
|
+
try {
|
|
66
|
+
const startedAt = Date.now();
|
|
67
|
+
let status = ActivityLogStatus.SUCCESS;
|
|
68
|
+
let errorCode = null;
|
|
69
|
+
// Best-effort: some webhook objects include metadata.userId
|
|
70
|
+
let actorUserId = undefined;
|
|
71
|
+
try {
|
|
72
|
+
const obj = event?.data?.object;
|
|
73
|
+
const md = obj?.metadata;
|
|
74
|
+
if (md && typeof md.userId === 'string')
|
|
75
|
+
actorUserId = md.userId;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
actorUserId = undefined;
|
|
79
|
+
}
|
|
80
|
+
switch (event?.type) {
|
|
81
|
+
case 'checkout.session.completed':
|
|
82
|
+
await subscriptionService.handleCheckoutCompleted(event);
|
|
83
|
+
break;
|
|
84
|
+
case 'customer.subscription.created':
|
|
85
|
+
await subscriptionService.handleSubscriptionCreated(event);
|
|
86
|
+
break;
|
|
87
|
+
case 'customer.subscription.updated':
|
|
88
|
+
await subscriptionService.handleSubscriptionUpdated(event);
|
|
89
|
+
break;
|
|
90
|
+
case 'customer.subscription.deleted':
|
|
91
|
+
await subscriptionService.handleSubscriptionDeleted(event);
|
|
92
|
+
break;
|
|
93
|
+
case 'invoice.paid':
|
|
94
|
+
case 'invoice.payment_succeeded':
|
|
95
|
+
await subscriptionService.handleInvoicePaid(event);
|
|
96
|
+
break;
|
|
97
|
+
case 'invoice.payment_failed':
|
|
98
|
+
await subscriptionService.handlePaymentFailed(event);
|
|
99
|
+
break;
|
|
100
|
+
case 'payment_intent.payment_failed':
|
|
101
|
+
await subscriptionService.handlePaymentIntentFailed(event);
|
|
102
|
+
break;
|
|
103
|
+
default:
|
|
104
|
+
logger.debug(`Unhandled stripe event type: ${event?.type}`, 'STRIPE');
|
|
105
|
+
}
|
|
106
|
+
await recordExplicitActivity({
|
|
107
|
+
db: prisma,
|
|
108
|
+
actorUserId: actorUserId ?? undefined,
|
|
109
|
+
action: `stripe.webhook.${event?.type}`,
|
|
110
|
+
category: ActivityLogCategory.SYSTEM,
|
|
111
|
+
status,
|
|
112
|
+
errorCode,
|
|
113
|
+
durationMs: Date.now() - startedAt,
|
|
114
|
+
forceWrite: true,
|
|
115
|
+
metadata: {
|
|
116
|
+
stripeEventId: event?.id,
|
|
117
|
+
stripeEventType: event?.type,
|
|
118
|
+
},
|
|
119
|
+
}).catch(() => { });
|
|
120
|
+
res.json({ received: true });
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
const startedAt = Date.now();
|
|
124
|
+
const message = err?.message ?? 'unknown';
|
|
125
|
+
await recordExplicitActivity({
|
|
126
|
+
db: prisma,
|
|
127
|
+
actorUserId: null,
|
|
128
|
+
action: `stripe.webhook.${event?.type}`,
|
|
129
|
+
category: ActivityLogCategory.SYSTEM,
|
|
130
|
+
status: ActivityLogStatus.FAILURE,
|
|
131
|
+
errorCode: message,
|
|
132
|
+
durationMs: Date.now() - startedAt,
|
|
133
|
+
forceWrite: true,
|
|
134
|
+
metadata: {
|
|
135
|
+
stripeEventId: event?.id,
|
|
136
|
+
stripeEventType: event?.type,
|
|
137
|
+
},
|
|
138
|
+
}).catch(() => { });
|
|
139
|
+
logger.error(`Error processing webhook event ${event?.type}: ${err.message}`, 'STRIPE');
|
|
140
|
+
res.status(500).send('Internal Server Error');
|
|
141
|
+
}
|
|
142
|
+
});
|
|
33
143
|
app.use(express.json({ limit: '50mb' }));
|
|
34
144
|
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
|
35
145
|
// Health (plain Express)
|
|
@@ -37,15 +147,25 @@ async function main() {
|
|
|
37
147
|
res.json({ ok: true, service: 'trpc-express', ts: Date.now() });
|
|
38
148
|
});
|
|
39
149
|
app.get('/profile-picture/:objectKey', async (req, res) => {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
150
|
+
try {
|
|
151
|
+
const { objectKey } = req.params;
|
|
152
|
+
const { data, error } = await supabaseClient.storage
|
|
153
|
+
.from('media')
|
|
154
|
+
.download(objectKey);
|
|
155
|
+
if (error || !data) {
|
|
156
|
+
logger.error(`Failed to download profile picture: ${error?.message}`, 'STORAGE');
|
|
157
|
+
return res.status(404).send('Not found');
|
|
158
|
+
}
|
|
159
|
+
const buffer = Buffer.from(await data.arrayBuffer());
|
|
160
|
+
res.setHeader('Content-Type', 'image/jpeg');
|
|
161
|
+
res.setHeader('Cache-Control', 'public, max-age=31536000');
|
|
162
|
+
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
|
163
|
+
res.send(buffer);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
logger.error('Error serving profile picture', 'STORAGE', undefined, err);
|
|
167
|
+
res.status(500).send('Internal Server Error');
|
|
46
168
|
}
|
|
47
|
-
// res.json({ url: signedUrl.data.signedUrl });
|
|
48
|
-
res.redirect(signedUrl.data.signedUrl);
|
|
49
169
|
});
|
|
50
170
|
// tRPC mounted under /trpc
|
|
51
171
|
app.use('/trpc', trpcExpress.createExpressMiddleware({
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { Prisma, type PrismaClient } from '@prisma/client';
|
|
2
2
|
/**
|
|
3
3
|
* SM-2 Spaced Repetition Algorithm
|
|
4
4
|
* https://www.supermemo.com/en/archives1990-2015/english/ol/sm2
|
|
@@ -39,33 +39,7 @@ export declare class FlashcardProgressService {
|
|
|
39
39
|
isCorrect: boolean;
|
|
40
40
|
confidence?: 'easy' | 'medium' | 'hard';
|
|
41
41
|
timeSpentMs?: number;
|
|
42
|
-
}): Promise<
|
|
43
|
-
flashcard: {
|
|
44
|
-
id: string;
|
|
45
|
-
createdAt: Date;
|
|
46
|
-
artifactId: string;
|
|
47
|
-
order: number;
|
|
48
|
-
front: string;
|
|
49
|
-
back: string;
|
|
50
|
-
tags: string[];
|
|
51
|
-
};
|
|
52
|
-
} & {
|
|
53
|
-
id: string;
|
|
54
|
-
createdAt: Date;
|
|
55
|
-
updatedAt: Date;
|
|
56
|
-
userId: string;
|
|
57
|
-
flashcardId: string;
|
|
58
|
-
timesStudied: number;
|
|
59
|
-
timesCorrect: number;
|
|
60
|
-
timesIncorrect: number;
|
|
61
|
-
timesIncorrectConsecutive: number;
|
|
62
|
-
easeFactor: number;
|
|
63
|
-
interval: number;
|
|
64
|
-
repetitions: number;
|
|
65
|
-
masteryLevel: number;
|
|
66
|
-
lastStudiedAt: Date | null;
|
|
67
|
-
nextReviewAt: Date | null;
|
|
68
|
-
}>;
|
|
42
|
+
}, retryCount?: number): Promise<any>;
|
|
69
43
|
/**
|
|
70
44
|
* Get user's progress on all flashcards in a set
|
|
71
45
|
*/
|
|
@@ -99,17 +73,18 @@ export declare class FlashcardProgressService {
|
|
|
99
73
|
id: string;
|
|
100
74
|
createdAt: Date;
|
|
101
75
|
updatedAt: Date;
|
|
102
|
-
title: string;
|
|
103
|
-
description: string | null;
|
|
104
76
|
workspaceId: string;
|
|
105
77
|
type: import("@prisma/client").$Enums.ArtifactType;
|
|
78
|
+
title: string;
|
|
106
79
|
isArchived: boolean;
|
|
107
|
-
generating: boolean;
|
|
108
|
-
generatingMetadata: import("@prisma/client/runtime/library").JsonValue | null;
|
|
109
80
|
difficulty: import("@prisma/client").$Enums.Difficulty | null;
|
|
110
81
|
estimatedTime: string | null;
|
|
111
|
-
imageObjectKey: string | null;
|
|
112
82
|
createdById: string | null;
|
|
83
|
+
description: string | null;
|
|
84
|
+
generating: boolean;
|
|
85
|
+
generatingMetadata: Prisma.JsonValue | null;
|
|
86
|
+
worksheetConfig: Prisma.JsonValue | null;
|
|
87
|
+
imageObjectKey: string | null;
|
|
113
88
|
};
|
|
114
89
|
} & {
|
|
115
90
|
id: string;
|
|
@@ -119,22 +94,24 @@ export declare class FlashcardProgressService {
|
|
|
119
94
|
front: string;
|
|
120
95
|
back: string;
|
|
121
96
|
tags: string[];
|
|
97
|
+
acceptedAnswers: string[];
|
|
122
98
|
}) | {
|
|
123
99
|
artifact: {
|
|
124
100
|
id: string;
|
|
125
101
|
createdAt: Date;
|
|
126
102
|
updatedAt: Date;
|
|
127
|
-
title: string;
|
|
128
|
-
description: string | null;
|
|
129
103
|
workspaceId: string;
|
|
130
104
|
type: import("@prisma/client").$Enums.ArtifactType;
|
|
105
|
+
title: string;
|
|
131
106
|
isArchived: boolean;
|
|
132
|
-
generating: boolean;
|
|
133
|
-
generatingMetadata: import("@prisma/client/runtime/library").JsonValue | null;
|
|
134
107
|
difficulty: import("@prisma/client").$Enums.Difficulty | null;
|
|
135
108
|
estimatedTime: string | null;
|
|
136
|
-
imageObjectKey: string | null;
|
|
137
109
|
createdById: string | null;
|
|
110
|
+
description: string | null;
|
|
111
|
+
generating: boolean;
|
|
112
|
+
generatingMetadata: Prisma.JsonValue | null;
|
|
113
|
+
worksheetConfig: Prisma.JsonValue | null;
|
|
114
|
+
imageObjectKey: string | null;
|
|
138
115
|
};
|
|
139
116
|
id: string;
|
|
140
117
|
createdAt: Date;
|
|
@@ -143,6 +120,7 @@ export declare class FlashcardProgressService {
|
|
|
143
120
|
front: string;
|
|
144
121
|
back: string;
|
|
145
122
|
tags: string[];
|
|
123
|
+
acceptedAnswers: string[];
|
|
146
124
|
})[]>;
|
|
147
125
|
/**
|
|
148
126
|
* Get user statistics for a flashcard set
|
|
@@ -161,7 +139,7 @@ export declare class FlashcardProgressService {
|
|
|
161
139
|
/**
|
|
162
140
|
* Reset progress for a flashcard
|
|
163
141
|
*/
|
|
164
|
-
resetProgress(userId: string, flashcardId: string): Promise<
|
|
142
|
+
resetProgress(userId: string, flashcardId: string): Promise<Prisma.BatchPayload>;
|
|
165
143
|
/**
|
|
166
144
|
* Bulk record study session
|
|
167
145
|
*/
|
|
@@ -173,33 +151,7 @@ export declare class FlashcardProgressService {
|
|
|
173
151
|
confidence?: 'easy' | 'medium' | 'hard';
|
|
174
152
|
timeSpentMs?: number;
|
|
175
153
|
}>;
|
|
176
|
-
}): Promise<
|
|
177
|
-
flashcard: {
|
|
178
|
-
id: string;
|
|
179
|
-
createdAt: Date;
|
|
180
|
-
artifactId: string;
|
|
181
|
-
order: number;
|
|
182
|
-
front: string;
|
|
183
|
-
back: string;
|
|
184
|
-
tags: string[];
|
|
185
|
-
};
|
|
186
|
-
} & {
|
|
187
|
-
id: string;
|
|
188
|
-
createdAt: Date;
|
|
189
|
-
updatedAt: Date;
|
|
190
|
-
userId: string;
|
|
191
|
-
flashcardId: string;
|
|
192
|
-
timesStudied: number;
|
|
193
|
-
timesCorrect: number;
|
|
194
|
-
timesIncorrect: number;
|
|
195
|
-
timesIncorrectConsecutive: number;
|
|
196
|
-
easeFactor: number;
|
|
197
|
-
interval: number;
|
|
198
|
-
repetitions: number;
|
|
199
|
-
masteryLevel: number;
|
|
200
|
-
lastStudiedAt: Date | null;
|
|
201
|
-
nextReviewAt: Date | null;
|
|
202
|
-
})[]>;
|
|
154
|
+
}): Promise<any[]>;
|
|
203
155
|
}
|
|
204
156
|
/**
|
|
205
157
|
* Factory function
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Prisma } from '@prisma/client';
|
|
1
2
|
import { NotFoundError } from '../lib/errors.js';
|
|
2
3
|
export class FlashcardProgressService {
|
|
3
4
|
constructor(db) {
|
|
@@ -105,7 +106,7 @@ export class FlashcardProgressService {
|
|
|
105
106
|
/**
|
|
106
107
|
* Record flashcard study attempt
|
|
107
108
|
*/
|
|
108
|
-
async recordStudyAttempt(data) {
|
|
109
|
+
async recordStudyAttempt(data, retryCount = 0) {
|
|
109
110
|
const { userId, flashcardId, isCorrect, timeSpentMs } = data;
|
|
110
111
|
// Verify flashcard exists and user has access
|
|
111
112
|
const flashcard = await this.db.flashcard.findFirst({
|
|
@@ -155,44 +156,55 @@ export class FlashcardProgressService {
|
|
|
155
156
|
(Math.min(sm2Result.repetitions, 10) / 10) * 30 - // 30% weight on repetitions
|
|
156
157
|
consecutivePenalty // Penalty for consecutive failures
|
|
157
158
|
)));
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
159
|
+
try {
|
|
160
|
+
// Upsert progress
|
|
161
|
+
return await this.db.flashcardProgress.upsert({
|
|
162
|
+
where: {
|
|
163
|
+
userId_flashcardId: {
|
|
164
|
+
userId,
|
|
165
|
+
flashcardId,
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
update: {
|
|
169
|
+
timesStudied: { increment: 1 },
|
|
170
|
+
timesCorrect: isCorrect ? { increment: 1 } : undefined,
|
|
171
|
+
timesIncorrect: !isCorrect ? { increment: 1 } : undefined,
|
|
172
|
+
timesIncorrectConsecutive: newConsecutiveIncorrect,
|
|
173
|
+
easeFactor: sm2Result.easeFactor,
|
|
174
|
+
interval: sm2Result.interval,
|
|
175
|
+
repetitions: sm2Result.repetitions,
|
|
176
|
+
masteryLevel,
|
|
177
|
+
lastStudiedAt: new Date(),
|
|
178
|
+
nextReviewAt: sm2Result.nextReviewAt,
|
|
179
|
+
},
|
|
180
|
+
create: {
|
|
162
181
|
userId,
|
|
163
182
|
flashcardId,
|
|
183
|
+
timesStudied: 1,
|
|
184
|
+
timesCorrect: isCorrect ? 1 : 0,
|
|
185
|
+
timesIncorrect: isCorrect ? 0 : 1,
|
|
186
|
+
timesIncorrectConsecutive: newConsecutiveIncorrect,
|
|
187
|
+
easeFactor: sm2Result.easeFactor,
|
|
188
|
+
interval: sm2Result.interval,
|
|
189
|
+
repetitions: sm2Result.repetitions,
|
|
190
|
+
masteryLevel,
|
|
191
|
+
lastStudiedAt: new Date(),
|
|
192
|
+
nextReviewAt: sm2Result.nextReviewAt,
|
|
164
193
|
},
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
userId,
|
|
180
|
-
flashcardId,
|
|
181
|
-
timesStudied: 1,
|
|
182
|
-
timesCorrect: isCorrect ? 1 : 0,
|
|
183
|
-
timesIncorrect: isCorrect ? 0 : 1,
|
|
184
|
-
timesIncorrectConsecutive: newConsecutiveIncorrect,
|
|
185
|
-
easeFactor: sm2Result.easeFactor,
|
|
186
|
-
interval: sm2Result.interval,
|
|
187
|
-
repetitions: sm2Result.repetitions,
|
|
188
|
-
masteryLevel,
|
|
189
|
-
lastStudiedAt: new Date(),
|
|
190
|
-
nextReviewAt: sm2Result.nextReviewAt,
|
|
191
|
-
},
|
|
192
|
-
include: {
|
|
193
|
-
flashcard: true,
|
|
194
|
-
},
|
|
195
|
-
});
|
|
194
|
+
include: {
|
|
195
|
+
flashcard: true,
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
// Handle rare race condition where parallel submissions try creating same row.
|
|
201
|
+
if (error instanceof Prisma.PrismaClientKnownRequestError &&
|
|
202
|
+
error.code === 'P2002' &&
|
|
203
|
+
retryCount < 1) {
|
|
204
|
+
return this.recordStudyAttempt(data, retryCount + 1);
|
|
205
|
+
}
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
196
208
|
}
|
|
197
209
|
/**
|
|
198
210
|
* Get user's progress on all flashcards in a set
|
|
@@ -253,7 +265,7 @@ export class FlashcardProgressService {
|
|
|
253
265
|
},
|
|
254
266
|
},
|
|
255
267
|
});
|
|
256
|
-
|
|
268
|
+
// allFlashcards count logged for debugging if needed
|
|
257
269
|
const TAKE_NUMBER = (allFlashcards.length > 10) ? 10 : allFlashcards.length;
|
|
258
270
|
// Get progress records for flashcards that are due or have low mastery
|
|
259
271
|
const progressRecords = await this.db.flashcardProgress.findMany({
|
|
@@ -289,9 +301,7 @@ export class FlashcardProgressService {
|
|
|
289
301
|
},
|
|
290
302
|
take: TAKE_NUMBER,
|
|
291
303
|
});
|
|
292
|
-
|
|
293
|
-
console.log('TAKE_NUMBER - progressRecords.length', TAKE_NUMBER - progressRecords.length);
|
|
294
|
-
console.log('progressRecords', progressRecords.map((progress) => progress.flashcard.id));
|
|
304
|
+
// Due card selection: TAKE_NUMBER=${TAKE_NUMBER}, existing=${progressRecords.length}
|
|
295
305
|
// Get flashcard IDs that already have progress records
|
|
296
306
|
const flashcardIdsWithProgress = new Set(progressRecords.map((progress) => progress.flashcard.id));
|
|
297
307
|
// Find flashcards without progress records (non-studied) to pad the results
|
|
@@ -318,8 +328,7 @@ export class FlashcardProgressService {
|
|
|
318
328
|
flashcard: flashcardWithoutProgress,
|
|
319
329
|
};
|
|
320
330
|
});
|
|
321
|
-
|
|
322
|
-
console.log('progressRecords', progressRecords.length);
|
|
331
|
+
// Combined: ${progressRecords.length} due + ${progressRecordsPadding.length} padding cards
|
|
323
332
|
const selectedCards = [...progressRecords, ...progressRecordsPadding];
|
|
324
333
|
// Build result array: include progress records and non-studied flashcards
|
|
325
334
|
const results = [];
|
package/dist/trpc.d.ts
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
|
+
import type { Context } from "./context.js";
|
|
1
2
|
export declare const router: import("@trpc/server").TRPCRouterBuilder<{
|
|
2
|
-
ctx:
|
|
3
|
-
db: import("@prisma/client").PrismaClient<import("@prisma/client").Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;
|
|
4
|
-
session: any;
|
|
5
|
-
req: import("express").Request<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>>;
|
|
6
|
-
res: import("express").Response<any, Record<string, any>>;
|
|
7
|
-
cookies: Record<string, string | undefined>;
|
|
8
|
-
};
|
|
3
|
+
ctx: Context;
|
|
9
4
|
meta: object;
|
|
10
5
|
errorShape: {
|
|
11
6
|
data: {
|
|
@@ -20,34 +15,38 @@ export declare const router: import("@trpc/server").TRPCRouterBuilder<{
|
|
|
20
15
|
};
|
|
21
16
|
transformer: true;
|
|
22
17
|
}>;
|
|
23
|
-
export declare const middleware: <$ContextOverrides>(fn: import("@trpc/server").TRPCMiddlewareFunction<
|
|
24
|
-
|
|
18
|
+
export declare const middleware: <$ContextOverrides>(fn: import("@trpc/server").TRPCMiddlewareFunction<Context, object, object, $ContextOverrides, unknown>) => import("@trpc/server").TRPCMiddlewareBuilder<Context, object, $ContextOverrides, unknown>;
|
|
19
|
+
export declare const publicProcedure: import("@trpc/server").TRPCProcedureBuilder<Context, object, object, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, false>;
|
|
20
|
+
/** Exported procedures with middleware */
|
|
21
|
+
export declare const authedProcedure: import("@trpc/server").TRPCProcedureBuilder<Context, object, {
|
|
22
|
+
userId: any;
|
|
25
23
|
session: any;
|
|
26
24
|
req: import("express").Request<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>>;
|
|
27
25
|
res: import("express").Response<any, Record<string, any>>;
|
|
28
|
-
cookies: Record<string, string | undefined>;
|
|
29
|
-
}, object, object, $ContextOverrides, unknown>) => import("@trpc/server").TRPCMiddlewareBuilder<{
|
|
30
26
|
db: import("@prisma/client").PrismaClient<import("@prisma/client").Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;
|
|
27
|
+
cookies: Record<string, string | undefined>;
|
|
28
|
+
}, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, false>;
|
|
29
|
+
export declare const verifiedProcedure: import("@trpc/server").TRPCProcedureBuilder<Context, object, {
|
|
30
|
+
userId: any;
|
|
31
31
|
session: any;
|
|
32
32
|
req: import("express").Request<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>>;
|
|
33
33
|
res: import("express").Response<any, Record<string, any>>;
|
|
34
|
-
cookies: Record<string, string | undefined>;
|
|
35
|
-
}, object, $ContextOverrides, unknown>;
|
|
36
|
-
export declare const publicProcedure: import("@trpc/server").TRPCProcedureBuilder<{
|
|
37
34
|
db: import("@prisma/client").PrismaClient<import("@prisma/client").Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;
|
|
35
|
+
cookies: Record<string, string | undefined>;
|
|
36
|
+
}, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, false>;
|
|
37
|
+
export declare const adminProcedure: import("@trpc/server").TRPCProcedureBuilder<Context, object, {
|
|
38
|
+
userId: any;
|
|
38
39
|
session: any;
|
|
39
40
|
req: import("express").Request<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>>;
|
|
40
41
|
res: import("express").Response<any, Record<string, any>>;
|
|
41
|
-
cookies: Record<string, string | undefined>;
|
|
42
|
-
}, object, object, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, false>;
|
|
43
|
-
/** Exported procedures with middleware */
|
|
44
|
-
export declare const authedProcedure: import("@trpc/server").TRPCProcedureBuilder<{
|
|
45
42
|
db: import("@prisma/client").PrismaClient<import("@prisma/client").Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;
|
|
43
|
+
cookies: Record<string, string | undefined>;
|
|
44
|
+
}, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, false>;
|
|
45
|
+
export declare const limitedProcedure: import("@trpc/server").TRPCProcedureBuilder<Context, object, {
|
|
46
|
+
userId: any;
|
|
46
47
|
session: any;
|
|
47
48
|
req: import("express").Request<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>>;
|
|
48
49
|
res: import("express").Response<any, Record<string, any>>;
|
|
50
|
+
db: import("@prisma/client").PrismaClient<import("@prisma/client").Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;
|
|
49
51
|
cookies: Record<string, string | undefined>;
|
|
50
|
-
}, object, {
|
|
51
|
-
session: any;
|
|
52
|
-
userId: any;
|
|
53
52
|
}, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, import("@trpc/server").TRPCUnsetMarker, false>;
|