1+ import { stringify } from "csv-stringify/sync"
12import { Request , Response } from "express"
23import JSONStream from "JSONStream"
4+ import jwt , { Secret } from "jsonwebtoken"
35import { chunk , omit } from "lodash"
6+ import * as XLSX from "xlsx"
47import * as yup from "yup"
58
69import {
@@ -14,13 +17,27 @@ import {
1417import { generateUserCourseProgress } from "../../bin/kafkaConsumer/common/userCourseProgress/generateUserCourseProgress"
1518import { err , isDefined } from "../../util"
1619import { ApiContext , Controller } from "../types"
20+ import { requireAdminOrCourseOwner } from "../utils"
1721
1822const languageMap : Record < string , string > = {
1923 en : "en_US" ,
2024 sv : "sv_SE" ,
2125 fi : "fi_FI" ,
2226}
2327
28+ // JWT secret for signing download tokens
29+ const JWT_SECRET = process . env . JWT_SECRET as Secret
30+
31+ if ( ! JWT_SECRET ) {
32+ throw new Error ( "JWT_SECRET environment variable is required" )
33+ }
34+
35+ interface DownloadTokenPayload {
36+ courseId : string
37+ fromDate ?: string
38+ format ?: "csv" | "excel"
39+ }
40+
2441interface RegisterCompletionInput {
2542 completion_id : string
2643 student_number : string
@@ -96,6 +113,174 @@ export class CompletionController extends Controller {
96113 return // NOSONAR
97114 }
98115
116+ completionsCSVToken = async (
117+ req : Request < { courseId : string } > ,
118+ res : Response ,
119+ ) => {
120+ const { courseId } = req . params
121+ const { fromDate, format } = req . query
122+
123+ const authRes = await requireAdminOrCourseOwner ( courseId , this . ctx ) (
124+ req ,
125+ res ,
126+ )
127+
128+ if ( authRes . isErr ( ) ) {
129+ return authRes . error
130+ }
131+
132+ const course = await this . ctx . prisma . course . findUnique ( {
133+ where : { id : courseId } ,
134+ } )
135+
136+ if ( ! course ) {
137+ return res . status ( 404 ) . json ( { message : "Course not found" } )
138+ }
139+
140+ // Generate a signed JWT token valid for 30 seconds
141+ const payload : DownloadTokenPayload = {
142+ courseId,
143+ fromDate : typeof fromDate === "string" ? fromDate : undefined ,
144+ format : format === "excel" ? "excel" : "csv" ,
145+ }
146+
147+ const token = jwt . sign ( payload , JWT_SECRET , {
148+ expiresIn : "30s" ,
149+ } )
150+
151+ return res . status ( 200 ) . json ( { token } )
152+ }
153+
154+ completionsCSV = async (
155+ req : Request < { courseId : string } > ,
156+ res : Response ,
157+ ) => {
158+ const { courseId } = req . params
159+ const { token } = req . query
160+ const { knex } = this . ctx
161+
162+ // Validate token
163+ if ( ! token || typeof token !== "string" ) {
164+ return res . status ( 401 ) . json ( { message : "Invalid or missing token" } )
165+ }
166+
167+ let tokenData : DownloadTokenPayload
168+ try {
169+ tokenData = jwt . verify ( token , JWT_SECRET ) as DownloadTokenPayload
170+ } catch ( error ) {
171+ if ( error instanceof jwt . TokenExpiredError ) {
172+ return res . status ( 401 ) . json ( { message : "Token expired" } )
173+ }
174+ return res . status ( 401 ) . json ( { message : "Invalid token" } )
175+ }
176+
177+ if ( tokenData . courseId !== courseId ) {
178+ return res
179+ . status ( 403 )
180+ . json ( { message : "Token not valid for this course" } )
181+ }
182+
183+ const fromDate = tokenData . fromDate
184+ const format = tokenData . format ?? "csv"
185+
186+ const course = await this . ctx . prisma . course . findUnique ( {
187+ where : { id : courseId } ,
188+ } )
189+
190+ if ( ! course ) {
191+ return res . status ( 404 ) . json ( { message : "Course not found" } )
192+ }
193+
194+ let query = knex
195+ . select < any , any [ ] > (
196+ "u.id" ,
197+ "com.email" ,
198+ "u.first_name" ,
199+ "u.last_name" ,
200+ "com.completion_date" ,
201+ "com.completion_language" ,
202+ "com.grade" ,
203+ )
204+ . from ( "completion as com" )
205+ . join ( "course as c" , "com.course_id" , "c.id" )
206+ . join ( "user as u" , "com.user_id" , "u.id" )
207+ . where ( "c.id" , course . completions_handled_by_id ?? course . id )
208+ . distinct ( "u.id" , "com.course_id" )
209+ . orderBy ( "com.completion_date" , "asc" )
210+ . orderBy ( "u.last_name" , "asc" )
211+ . orderBy ( "u.first_name" , "asc" )
212+ . orderBy ( "u.id" , "asc" )
213+
214+ if ( fromDate && typeof fromDate === "string" ) {
215+ try {
216+ const date = new Date ( fromDate )
217+ query = query . where ( "com.completion_date" , ">=" , date )
218+ } catch ( e ) {
219+ return res . status ( 400 ) . json ( { message : "Invalid date format" } )
220+ }
221+ }
222+
223+ const completions = await query
224+
225+ const headers = [
226+ "User ID" ,
227+ "Email" ,
228+ "First Name" ,
229+ "Last Name" ,
230+ "Completion Date" ,
231+ "Completion Language" ,
232+ "Grade" ,
233+ ]
234+
235+ const rows = completions . map ( ( row ) => [
236+ row . id ,
237+ row . email ,
238+ row . first_name ,
239+ row . last_name ,
240+ row . completion_date ,
241+ row . completion_language ,
242+ row . grade ,
243+ ] )
244+
245+ if ( format === "excel" ) {
246+ // Generate Excel file
247+ const worksheet = XLSX . utils . aoa_to_sheet ( [ headers , ...rows ] )
248+ const workbook = XLSX . utils . book_new ( )
249+ XLSX . utils . book_append_sheet ( workbook , worksheet , "Completions" )
250+
251+ const excelBuffer = XLSX . write ( workbook , {
252+ type : "buffer" ,
253+ bookType : "xlsx" ,
254+ } )
255+
256+ res . setHeader (
257+ "Content-Type" ,
258+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ,
259+ )
260+ res . setHeader (
261+ "Content-Disposition" ,
262+ `attachment; filename="completions_${
263+ fromDate ? fromDate . toString ( ) . split ( "T" ) [ 0 ] : "all"
264+ } .xlsx"`,
265+ )
266+
267+ return res . status ( 200 ) . send ( excelBuffer )
268+ }
269+
270+ // Default CSV format
271+ const csvContent = stringify ( [ headers , ...rows ] )
272+
273+ res . setHeader ( "Content-Type" , "text/csv" )
274+ res . setHeader (
275+ "Content-Disposition" ,
276+ `attachment; filename="completions_${
277+ fromDate ? fromDate . toString ( ) . split ( "T" ) [ 0 ] : "all"
278+ } .csv"`,
279+ )
280+
281+ return res . status ( 200 ) . send ( csvContent )
282+ }
283+
99284 completionInstructions = async (
100285 req : Request < { slug : string ; language : string } > ,
101286 res : Response ,
0 commit comments