1
+ 'use server' ;
2
+
3
+ import { z } from 'zod' ;
4
+ import { ObjectId } from 'mongodb' ;
5
+ import { connectToDatabase } from '@/lib/db' ;
6
+ import { getOpenAIClient } from '@/lib/openai' ;
7
+ import { mapMongoId } from '@/lib/utils' ;
8
+ import type {
9
+ ReadingSessionDocument ,
10
+ ReadingSession ,
11
+ ReadingAttemptDocument ,
12
+ ReadingComprehensionQuestion ,
13
+ } from '@/types' ;
14
+
15
+ // --- Zod Schemas for Validation ---
16
+
17
+ const ReadingComprehensionQuestionSchema = z . object ( {
18
+ question_text : z . string ( ) . trim ( ) . min ( 1 , 'Question text cannot be empty' ) ,
19
+ correct_answer : z . string ( ) . trim ( ) . min ( 1 , 'Answer text cannot be empty' ) ,
20
+ } ) ;
21
+
22
+ const ReadingContentResponseSchema = z . object ( {
23
+ passage : z . string ( ) . trim ( ) . min ( 50 , 'Passage must be at least 50 characters' ) ,
24
+ questions : z . array ( ReadingComprehensionQuestionSchema ) . length ( 5 , 'Must generate exactly 5 questions' ) ,
25
+ } ) ;
26
+
27
+ // --- Helper: Map Reading Session Document ---
28
+ function mapReadingSessionDocument ( doc : ReadingSessionDocument ) : ReadingSession {
29
+ const mapped = mapMongoId ( doc ) ;
30
+ return mapped as ReadingSession ;
31
+ }
32
+
33
+ // --- Action: Generate Reading Content ---
34
+
35
+ interface GenerateReadingContentResult {
36
+ success : boolean ;
37
+ session ?: ReadingSession ;
38
+ message ?: string ;
39
+ }
40
+
41
+ export async function generateReadingContentAction ( topic : string ) : Promise < GenerateReadingContentResult > {
42
+ if ( ! topic || ! topic . trim ( ) ) {
43
+ return { success : false , message : 'Topic cannot be empty.' } ;
44
+ }
45
+
46
+ try {
47
+ const openai = getOpenAIClient ( ) ;
48
+ const { db } = await connectToDatabase ( ) ;
49
+ const readingSessionsCollection = db . collection < ReadingSessionDocument > ( 'reading_sessions' ) ;
50
+
51
+ const prompt = `Generate reading comprehension content for the topic: "${ topic } ".
52
+
53
+ Create a short paragraph (150-250 words) about this topic that is informative and engaging. The paragraph should contain specific facts, dates, names, or details that can be tested.
54
+
55
+ Then create exactly 5 comprehension questions with specific answers based on the passage. The questions should:
56
+ - Test specific details from the passage
57
+ - Have clear, factual answers (not open-ended)
58
+ - Be answerable directly from the text
59
+ - Include a mix of who, what, when, where, why questions
60
+
61
+ Output Format:
62
+ Provide the output as a JSON object with "passage" (string) and "questions" (array of objects). Each question object must have "question_text" and "correct_answer" fields.
63
+
64
+ Example format:
65
+ {
66
+ "passage": "Your informative paragraph here...",
67
+ "questions": [
68
+ {
69
+ "question_text": "What year was X founded?",
70
+ "correct_answer": "1995"
71
+ },
72
+ ...
73
+ ]
74
+ }` ;
75
+
76
+ const completion = await openai . chat . completions . create ( {
77
+ model : 'gpt-4o' ,
78
+ messages : [ { role : 'user' , content : prompt } ] ,
79
+ response_format : { type : 'json_object' } ,
80
+ temperature : 0.7 ,
81
+ } ) ;
82
+
83
+ const content = completion . choices [ 0 ] ?. message ?. content ;
84
+ if ( ! content ) {
85
+ throw new Error ( 'OpenAI did not return content.' ) ;
86
+ }
87
+
88
+ let parsedContent ;
89
+ try {
90
+ parsedContent = JSON . parse ( content ) ;
91
+ } catch ( parseError ) {
92
+ console . error ( 'Failed to parse OpenAI JSON response:' , parseError , 'Content:' , content ) ;
93
+ throw new Error ( 'Failed to parse reading content from AI response.' ) ;
94
+ }
95
+
96
+ const validationResult = ReadingContentResponseSchema . safeParse ( parsedContent ) ;
97
+ if ( ! validationResult . success ) {
98
+ console . error ( 'OpenAI response validation failed:' , validationResult . error . errors ) ;
99
+ throw new Error ( 'Generated reading content is not in the expected format.' ) ;
100
+ }
101
+
102
+ const newReadingSessionData : Omit < ReadingSessionDocument , '_id' > = {
103
+ topic : topic . trim ( ) ,
104
+ passage : validationResult . data . passage ,
105
+ questions : validationResult . data . questions ,
106
+ createdAt : new Date ( ) ,
107
+ } ;
108
+
109
+ const insertResult = await readingSessionsCollection . insertOne ( newReadingSessionData as ReadingSessionDocument ) ;
110
+ if ( ! insertResult . insertedId ) {
111
+ throw new Error ( 'Failed to insert reading session into database.' ) ;
112
+ }
113
+
114
+ const createdDoc = await readingSessionsCollection . findOne ( { _id : insertResult . insertedId } ) ;
115
+ if ( ! createdDoc ) {
116
+ throw new Error ( 'Failed to retrieve created reading session from database.' ) ;
117
+ }
118
+
119
+ const mappedSession = mapReadingSessionDocument ( createdDoc ) ;
120
+ return { success : true , session : mappedSession } ;
121
+
122
+ } catch ( error ) {
123
+ console . error ( '[Generate Reading Content Action Error]' , error ) ;
124
+ const message = error instanceof Error ? error . message : 'Failed to generate reading content.' ;
125
+ return { success : false , message } ;
126
+ }
127
+ }
128
+
129
+ // --- Action: Submit Reading Attempt ---
130
+
131
+ interface SubmitReadingAttemptResult {
132
+ success : boolean ;
133
+ attempt ?: {
134
+ id : string ;
135
+ total_score : number ;
136
+ answers : {
137
+ question_index : number ;
138
+ user_answer : string ;
139
+ is_correct : boolean ;
140
+ correct_answer : string ;
141
+ } [ ] ;
142
+ } ;
143
+ message ?: string ;
144
+ }
145
+
146
+ export async function submitReadingAttemptAction (
147
+ sessionId : string ,
148
+ readingTimeMs : number ,
149
+ userAnswers : { question_index : number ; user_answer : string } [ ]
150
+ ) : Promise < SubmitReadingAttemptResult > {
151
+ if ( ! sessionId || ! ObjectId . isValid ( sessionId ) ) {
152
+ return { success : false , message : 'Invalid session ID.' } ;
153
+ }
154
+ if ( readingTimeMs <= 0 ) {
155
+ return { success : false , message : 'Invalid reading time.' } ;
156
+ }
157
+ if ( ! Array . isArray ( userAnswers ) || userAnswers . length === 0 ) {
158
+ return { success : false , message : 'No answers provided.' } ;
159
+ }
160
+
161
+ try {
162
+ const { db } = await connectToDatabase ( ) ;
163
+ const readingSessionsCollection = db . collection < ReadingSessionDocument > ( 'reading_sessions' ) ;
164
+ const readingAttemptsCollection = db . collection < ReadingAttemptDocument > ( 'reading_attempts' ) ;
165
+
166
+ // Fetch the reading session to get correct answers
167
+ const session = await readingSessionsCollection . findOne ( { _id : new ObjectId ( sessionId ) } ) ;
168
+ if ( ! session ) {
169
+ throw new Error ( 'Reading session not found.' ) ;
170
+ }
171
+
172
+ // Score the answers by simple string comparison (case-insensitive)
173
+ const scoredAnswers = userAnswers . map ( userAnswer => {
174
+ const question = session . questions [ userAnswer . question_index ] ;
175
+ if ( ! question ) {
176
+ throw new Error ( `Question at index ${ userAnswer . question_index } not found.` ) ;
177
+ }
178
+
179
+ const isCorrect = userAnswer . user_answer . trim ( ) . toLowerCase ( ) ===
180
+ question . correct_answer . trim ( ) . toLowerCase ( ) ;
181
+
182
+ return {
183
+ question_index : userAnswer . question_index ,
184
+ user_answer : userAnswer . user_answer . trim ( ) ,
185
+ is_correct : isCorrect ,
186
+ correct_answer : question . correct_answer ,
187
+ } ;
188
+ } ) ;
189
+
190
+ const totalScore = scoredAnswers . filter ( answer => answer . is_correct ) . length ;
191
+
192
+ // Store the attempt
193
+ const newAttemptData : Omit < ReadingAttemptDocument , '_id' > = {
194
+ session_id : new ObjectId ( sessionId ) ,
195
+ reading_time_ms : readingTimeMs ,
196
+ answers : scoredAnswers . map ( answer => ( {
197
+ question_index : answer . question_index ,
198
+ user_answer : answer . user_answer ,
199
+ is_correct : answer . is_correct ,
200
+ } ) ) ,
201
+ total_score : totalScore ,
202
+ createdAt : new Date ( ) ,
203
+ } ;
204
+
205
+ const insertResult = await readingAttemptsCollection . insertOne ( newAttemptData as ReadingAttemptDocument ) ;
206
+ if ( ! insertResult . insertedId ) {
207
+ throw new Error ( 'Failed to store reading attempt.' ) ;
208
+ }
209
+
210
+ return {
211
+ success : true ,
212
+ attempt : {
213
+ id : insertResult . insertedId . toString ( ) ,
214
+ total_score : totalScore ,
215
+ answers : scoredAnswers ,
216
+ } ,
217
+ } ;
218
+
219
+ } catch ( error ) {
220
+ console . error ( '[Submit Reading Attempt Action Error]' , error ) ;
221
+ const message = error instanceof Error ? error . message : 'Failed to submit reading attempt.' ;
222
+ return { success : false , message } ;
223
+ }
224
+ }
225
+
226
+ // --- Action: Update Answer Score (Override) ---
227
+
228
+ interface UpdateAnswerScoreResult {
229
+ success : boolean ;
230
+ message ?: string ;
231
+ }
232
+
233
+ export async function updateAnswerScoreAction (
234
+ attemptId : string ,
235
+ questionIndex : number ,
236
+ newIsCorrect : boolean
237
+ ) : Promise < UpdateAnswerScoreResult > {
238
+ if ( ! attemptId || ! ObjectId . isValid ( attemptId ) ) {
239
+ return { success : false , message : 'Invalid attempt ID.' } ;
240
+ }
241
+
242
+ try {
243
+ const { db } = await connectToDatabase ( ) ;
244
+ const readingAttemptsCollection = db . collection < ReadingAttemptDocument > ( 'reading_attempts' ) ;
245
+
246
+ // Find the attempt
247
+ const attempt = await readingAttemptsCollection . findOne ( { _id : new ObjectId ( attemptId ) } ) ;
248
+ if ( ! attempt ) {
249
+ throw new Error ( 'Reading attempt not found.' ) ;
250
+ }
251
+
252
+ // Update the specific answer
253
+ const updatedAnswers = attempt . answers . map ( answer => {
254
+ if ( answer . question_index === questionIndex ) {
255
+ return {
256
+ ...answer ,
257
+ is_correct : newIsCorrect ,
258
+ overridden : true ,
259
+ } ;
260
+ }
261
+ return answer ;
262
+ } ) ;
263
+
264
+ // Recalculate total score
265
+ const newTotalScore = updatedAnswers . filter ( answer => answer . is_correct ) . length ;
266
+
267
+ // Update the document
268
+ await readingAttemptsCollection . updateOne (
269
+ { _id : new ObjectId ( attemptId ) } ,
270
+ {
271
+ $set : {
272
+ answers : updatedAnswers ,
273
+ total_score : newTotalScore ,
274
+ }
275
+ }
276
+ ) ;
277
+
278
+ return { success : true } ;
279
+
280
+ } catch ( error ) {
281
+ console . error ( '[Update Answer Score Action Error]' , error ) ;
282
+ const message = error instanceof Error ? error . message : 'Failed to update answer score.' ;
283
+ return { success : false , message } ;
284
+ }
285
+ }
0 commit comments