Skip to content

Commit c8de9c8

Browse files
authored
chore(prisma-data): add round-parameterized course-award seed (#5191)
1 parent 15feded commit c8de9c8

10 files changed

Lines changed: 1035 additions & 1062 deletions

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ output/
6565
playwright/playwright-report/
6666
playwright/test-results/
6767

68-
# One-off course data pulls (contain real participant data — never commit)
68+
# One-off course data pulls (contain real participant data — never commit).
69+
# Course-award seed payloads, comparison sheets, and state dumps all live in
70+
# _local/, so a new round cannot leak data by picking an unignored filename.
71+
packages/prisma-data/src/data/_local/
6972
packages/prisma-data/summerschool_*
7073
packages/prisma-data/src/data/summerschool_*
7174

docs/data-and-migrations.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,23 @@ Three independent seed paths — changing one does NOT update the others:
4949

5050
### Production batch seeds
5151

52-
Production batch inputs, comparison sheets, and state dumps stay local and gitignored. The Summer School portfolio seed is isolated from the earlier activity seed and is safe-by-default:
52+
Externally-earned points and badges (Summer School games, offline activities) are seeded by **one** script, `seedCourseAwards.ts`, parameterized by a _round_. Rounds are declared in `courseAwardRounds.ts`; every artefact a round produces is namespaced by its key inside the gitignored `packages/prisma-data/src/data/_local/`, so no round can replay another round's payload and no payload can be committed by picking an unignored filename.
5353

5454
```bash
55-
pnpm --filter @klicker-uzh/prisma-data seed:prod:summerschool:portfolio
55+
ROUND=<key> pnpm --filter @klicker-uzh/prisma-data seed:prod:course-awards:prepare # optional, derives an award from the DB
56+
ROUND=<key> pnpm --filter @klicker-uzh/prisma-data seed:prod:course-awards # dry run
57+
ROUND=<key> DRY_RUN=false pnpm --filter @klicker-uzh/prisma-data seed:prod:course-awards
5658
```
5759

58-
The default command only validates production references, resolves usernames case-insensitively, writes a comparison CSV and payload-bound before-state dump, and reports the intended point/XP and achievement changes. A write requires a separate `DRY_RUN=false` execution and refuses to start if production state or the payload no longer matches that dump. Dry-run cannot overwrite a changed snapshot, and an after-state dump blocks accidental replay. Writes run atomically and are verified before commit; the after-state dump records the result. Never reuse an earlier Summer School payload for a later activity.
60+
A new round is one entry in `ROUNDS` (course ID plus the achievement IDs it may grant) and a `_local/<round>_data.json` payload of `{ username, points?, awards? }` rows. Achievement IDs are asserted against `nameEN` before any write, so ID drift across environments fails loudly.
61+
62+
The dry run validates references, resolves usernames case-insensitively, writes a comparison CSV and a payload-bound before-state dump, and reports the intended point/XP and achievement changes. A write requires a separate `DRY_RUN=false` execution and refuses to start if production state or the payload no longer matches that dump. An after-state dump blocks accidental replay. Writes run atomically under `Serializable` and are verified inside the transaction before commit.
63+
64+
Points and badges are independent per row: `points` defaults to 0 and `awards` to none, but a row must grant one of the two. That makes a badge-only round ordinary rather than a special case — it skips the `leaderboardEntry`/`Participant.xp` writes, and the post-write check (score and XP must move by exactly the payload delta) then asserts they did not move at all. **A late addition to an already-seeded round is a new round, never a rerun:** the original is replay-locked, and recipients who already received points must not be paid twice.
65+
66+
Points earned inside Klicker (Swiss Quiz, microlearnings) are already on the leaderboard and are never part of these payloads — only externally-run activities are seeded. Awards that depend on in-platform behaviour are derived from the database rather than the workbook: `prepareMicrolearningAwards.ts` grants a round's `derivedAward` (Busy Bee, for Summer School) when the participant has a `QuestionResponse` for every `ElementInstance` of every non-deleted `MicroLearning` in the course. The derivation is frozen into the payload rather than recomputed at write time, so the payload hash still pins exactly what gets written.
67+
68+
**Do not derive microlearning completion from `ParticipantActivityPerformance.completion`, `MicroLearning.completedCount`, or `startedCount`.** All three are empty for the Summer School 2026 course (zero rows, zero counters) even though responses exist, so they silently yield zero for every participant instead of failing. `QuestionResponse` is the reliable signal; cross-check the derived count against the workbook before seeding.
5969

6070
## Typed Json fields
6171

packages/prisma-data/package.json

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,18 +42,16 @@
4242
"seed": "ENV=development ../../util/_run_with_infisical.sh --env dev pnpm run seed:raw",
4343
"seed:achievements": "ENV=development ../../util/_run_with_infisical.sh --env dev tsx src/data/seedAchievements.ts",
4444
"seed:assessment-course": "ENV=development tsx src/data/seedAssessmentCourse.ts",
45+
"seed:course-awards": "ENV=development ../../util/_run_with_infisical.sh --env dev tsx src/data/seedCourseAwards.ts",
4546
"seed:flashcards": "ENV=development ../../util/_run_with_infisical.sh --env dev tsx src/data/seedFlashcards.ts",
4647
"seed:prod:achievements": "ENV=production ../../util/_run_with_infisical.sh --env prd tsx src/data/seedAchievements.ts",
48+
"seed:prod:course-awards": "ENV=production ../../util/_run_with_infisical.sh --env prd tsx src/data/seedCourseAwards.ts",
49+
"seed:prod:course-awards:prepare": "ENV=production ../../util/_run_with_infisical.sh --env prd tsx src/data/prepareMicrolearningAwards.ts",
4750
"seed:prod:flashcards": "ENV=development ../../util/_run_with_infisical.sh --env prd tsx src/data/seedFlashcards.ts",
4851
"seed:prod:repetitionPool": "ENV=development ../../util/_run_with_infisical.sh --env prd tsx src/data/seedRepetitionPool.ts",
49-
"seed:prod:summerschool": "ENV=production ../../util/_run_with_infisical.sh --env prd tsx src/data/seedSummerSchool2026.ts",
50-
"seed:prod:summerschool:portfolio": "ENV=production ../../util/_run_with_infisical.sh --env prd tsx src/data/seedSummerSchoolPortfolio2026.ts",
5152
"seed:qa": "ENV=development ../../util/_run_with_infisical.sh --env stg tsx src/data/seedTEST.ts",
5253
"seed:raw": "ENV=development run-s --npm-path pnpm seed:test seed:assessment-course",
53-
"seed:raw:summerschool": "tsx src/data/seedSummerSchool2026.ts",
54-
"seed:raw:summerschool:portfolio": "tsx src/data/seedSummerSchoolPortfolio2026.ts",
55-
"seed:summerschool": "ENV=development ../../util/_run_with_infisical.sh --env dev tsx src/data/seedSummerSchool2026.ts",
56-
"seed:summerschool:portfolio": "ENV=development ../../util/_run_with_infisical.sh --env dev tsx src/data/seedSummerSchoolPortfolio2026.ts",
54+
"seed:raw:course-awards": "tsx src/data/seedCourseAwards.ts",
5755
"seed:test": "ENV=development tsx src/data/seedTEST.ts"
5856
},
5957
"engines": {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Round registry for the production course-award seed (seedCourseAwards.ts).
3+
*
4+
* A "round" is one batch of externally-earned points and badges handed to
5+
* Klicker from a lecturer workbook. Each round owns its own key, and every
6+
* artefact it produces is namespaced by that key inside the gitignored
7+
* `_local/` directory, so no round can ever replay another round's payload.
8+
*
9+
* Adding a round for a new event:
10+
* 1. Add an entry below with the course ID and the achievement IDs it grants.
11+
* 2. Drop the sanitized payload at `_local/<key>_data.json` (see the shape in
12+
* seedCourseAwards.ts). Never commit it — it contains real usernames.
13+
* 3. Dry-run, review the comparison CSV, then write with DRY_RUN=false.
14+
*
15+
* Achievement IDs are verified against `nameEN` before any write, so a wrong ID
16+
* fails loudly instead of silently granting the wrong badge.
17+
*/
18+
19+
export interface AwardDefinition {
20+
/** Key used in the payload's `awards` array. */
21+
key: string
22+
/** `Achievement.id` in the target database. */
23+
id: number
24+
/** `Achievement.nameEN`, asserted before any write to catch ID drift. */
25+
nameEN: string
26+
}
27+
28+
export interface RoundConfig {
29+
label: string
30+
courseId: string
31+
awards: AwardDefinition[]
32+
/**
33+
* Award key derived from full microlearning completion rather than from the
34+
* workbook, computed by prepareMicrolearningAwards.ts.
35+
*/
36+
derivedAward?: string
37+
/** Completed rounds stay here as worked examples; they are replay-locked. */
38+
completed?: string
39+
}
40+
41+
const SUMMER_SCHOOL_2026_COURSE_ID = '043a156f-c3d4-484a-9b98-bbf7c54b92cc'
42+
43+
export const ROUNDS: Record<string, RoundConfig> = {
44+
summerschool_portfolio: {
45+
label: 'Summer School 2026 portfolio game',
46+
courseId: SUMMER_SCHOOL_2026_COURSE_ID,
47+
awards: [{ key: 'portfolio', id: 21, nameEN: 'Portfolio Professional' }],
48+
completed: '2026-07 — 25,700 points, 3 badges',
49+
},
50+
summerschool_dtp: {
51+
label: 'Summer School 2026 DTP game',
52+
courseId: SUMMER_SCHOOL_2026_COURSE_ID,
53+
awards: [
54+
{ key: 'creative_mastermind', id: 11, nameEN: 'Creative Mastermind' },
55+
{ key: 'shooting_star', id: 16, nameEN: 'Shooting Star' },
56+
{ key: 'happiness', id: 14, nameEN: 'Happiness' },
57+
{ key: 'busy_bee', id: 3, nameEN: 'Busy Bee' },
58+
],
59+
derivedAward: 'busy_bee',
60+
completed: '2026-07-21 — 26,200 points, 37 badges across 36 participants',
61+
},
62+
summerschool_shootingstar: {
63+
label: 'Summer School 2026 Shooting Star follow-up',
64+
courseId: SUMMER_SCHOOL_2026_COURSE_ID,
65+
awards: [{ key: 'shooting_star', id: 16, nameEN: 'Shooting Star' }],
66+
completed: '2026-07-21 — badge-only, 3 participants, zero point delta',
67+
},
68+
}
69+
70+
export function resolveRound(): { key: string; config: RoundConfig } {
71+
const key = process.env.ROUND
72+
if (!key) {
73+
throw new Error(
74+
`ROUND is required. Available rounds: ${Object.keys(ROUNDS).join(', ')}`
75+
)
76+
}
77+
78+
const config = ROUNDS[key]
79+
if (!config) {
80+
throw new Error(
81+
`Unknown round "${key}". Available rounds: ${Object.keys(ROUNDS).join(', ')}`
82+
)
83+
}
84+
85+
return { key, config }
86+
}
87+
88+
/** Every artefact of a round lives in the gitignored `_local/` directory. */
89+
export function roundFile(key: string, suffix: string): URL {
90+
return new URL(`_local/${key}_${suffix}`, import.meta.url)
91+
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
/**
2+
* Optional pre-step for seedCourseAwards.ts: derives the round's `derivedAward`
3+
* from in-platform behaviour instead of from the lecturer workbook.
4+
*
5+
* Rule: a participant qualifies when they answered every element instance of
6+
* every non-deleted microlearning in the course ("completed all microlearnings").
7+
*
8+
* Completion is read from QuestionResponse, never from
9+
* ParticipantActivityPerformance.completion or MicroLearning.completedCount /
10+
* startedCount: for the Summer School 2026 course all three are empty (zero rows,
11+
* zero counters) even though responses exist, so they derive zero awards silently
12+
* instead of failing.
13+
*
14+
* Reads the gitignored `_local/<round>_base.json` (workbook-derived rows without
15+
* the derived award) and writes `_local/<round>_data.json` for the seed. Executes
16+
* zero database writes; freezing the result into the payload keeps the seed's
17+
* payload-hash replay safety intact.
18+
*
19+
* Usage:
20+
* ROUND=<key> pnpm --filter @klicker-uzh/prisma-data seed:prod:course-awards:prepare
21+
*/
22+
import { prisma } from '@klicker-uzh/prisma'
23+
import fs from 'node:fs'
24+
import { resolveRound, roundFile } from './courseAwardRounds.js'
25+
26+
interface BaseEntry {
27+
username: string
28+
points?: number
29+
awards?: string[]
30+
}
31+
32+
const { key: ROUND, config } = resolveRound()
33+
const baseUrl = roundFile(ROUND, 'base.json')
34+
const outputUrl = roundFile(ROUND, 'data.json')
35+
36+
async function main() {
37+
const derivedAward = config.derivedAward
38+
if (!derivedAward) {
39+
throw new Error(`Round ${ROUND} declares no derivedAward`)
40+
}
41+
if (!config.awards.some((award) => award.key === derivedAward)) {
42+
throw new Error(
43+
`Round ${ROUND} derives ${derivedAward}, which is not one of its awards`
44+
)
45+
}
46+
47+
const base: BaseEntry[] = JSON.parse(fs.readFileSync(baseUrl, 'utf-8'))
48+
console.log(`Base payload entries: ${base.length}`)
49+
50+
const microLearnings = await prisma.microLearning.findMany({
51+
where: { courseId: config.courseId, isDeleted: false },
52+
select: {
53+
id: true,
54+
name: true,
55+
status: true,
56+
stacks: { select: { elements: { select: { id: true } } } },
57+
},
58+
orderBy: { scheduledStartAt: 'asc' },
59+
})
60+
61+
if (microLearnings.length === 0) {
62+
throw new Error(`Course ${config.courseId} has no microlearnings`)
63+
}
64+
65+
const required = microLearnings.map((microLearning) => {
66+
const instanceIds = microLearning.stacks.flatMap((stack) =>
67+
stack.elements.map((element) => element.id)
68+
)
69+
if (instanceIds.length === 0) {
70+
throw new Error(
71+
`Microlearning ${microLearning.name} has no element instances`
72+
)
73+
}
74+
return { ...microLearning, instanceIds }
75+
})
76+
77+
console.log(
78+
`\nMicrolearnings in course ${config.courseId}: ${required.length}`
79+
)
80+
for (const microLearning of required) {
81+
console.log(
82+
` ${microLearning.name} | ${microLearning.status} | ${microLearning.instanceIds.length} instances`
83+
)
84+
}
85+
86+
const participants = await prisma.participant.findMany({
87+
where: {
88+
OR: base.map((entry) => ({
89+
username: { equals: entry.username, mode: 'insensitive' as const },
90+
})),
91+
},
92+
select: { id: true, username: true },
93+
})
94+
95+
const byUsername = new Map<string, typeof participants>()
96+
for (const participant of participants) {
97+
const key = participant.username.toLocaleLowerCase('en-US')
98+
byUsername.set(key, [...(byUsername.get(key) ?? []), participant])
99+
}
100+
101+
const resolved = base.map((entry) => {
102+
const candidates = byUsername.get(entry.username.toLocaleLowerCase('en-US'))
103+
const candidate = candidates?.[0]
104+
if (candidates?.length !== 1 || !candidate) {
105+
throw new Error(
106+
`Expected exactly one participant match for ${entry.username}, found ${candidates?.length ?? 0}`
107+
)
108+
}
109+
return { ...entry, participantId: candidate.id }
110+
})
111+
112+
const responses = await prisma.questionResponse.findMany({
113+
where: {
114+
microLearningId: { in: required.map((item) => item.id) },
115+
participantId: { in: resolved.map((entry) => entry.participantId) },
116+
},
117+
select: {
118+
participantId: true,
119+
microLearningId: true,
120+
elementInstanceId: true,
121+
},
122+
})
123+
124+
const answered = new Map<string, Set<number>>()
125+
for (const response of responses) {
126+
const key = `${response.participantId}:${response.microLearningId}`
127+
const set = answered.get(key) ?? new Set<number>()
128+
set.add(response.elementInstanceId)
129+
answered.set(key, set)
130+
}
131+
132+
const completedPerMicroLearning = new Map<string, number>()
133+
const histogram = new Map<number, number>()
134+
135+
const entries = resolved.map((entry) => {
136+
const completed = required.filter((microLearning) => {
137+
const set =
138+
answered.get(`${entry.participantId}:${microLearning.id}`) ??
139+
new Set<number>()
140+
return microLearning.instanceIds.every((id) => set.has(id))
141+
})
142+
143+
for (const microLearning of completed) {
144+
completedPerMicroLearning.set(
145+
microLearning.name,
146+
(completedPerMicroLearning.get(microLearning.name) ?? 0) + 1
147+
)
148+
}
149+
histogram.set(completed.length, (histogram.get(completed.length) ?? 0) + 1)
150+
151+
const { participantId: _participantId, ...rest } = entry
152+
const awards = (rest.awards ?? []).filter((award) => award !== derivedAward)
153+
return {
154+
...rest,
155+
awards:
156+
completed.length === required.length
157+
? [...awards, derivedAward]
158+
: awards,
159+
}
160+
})
161+
162+
console.log('\nFully completed, per microlearning:')
163+
for (const microLearning of required) {
164+
console.log(
165+
` ${microLearning.name}: ${completedPerMicroLearning.get(microLearning.name) ?? 0} of ${entries.length}`
166+
)
167+
}
168+
console.log('Fully completed microlearnings per participant:')
169+
for (const [completed, count] of [...histogram].sort((a, b) => a[0] - b[0])) {
170+
console.log(` ${completed}/${required.length}: ${count} participants`)
171+
}
172+
173+
const derivedCount = entries.filter((entry) =>
174+
entry.awards.includes(derivedAward)
175+
).length
176+
console.log(`\n${derivedAward} awards: ${derivedCount}`)
177+
console.log(
178+
`Point delta: ${entries.reduce((sum, entry) => sum + (entry.points ?? 0), 0)}`
179+
)
180+
for (const award of config.awards) {
181+
console.log(
182+
`${award.key}: ${entries.filter((entry) => entry.awards.includes(award.key)).length}`
183+
)
184+
}
185+
186+
fs.mkdirSync(new URL('.', outputUrl), { recursive: true })
187+
fs.writeFileSync(outputUrl, `${JSON.stringify(entries, null, 2)}\n`)
188+
console.log(`\nSeed payload written: ${outputUrl.pathname}`)
189+
}
190+
191+
main()
192+
.catch((error) => {
193+
console.error(error)
194+
process.exitCode = 1
195+
})
196+
.finally(async () => {
197+
await prisma.$disconnect()
198+
})

0 commit comments

Comments
 (0)