diff --git a/app/middleware/require-auth.ts b/app/middleware/require-auth.ts new file mode 100644 index 0000000..0859a5e --- /dev/null +++ b/app/middleware/require-auth.ts @@ -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}`) + } +}) diff --git a/server/api/atc/say.post.ts b/server/api/atc/say.post.ts index afa3abe..0cc04a3 100644 --- a/server/api/atc/say.post.ts +++ b/server/api/atc/say.post.ts @@ -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", diff --git a/server/api/learn/state.get.ts b/server/api/learn/state.get.ts new file mode 100644 index 0000000..470d786 --- /dev/null +++ b/server/api/learn/state.get.ts @@ -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, + } +}) diff --git a/server/api/learn/state.put.ts b/server/api/learn/state.put.ts new file mode 100644 index 0000000..3927181 --- /dev/null +++ b/server/api/learn/state.put.ts @@ -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 +} + +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 = {} + + for (const [lessonId, value] of Object.entries(lessons as Record)) { + 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 | 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(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, + } +}) diff --git a/server/models/LearnProfile.ts b/server/models/LearnProfile.ts new file mode 100644 index 0000000..3c60165 --- /dev/null +++ b/server/models/LearnProfile.ts @@ -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( + { + 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 | undefined) || + mongoose.model('LearnProfile', learnProfileSchema) diff --git a/shared/learn/config.ts b/shared/learn/config.ts new file mode 100644 index 0000000..c924e8b --- /dev/null +++ b/shared/learn/config.ts @@ -0,0 +1,38 @@ +export interface LessonProgress { + best: number + done: boolean +} + +export type LearnProgress = Record> + +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(), + } +}