Skip to content

Commit 7334d72

Browse files
authored
Add button for downloading course completions (#1307)
* Add button for downloading course completions * Add option to download completions as excel * Use course ownership for admin course pages also fix context for navigation menu buttons * Refactoring * Add example JWT_SECRET to .env.example * Validate identifiers for authorizing by course identifier * Authorize admins before checking course existance * Fix parameter order * Obey the rabbit
1 parent e0b4da2 commit 7334d72

File tree

25 files changed

+972
-54
lines changed

25 files changed

+972
-54
lines changed

backend/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,5 @@ HY_ORGANIZATION_ID=x
3232

3333
UPDATE_USER_SECRET=secret
3434
BACKEND_URL=http://localhost:4000
35+
36+
JWT_SECRET=supersecretkey

backend/accessControl.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,16 @@ export const isCourseOwner =
7474
return Boolean(ownership)
7575
}
7676

77+
export const isAdminOrCourseOwner =
78+
(course_id: string): AuthorizeFunction =>
79+
async (root, args, ctx, info) => {
80+
if (isAdmin(root, args, ctx, info)) {
81+
return true
82+
}
83+
84+
return await isCourseOwner(course_id)(root, args, ctx, info)
85+
}
86+
7787
export const or =
7888
(...predicates: AuthorizeFunction[]): AuthorizeFunction =>
7989
(...params) =>

backend/api/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ export function apiRouter(ctx: ApiContext) {
1818

1919
return Router()
2020
.get("/completions/:slug", completionController.completions)
21+
.get(
22+
"/completions/:courseId/csv/token",
23+
completionController.completionsCSVToken,
24+
)
25+
.get("/completions/:courseId/csv", completionController.completionsCSV)
2126
.get("/completionTiers/:slug", completionController.completionTiers)
2227
.get(
2328
"/completionInstructions/:slug/:language",

backend/api/routes/completions.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import { stringify } from "csv-stringify/sync"
12
import { Request, Response } from "express"
23
import JSONStream from "JSONStream"
4+
import jwt, { Secret } from "jsonwebtoken"
35
import { chunk, omit } from "lodash"
6+
import * as XLSX from "xlsx"
47
import * as yup from "yup"
58

69
import {
@@ -14,13 +17,27 @@ import {
1417
import { generateUserCourseProgress } from "../../bin/kafkaConsumer/common/userCourseProgress/generateUserCourseProgress"
1518
import { err, isDefined } from "../../util"
1619
import { ApiContext, Controller } from "../types"
20+
import { requireAdminOrCourseOwner } from "../utils"
1721

1822
const 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+
2441
interface 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,

backend/api/utils.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,40 @@ export function requireAdmin(ctx: BaseContext) {
7171
}
7272
}
7373

74+
export function requireAdminOrCourseOwner(courseId: string, ctx: BaseContext) {
75+
return async function (
76+
req: Request,
77+
res: Response,
78+
): Promise<Result<boolean, Response>> {
79+
const getUserResult = await getUser(ctx)(req, res)
80+
81+
if (getUserResult.isErr()) {
82+
return err(getUserResult.error)
83+
}
84+
85+
const { user, details } = getUserResult.value
86+
87+
// Allow if user is admin
88+
if (details.administrator) {
89+
return ok(true)
90+
}
91+
92+
// Check if user has course ownership for this course
93+
const ownership = await ctx.knex
94+
.select("id")
95+
.from("course_ownership")
96+
.where("user_id", user.id)
97+
.andWhere("course_id", courseId)
98+
.first()
99+
100+
if (ownership) {
101+
return ok(true)
102+
}
103+
104+
return err(res.status(401).json({ message: "unauthorized" }))
105+
}
106+
}
107+
74108
export function getUser({ knex, logger }: BaseContext) {
75109
return async function (
76110
req: Request,

0 commit comments

Comments
 (0)