Persist learn progress per user

This commit is contained in:
Remi
2025-09-18 23:28:02 +02:00
committed by itsrubberduck
parent b6785e9f05
commit 6dbfe07d2b
6 changed files with 231 additions and 3 deletions

View File

@@ -0,0 +1,21 @@
import { defineNuxtRouteMiddleware, navigateTo } from '#app'
import { useAuthStore } from '~/stores/auth'
export default defineNuxtRouteMiddleware(async (to) => {
const auth = useAuthStore()
if (!auth.accessToken) {
await auth.tryRefresh().catch(() => false)
}
if (!auth.initialized) {
await auth.fetchUser().catch(() => null)
} else if (!auth.user) {
await auth.fetchUser().catch(() => null)
}
if (!auth.user) {
const redirect = encodeURIComponent(to.fullPath || '/')
return navigateTo(`/login?redirect=${redirect}`)
}
})

View File

@@ -8,7 +8,7 @@ import {normalize, TTS_MODEL, normalizeATC} from "../../utils/normalize";
import { getServerRuntimeConfig } from "../../utils/runtimeConfig";
import {request} from "node:http";
import { TransmissionLog } from "../../models/TransmissionLog";
import { getUserFromEvent } from "../../utils/auth";
import { requireUserSession } from "../../utils/auth";
function outDir() {
@@ -125,6 +125,8 @@ export default defineEventHandler(async (event) => {
format?: AudioFmt | "smallest";
}>(event);
const user = await requireUserSession(event);
const raw = (body?.text || "").trim();
if (!raw) throw createError({ statusCode: 400, statusMessage: "text required" });
@@ -212,9 +214,8 @@ export default defineEventHandler(async (event) => {
// await writeFile(fileJson, JSON.stringify(meta, null, 2), "utf-8");
try {
const user = await getUserFromEvent(event)
await TransmissionLog.create({
user: user?._id,
user: user._id,
role: "atc",
channel: "say",
direction: "outgoing",

View File

@@ -0,0 +1,24 @@
import { requireUserSession } from '../../utils/auth'
import { LearnProfile } from '../../models/LearnProfile'
import { createDefaultLearnConfig, createDefaultLearnState } from '~~/shared/learn/config'
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event)
const profile = await LearnProfile.findOne({ user: user._id })
if (!profile) {
return createDefaultLearnState()
}
const config = { ...createDefaultLearnConfig(), ...(profile.config || {}) }
const progress =
profile.progress && typeof profile.progress === 'object'
? JSON.parse(JSON.stringify(profile.progress))
: {}
return {
xp: typeof profile.xp === 'number' ? profile.xp : 0,
progress,
config,
}
})

View File

@@ -0,0 +1,110 @@
import { readBody } from 'h3'
import { requireUserSession } from '../../utils/auth'
import { LearnProfile } from '../../models/LearnProfile'
import type { LearnConfig, LearnProgress, LessonProgress } from '~~/shared/learn/config'
import { createDefaultLearnConfig } from '~~/shared/learn/config'
interface LearnStateUpdateBody {
xp?: number
progress?: LearnProgress
config?: Partial<LearnConfig>
}
function sanitizeProgress(input: LearnProgress | undefined): LearnProgress | undefined {
if (!input || typeof input !== 'object') {
return undefined
}
const sanitized: LearnProgress = {}
for (const [moduleId, lessons] of Object.entries(input)) {
if (!lessons || typeof lessons !== 'object') {
continue
}
const lessonProgress: Record<string, LessonProgress> = {}
for (const [lessonId, value] of Object.entries(lessons as Record<string, any>)) {
if (!value || typeof value !== 'object') {
continue
}
const bestRaw = (value as any).best
const doneRaw = (value as any).done
const best = typeof bestRaw === 'number' && Number.isFinite(bestRaw) ? Math.max(0, Math.min(100, Math.round(bestRaw))) : 0
const done = Boolean(doneRaw)
lessonProgress[lessonId] = { best, done }
}
sanitized[moduleId] = lessonProgress
}
return sanitized
}
function sanitizeConfig(input: Partial<LearnConfig> | undefined): LearnConfig | undefined {
if (!input || typeof input !== 'object') {
return undefined
}
const config = createDefaultLearnConfig()
if (typeof input.tts === 'boolean') {
config.tts = input.tts
}
if (typeof input.audioChallenge === 'boolean') {
config.audioChallenge = input.audioChallenge
}
if (typeof input.radioLevel === 'number' && Number.isFinite(input.radioLevel)) {
const level = Math.round(input.radioLevel)
config.radioLevel = Math.min(5, Math.max(1, level))
}
if (typeof input.voice === 'string') {
config.voice = input.voice.slice(0, 120)
}
return config
}
export default defineEventHandler(async (event) => {
const user = await requireUserSession(event)
const body = await readBody<LearnStateUpdateBody>(event)
let profile = await LearnProfile.findOne({ user: user._id })
if (!profile) {
profile = new LearnProfile({ user: user._id })
}
if (typeof body.xp === 'number' && Number.isFinite(body.xp)) {
profile.xp = Math.max(0, Math.round(body.xp))
}
const progress = sanitizeProgress(body.progress)
if (progress !== undefined) {
profile.progress = progress
}
const config = sanitizeConfig(body.config)
if (config !== undefined) {
profile.config = config
}
await profile.save()
const responseConfig = { ...createDefaultLearnConfig(), ...(profile.config || {}) }
const responseProgress =
profile.progress && typeof profile.progress === 'object'
? JSON.parse(JSON.stringify(profile.progress))
: {}
return {
xp: typeof profile.xp === 'number' ? profile.xp : 0,
progress: responseProgress,
config: responseConfig,
}
})

View File

@@ -0,0 +1,34 @@
import mongoose from 'mongoose'
import type { LearnConfig, LearnProgress } from '~~/shared/learn/config'
export interface LearnProfileDocument extends mongoose.Document {
user: mongoose.Types.ObjectId
xp: number
progress: LearnProgress
config: LearnConfig
createdAt: Date
updatedAt: Date
}
const learnProfileSchema = new mongoose.Schema<LearnProfileDocument>(
{
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, unique: true },
xp: { type: Number, default: 0 },
progress: { type: mongoose.Schema.Types.Mixed, default: () => ({}) },
config: {
tts: { type: Boolean, default: false },
radioLevel: { type: Number, default: 4, min: 1, max: 5 },
voice: { type: String, default: '', trim: true },
audioChallenge: { type: Boolean, default: false },
},
},
{
timestamps: true,
},
)
learnProfileSchema.index({ user: 1 }, { unique: true })
export const LearnProfile =
(mongoose.models.LearnProfile as mongoose.Model<LearnProfileDocument> | undefined) ||
mongoose.model<LearnProfileDocument>('LearnProfile', learnProfileSchema)

38
shared/learn/config.ts Normal file
View File

@@ -0,0 +1,38 @@
export interface LessonProgress {
best: number
done: boolean
}
export type LearnProgress = Record<string, Record<string, LessonProgress>>
export interface LearnConfig {
tts: boolean
radioLevel: number
voice: string
audioChallenge: boolean
}
export interface LearnState {
xp: number
progress: LearnProgress
config: LearnConfig
}
export const LEARN_CONFIG_DEFAULTS: LearnConfig = {
tts: false,
radioLevel: 4,
voice: '',
audioChallenge: false,
}
export function createDefaultLearnConfig(): LearnConfig {
return { ...LEARN_CONFIG_DEFAULTS }
}
export function createDefaultLearnState(): LearnState {
return {
xp: 0,
progress: {} as LearnProgress,
config: createDefaultLearnConfig(),
}
}