Skip to content

Commit 7d51542

Browse files
authored
feat(packages/export): add @klicker-uzh/export package for assessment course data export (#5043)
1 parent b6ed437 commit 7d51542

28 files changed

Lines changed: 3107 additions & 20 deletions

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,8 @@ Without Traefik, use `http://localhost:<port>` directly. The `*.klicker.com` dom
210210
- **Infisical + Turbo env sync**: Any Infisical-managed env var used by tasks must be listed in `turbo.json` `globalEnv`; otherwise task runs/cache behavior can become stale or inconsistent across environments.
211211
- **Participant email uniqueness across auth modes**: Prisma enforces `Participant @@unique([email, isSSOAccount])`, so the same normalized email can exist once as manual and once as SSO. To block new cross-mode duplicates, account creation must explicitly check normalized email collisions in service logic. (`packages/graphql/src/services/accounts.ts`)
212212
- **Helm v3 secrets are external**: `deploy/charts/klicker-uzh-v3/` deployments reference `envFrom.secretRef` names, but the chart currently defines no `Secret` manifests; secrets must be provisioned out-of-band with matching names. (`deploy/charts/klicker-uzh-v3/templates/`)
213+
- **ExcelJS sheet-name collisions are case-insensitive**: When generating workbook sheet names, enforce uniqueness on a normalized lowercase key and keep the full name within Excel's 31-character limit; distinct casing alone is not enough to avoid collisions. (`packages/export/src/exportCourse.ts`)
214+
- **ExcelJS autoFilter must span data rows**: Setting `worksheet.autoFilter` to a header-only range (`to.row === 1`) or on an empty sheet makes Excel (esp. macOS) flag the workbook as "needs repair" on open even though the XML is well-formed. Set the range to the last data row (`to.row = rows.length + 1`) and skip autoFilter on header-only sheets. (`packages/export/src/exportCourse.ts` `addSheet`)
213215
- **Production rollout strategy**: Do not use `Recreate` for production web/API Deployments; PDBs do not protect against Deployment-driven scale-downs, so slow image pulls can leave services with zero endpoints. Use `RollingUpdate` in `deploy/env-uzh-prd/values.yaml`, with `maxUnavailable: 0` only for singleton services and `1` for multi-replica services.
214216
- **Edited chat image hydration needs a stable source id**: Edited branch messages in `apps/chat` get fresh local message ids, so image hydration must distinguish the local target message id from the persisted source message id (`attachmentSourceMessageId`) when fetching and merging attachments. (`apps/chat/src/hooks/useThreadManagement.ts`, `apps/chat/src/stores/chatStore.ts`)
215217
- **Assistant UI chat drop targets**: `ComposerPrimitive.AttachmentDropzone` must wrap both normal and edit chat composer roots; it owns the drag/drop capture handlers that prevent native browser file navigation, while Klicker-specific limits stay in local composer code. (`apps/chat/src/components/thread.tsx`)
@@ -220,6 +222,7 @@ Without Traefik, use `http://localhost:<port>` directly. The `*.klicker.com` dom
220222
- **Local embed harness target**: `util/embed-harness/` is for local verification only and should target the branch-local PWA (`http://127.0.0.1:3101/...`), not `https://pwa.klicker.com/...`, because production CSP / `frame-ancestors` blocks localhost embedding. (`util/embed-harness/`)
221223
- **Chat PWA login redirects**: `apps/chat/src/app/noLogin/page.tsx` must pass an absolute chat URL to the PWA login `redirect_to`; a relative chatbot path makes the PWA redirect to its own domain and 404. Local chat dev also needs ignored local env values for the backend `APP_SECRET` and `DATABASE_URL` so participant cookies verify and Prisma can load chatbot data.
222224
- **Chat Vitest alias resolution**: `apps/chat/vitest.config.ts` mirrors the app `@/*` alias from `apps/chat/tsconfig.json`; keep this in sync when adding client tests for modules that import from `@/src/...`.
225+
- **Export package Prisma row typing**: `packages/export` selects nested `ElementInstance` / `ElementBlock` relations through Prisma, but generated relation inference can collapse those nested fields to `never`; keep explicit row DTOs at the query boundary and cast the read result there instead of spreading casts through transforms. (`packages/export/src/`)
223226

224227
## Factory Skills (AI Assistance)
225228

cypress/cypress/e2e/N-course-workflow.cy.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1831,6 +1831,12 @@ describe('Test course creation and editing functionalities', function () {
18311831
cy.get('[data-cy="courses"]').click()
18321832
cy.get(`[data-cy="course-list-button-${this.data.sharing.course}"]`).click()
18331833
cy.get('[data-cy="course-share-button"]').click()
1834+
cy.get(`[data-cy="owner-permission-${Cypress.env('LECTURER_SHORTNAME')}"]`)
1835+
.should('exist')
1836+
.contains(messages.manage.sharing.permissionsOWNER)
1837+
cy.get(
1838+
`[data-cy="permission-${Cypress.env('LECTURER_IND_SHORTNAME')}"]`
1839+
).should('exist')
18341840

18351841
cy.get(
18361842
`[data-cy="revoke-permission-${Cypress.env('LECTURER_IND_SHORTNAME')}"]`
@@ -2199,6 +2205,10 @@ describe('Test course creation and editing functionalities', function () {
21992205
cy.get('[data-cy="courses"]').click()
22002206
cy.get(`[data-cy="course-list-button-${this.data.sharing.course}"]`).click()
22012207
cy.get('[data-cy="course-share-button"]').click()
2208+
cy.get(`[data-cy="owner-permission-${Cypress.env('LECTURER_SHORTNAME')}"]`)
2209+
.should('exist')
2210+
.contains(messages.manage.sharing.permissionsOWNER)
2211+
cy.get(`[data-cy="permission-${this.data.sharing.group1}"]`).should('exist')
22022212

22032213
cy.get(`[data-cy="revoke-permission-${this.data.sharing.group1}"]`).click()
22042214
cy.get('[data-cy="confirm-revocation"]').click()

cypress/cypress/e2e/O-live-quiz-workflow.cy.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,8 +1066,11 @@ describe('Different live-quiz workflows', function () {
10661066
// login student again on mobile, test navigation and answer second question
10671067
cy.viewport('iphone-x')
10681068
cy.loginStudent()
1069-
cy.findByText(this.data.course2.quiz.displayName).click()
1070-
cy.findByText(this.data.NR.content).should('exist')
1069+
cy.findByText(this.data.course2.quiz.displayName).should('exist').click()
1070+
cy.findByText(this.data.NR.content, { timeout: 10000 }).should('exist')
1071+
cy.findByText(messages.pwa.liveQuiz.allQuestionsAnswered).should(
1072+
'not.exist'
1073+
)
10711074

10721075
cy.get('[data-cy="mobile-menu-leaderboard"]').click()
10731076
cy.get('[data-cy="mobile-menu-feedbacks"]').click()
@@ -1140,9 +1143,15 @@ describe('Different live-quiz workflows', function () {
11401143
cy.wait(1000)
11411144

11421145
// make both feedbacks visible and respond to one of them (moderation enabled)
1146+
cy.get(
1147+
`[data-cy="publish-feedback-${this.data.course2.quiz.feedbackDesktop}"]`
1148+
).should('exist')
11431149
cy.get(
11441150
`[data-cy="publish-feedback-${this.data.course2.quiz.feedbackDesktop}"]`
11451151
).click()
1152+
cy.get(
1153+
`[data-cy="publish-feedback-${this.data.course2.quiz.feedbackMobile}"]`
1154+
).should('exist')
11461155
cy.get(
11471156
`[data-cy="publish-feedback-${this.data.course2.quiz.feedbackMobile}"]`
11481157
).click()

cypress/cypress/support/commands.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,18 @@ Cypress.Commands.add('cleanup', () => {
117117
cy.reload()
118118
})
119119

120+
const clearPersistedClientState = () => {
121+
return cy.wrap(null, { log: false }).then(async () => {
122+
await localforage.clear()
123+
})
124+
}
125+
126+
const assertManageSession = () => {
127+
cy.location('origin').should('eq', Cypress.env('URL_MANAGE'))
128+
cy.location('pathname').should('not.eq', '/login')
129+
cy.location('search').should('not.contain', 'expired=true')
130+
}
131+
120132
const loginFactory = (
121133
tokenData: {
122134
email: string
@@ -158,6 +170,10 @@ const loginFactory = (
158170
})
159171

160172
cy.visit(redirectUrl ?? Cypress.env('URL_MANAGE'))
173+
174+
if (!redirectUrl) {
175+
assertManageSession()
176+
}
161177
}
162178
}
163179

@@ -277,6 +293,9 @@ Cypress.Commands.add(
277293
)
278294

279295
Cypress.Commands.add('logoutUser', () => {
296+
cy.clearAllLocalStorage()
297+
cy.clearAllSessionStorage()
298+
clearPersistedClientState()
280299
cy.clearCookie('next-auth.session-token')
281300
})
282301

@@ -289,7 +308,18 @@ Cypress.Commands.add(
289308
({ username }: { username: string }) => {
290309
cy.clearAllCookies()
291310
cy.clearAllLocalStorage()
292-
cy.visit(Cypress.env('URL_STUDENT_LOGIN'))
311+
cy.visit(Cypress.env('URL_STUDENT_LOGIN'), {
312+
onBeforeLoad: (win) => {
313+
win.localStorage.clear()
314+
win.sessionStorage.clear()
315+
try {
316+
win.indexedDB?.deleteDatabase('localforage')
317+
} catch {
318+
// Fall back to the regular localforage clear after load.
319+
}
320+
},
321+
})
322+
clearPersistedClientState()
293323
cy.get('[data-cy="username-field"]').click().type(username)
294324
cy.get('[data-cy="password-field"]')
295325
.click()

packages/export/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export-output/

packages/export/README.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# @klicker-uzh/export
2+
3+
Standalone, **read-only** CLI that exports a single course's (or several courses') live-quiz data to CSV + XLSX for offline analysis.
4+
5+
It is **not** imported by any app and **not** part of any deployment — it is run manually by an operator, on demand. The only production contact is pointing it at a read-only production `DATABASE_URL`.
6+
7+
## Scope
8+
9+
Exports **live-quiz data only**:
10+
11+
- `LiveQuizResponse` — one row per response
12+
- `Participation` — enrolled participants
13+
- `ParticipantInvitation` — invitations
14+
- `AppliedPointCorrection` — lecturer point corrections (audit)
15+
- plus two dimension sheets: live quizzes and element instances
16+
17+
It does **not** include practice-quiz / microlearning `QuestionResponse`, `QuestionResponseDetail`, or `GroupActivityInstance`. The scope (included/excluded models) is recorded in every export's `manifest.json` so a course with those activity types is not mistaken for "fully exported".
18+
19+
## Usage
20+
21+
```bash
22+
# build the compiled binary (no tsx at runtime)
23+
pnpm --filter @klicker-uzh/export build
24+
25+
# single course
26+
pnpm --filter @klicker-uzh/export export -- --courseId <uuid> [--outputDir <path>] [--pseudonymize]
27+
28+
# multiple courses (also writes a combined workbook)
29+
pnpm --filter @klicker-uzh/export export -- --courseId <uuid1> --courseId <uuid2>
30+
31+
# development (runs via tsx, no build step)
32+
pnpm --filter @klicker-uzh/export export:dev -- --courseId <uuid>
33+
34+
# against production secrets (read-only URL)
35+
infisical run --env prd -- \
36+
node packages/export/dist/scripts/export-course.js --courseId <uuid> --outputDir <path>
37+
```
38+
39+
Flags:
40+
41+
- `--courseId <uuid>` — required, repeatable for multi-course export
42+
- `--outputDir <path>` — default `./export-output`
43+
- `--pseudonymize` — de-identify direct identifiers (see below)
44+
45+
## Output
46+
47+
```
48+
<outputDir>/
49+
<courseName>_<courseId>/
50+
export.xlsx # 6 sheets, frozen header + per-column filters
51+
responses.csv
52+
participants.csv
53+
invitations.csv
54+
corrections.csv
55+
live_quizzes.csv
56+
element_instances.csv
57+
manifest.json # schema version, counts, scope, per-file SHA-256, data dictionary
58+
combined-export.xlsx # only when >1 course is exported
59+
```
60+
61+
Files are written `0600`, directories `0700`.
62+
63+
### Sheets and join keys
64+
65+
| Sheet | Join key |
66+
| ------------------- | -------------------------------------------------------------------- |
67+
| `RESPONSES` | `liveQuizResponseId` (PK); `elementInstanceId``ELEMENT_INSTANCES` |
68+
| `PARTICIPANTS` | `participantId` (PK) |
69+
| `INVITATIONS` | `participantId` |
70+
| `CORRECTIONS` | `liveQuizResponseId` + `elementBlockExecution``RESPONSES` |
71+
| `LIVE_QUIZZES` | `liveQuizId` (includes zero-response quizzes) |
72+
| `ELEMENT_INSTANCES` | `elementInstanceId` (full untruncated content + point config) |
73+
74+
`RESPONSES` carries both the raw `response` JSON and flattened `response_choices` / `response_value` / `response_selection` / `response_assessment` columns so analysts can avoid parsing JSON.
75+
76+
## Safety
77+
78+
- **Read-only DB access**`createReadonlyClient` narrows the Prisma client to read operations at compile time, and a runtime `$allOperations` guard blocks every non-read operation, including raw queries (`$queryRaw` / `$executeRaw` / `…Unsafe`).
79+
- **PII modes** — default `full` writes identifiers verbatim (with a loud stderr warning); `--pseudonymize` hashes email / SSO id+email / matriculation number (per-run HMAC-SHA256 salt, never persisted) and redacts free-text answers and raw response JSON.
80+
- **CSV** — UTF-8 BOM, one physical line per row (embedded newlines normalized), formula-injection escaping (`= + - @`).
81+
- **Integrity**`manifest.json` records per-file SHA-256, counts, package version, PII mode, scope, and a data dictionary for the cryptic + flattened columns.
82+
83+
Output directories contain PII and are gitignored.
84+
85+
## Development
86+
87+
```bash
88+
pnpm --filter @klicker-uzh/export check # tsc --noEmit
89+
pnpm --filter @klicker-uzh/export test # vitest
90+
pnpm --filter @klicker-uzh/export build # rollup -> dist/ + dist/scripts/export-course.js
91+
```

packages/export/package.json

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
"name": "@klicker-uzh/export",
3+
"version": "3.4.0-alpha.62",
4+
"license": "AGPL-3.0",
5+
"main": "dist/index.js",
6+
"types": "dist/index.d.ts",
7+
"files": [
8+
"dist"
9+
],
10+
"dependencies": {
11+
"@klicker-uzh/prisma": "workspace:*",
12+
"exceljs": "4.4.0"
13+
},
14+
"devDependencies": {
15+
"@klicker-uzh/types": "workspace:*",
16+
"@rollup/plugin-node-resolve": "~15.3.1",
17+
"@rollup/plugin-typescript": "~12.1.4",
18+
"@types/node": "^20.19.11",
19+
"cross-env": "~7.0.3",
20+
"npm-run-all": "~4.1.5",
21+
"rollup": "~4.34.9",
22+
"tsx": "~4.19.4",
23+
"typescript": "~5.6.3",
24+
"vitest": "~3.2.4"
25+
},
26+
"scripts": {
27+
"build": "run-s build:ts",
28+
"build:test": "pnpm run build",
29+
"build:ts": "cross-env NODE_ENV=production rollup -c && chmod +x dist/scripts/export-course.js",
30+
"check": "tsc --noEmit",
31+
"dev": "cross-env NODE_ENV=development rollup -c --watch",
32+
"export": "node dist/scripts/export-course.js",
33+
"export:dev": "tsx src/scripts/export-course.ts",
34+
"test": "vitest run",
35+
"test:watch": "vitest"
36+
},
37+
"engines": {
38+
"node": "=20"
39+
},
40+
"volta": {
41+
"extends": "../../package.json"
42+
},
43+
"bin": {
44+
"klicker-export": "dist/scripts/export-course.js"
45+
},
46+
"type": "module"
47+
}

packages/export/rollup.config.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { nodeResolve } from '@rollup/plugin-node-resolve'
2+
import typescript from '@rollup/plugin-typescript'
3+
import { defineConfig } from 'rollup'
4+
5+
const config = defineConfig([
6+
{
7+
// Main build configuration (library entry)
8+
input: ['src/index.ts'],
9+
output: {
10+
dir: 'dist',
11+
format: 'esm',
12+
sourcemap: true,
13+
entryFileNames: '[name].js',
14+
},
15+
plugins: [
16+
nodeResolve(),
17+
typescript({
18+
tsconfig: './tsconfig.json',
19+
rootDir: 'src',
20+
}),
21+
],
22+
external: [/@klicker-uzh*/, /node_modules/],
23+
},
24+
{
25+
// CLI binary: compile the export script so production runs need no tsx/esbuild
26+
input: ['src/scripts/export-course.ts'],
27+
output: {
28+
dir: 'dist/scripts',
29+
format: 'esm',
30+
sourcemap: true,
31+
entryFileNames: '[name].js',
32+
banner: '#!/usr/bin/env node',
33+
},
34+
plugins: [
35+
nodeResolve(),
36+
typescript({
37+
tsconfig: './tsconfig.json',
38+
rootDir: 'src',
39+
outDir: 'dist/scripts',
40+
declaration: false,
41+
declarationMap: false,
42+
}),
43+
],
44+
external: [/@klicker-uzh*/, /node_modules/],
45+
},
46+
])
47+
48+
export default config

packages/export/src/cli.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
export const EXPORT_COURSE_USAGE =
2+
'Usage: pnpm --filter @klicker-uzh/export export -- --courseId <id> [--courseId <id2> ...] [--outputDir <path>] [--pseudonymize]\n' +
3+
'Scope: live-quiz responses, participants, invitations, point corrections only (no practice-quiz / microlearning / group-activity responses).\n' +
4+
'--pseudonymize replaces direct identifiers (email, sso id, matriculation) with per-run HMAC hashes and redacts free-text answers.'
5+
6+
export class CliUsageError extends Error {
7+
constructor(message: string) {
8+
super(message)
9+
this.name = 'CliUsageError'
10+
}
11+
}
12+
13+
function readOptionValue(
14+
args: string[],
15+
index: number,
16+
option: string
17+
): string {
18+
const value = args[index + 1]
19+
if (value == null || value.startsWith('--') || value.trim() === '') {
20+
throw new CliUsageError(`Missing value for ${option}`)
21+
}
22+
return value
23+
}
24+
25+
export function parseExportCourseArgs(args: string[]): {
26+
courseIds: string[]
27+
outputDir: string
28+
pseudonymize: boolean
29+
} {
30+
const courseIds: string[] = []
31+
let outputDir = './export-output'
32+
let outputDirSet = false
33+
let pseudonymize = false
34+
35+
for (let i = 0; i < args.length; i++) {
36+
const arg = args[i]!
37+
38+
if (arg === '--') {
39+
continue
40+
}
41+
42+
if (arg === '--pseudonymize') {
43+
pseudonymize = true
44+
continue
45+
}
46+
47+
if (arg === '--courseId') {
48+
courseIds.push(readOptionValue(args, i, arg))
49+
i++
50+
continue
51+
}
52+
53+
if (arg === '--outputDir') {
54+
if (outputDirSet) {
55+
throw new CliUsageError('Duplicate --outputDir')
56+
}
57+
outputDir = readOptionValue(args, i, arg)
58+
outputDirSet = true
59+
i++
60+
continue
61+
}
62+
63+
if (arg === '--help' || arg === '-h') {
64+
throw new CliUsageError(EXPORT_COURSE_USAGE)
65+
}
66+
67+
throw new CliUsageError(`Unknown argument: ${arg}`)
68+
}
69+
70+
if (courseIds.length === 0) {
71+
throw new CliUsageError('At least one --courseId is required')
72+
}
73+
74+
return { courseIds, outputDir, pseudonymize }
75+
}

0 commit comments

Comments
 (0)