finish srs system

This commit is contained in:
Rene Kievits
2025-10-27 05:45:38 +01:00
parent 882328c28e
commit 150667f781
19 changed files with 652 additions and 1011 deletions

View File

@@ -27,7 +27,7 @@ function createRefreshToken(user: any) {
}
router.post('/login', async (req: Request, res: Response) => {
const { email, username, password } = req.body
const { username, password, remember } = req.body
if (!username || !password) return res.status(400).json({ error: 'Missing credentials' })
try {
@@ -40,7 +40,7 @@ router.post('/login', async (req: Request, res: Response) => {
user = await UserModel.create({
username: ldapUser.user.cn,
email: ldapUser.user.dn,
refresh_token: '',
refreshToken: '',
})
}
@@ -51,10 +51,12 @@ router.post('/login', async (req: Request, res: Response) => {
await user.save()
res.cookie('access_token', accessToken, {
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: 15 * 60 * 1000,
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: 7 * 24 * 60 * 60 * 1000,
})
res.cookie('refresh_token', refreshToken, {
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: 7 * 24 * 3600 * 1000,
const refreshMaxAge = remember > 7 ? 365 * 24 * 60 * 60 * 1000 : 7 * 24 * 60 * 60 * 1000
res.cookie('refreshToken', refreshToken, {
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: refreshMaxAge,
})
res.json({
@@ -72,12 +74,14 @@ router.post('/login', async (req: Request, res: Response) => {
})
router.post('/refresh', async (req: Request, res: Response) => {
const token = req.cookies.refresh_token
const token = req.cookies.refreshToken
if (!token) return res.status(401).json({ error: 'No refresh token' })
try {
const payload = jwt.verify(token, REFRESH_TOKEN_SECRET)
const payload = jwt.verify(token, REFRESH_TOKEN_SECRET) as any
const user = await UserModel.findById(payload.sub)
if (!user || !user.refreshToken === token) return res.status(403).json({ error: 'Invalid refresh token' })
if (!user || user.refreshToken !== token)
return res.status(403).json({ error: 'Invalid refresh token' })
const newAccessToken = createAccessToken(user)
const newRefreshToken = createRefreshToken(user)
@@ -85,20 +89,33 @@ router.post('/refresh', async (req: Request, res: Response) => {
user.refreshToken = newRefreshToken
await user.save()
const existingRefreshCookie = req.cookies.refreshToken
const decodedOld = jwt.decode(existingRefreshCookie) as any
const remainingDays = (decodedOld.exp * 1000 - Date.now()) / (1000 * 60 * 60 * 24)
const refreshMaxAge = remainingDays > 7 ? 365 * 24 * 60 * 60 * 1000 : 7 * 24 * 60 * 60 * 1000
res.cookie('access_token', newAccessToken, {
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: 15 * 60 * 1000,
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV !== 'dev',
maxAge: 15 * 60 * 1000,
})
res.cookie('refresh_token', newRefreshToken, {
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: 7 * 24 * 3600 * 1000,
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV !== 'dev',
maxAge: refreshMaxAge,
})
res.json({ ok: true })
return res.json({ ok: true })
} catch (error) {
res.status(401).json({ error: 'Invalid refresh token' })
return res.status(401).json({ error: 'Invalid refresh token' })
}
})
router.post('/logout', async (req: Request, res: Response) => {
const token = req.cookies.refresh_token
const token = req.cookies.refreshToken
if (token) {
try {
const payload = jwt.verify(token, REFRESH_TOKEN_SECRET)
@@ -110,7 +127,7 @@ router.post('/logout', async (req: Request, res: Response) => {
} catch { }
}
res.clearCookie('access_token')
res.clearCookie('refresh_token')
res.clearCookie('refreshToken')
res.json({ loggedOut: true })
})

View File

@@ -27,15 +27,33 @@ router.post('/', verifyAccessToken, async (req: AuthRequest, res: Response) => {
possibleModes.push({ key: 'writing_practice', type: 'writing_practice' })
}
if (subjectOptions.direction === 'jp->en') {
const modes = Array.isArray(subjectOptions.mode) ? subjectOptions.mode : [subjectOptions.mode]
if (modes.includes('meaning')) possibleModes.push({ key: 'jp_en_meaning', type: 'jp_en_meaning' })
if (modes.includes('reading')) possibleModes.push({ key: 'jp_en_reading', type: 'jp_en_reading' })
}
const directions = Array.isArray(subjectOptions.direction)
? subjectOptions.direction
: subjectOptions.direction === 'both'
? ['jp->en', 'en->jp']
: [subjectOptions.direction]
if (subjectOptions.direction === 'en->jp') {
if (subjectOptions.mode === 'writing' || subjectOptions.writingMode) {
possibleModes.push({ key: 'en_jp_writing', type: 'en_jp_writing' })
for (const dir of directions) {
if (dir === 'jp->en') {
const modes = Array.isArray(subjectOptions.mode)
? subjectOptions.mode
: [subjectOptions.mode]
if (modes.includes('meaning')) {
possibleModes.push({ key: 'jp_en_meaning', type: 'jp_en_meaning' })
}
if (modes.includes('reading')) {
possibleModes.push({ key: 'jp_en_reading', type: 'jp_en_reading' })
}
}
if (dir === 'en->jp') {
const modes = Array.isArray(subjectOptions.mode)
? subjectOptions.mode
: [subjectOptions.mode]
if (!subjectOptions.meaning && (modes.includes('reading'))) {
possibleModes.push({ key: 'en_jp_writing', type: 'en_jp_writing' })
}
}
}
@@ -89,7 +107,7 @@ router.post('/correct', verifyAccessToken, async (req: AuthRequest, res: Respons
if (req.body.currentSubjectOptions.writingPractice) {
key = 'srs.writing_practice'
} else if (req.body.currentSubjectOptions.direction === 'jp->en') {
key = req.body.currentSubjectOptions.mode === 'reading' ? 'srs.jp_en_reading' : 'srs.jp_en_meaning'
key = req.body.currentSubjectOptions.mode === 'jp_en_reading' ? 'srs.jp_en_reading' : 'srs.jp_en_meaning'
} else {
key = 'srs.en_jp_writing'
}
@@ -155,23 +173,42 @@ router.get('/stats', verifyAccessToken, async (req: AuthRequest, res: Response)
const now = new Date()
// Total counts
const totalKanji = await ReviewItemModel.countDocuments({ userId, type: 'kanji' })
const totalVocab = await ReviewItemModel.countDocuments({ userId, type: 'vocab' })
// Any of the SRS fields that are due
const dueConditions = [
{ 'srs.jp_en_meaning.next_review': { $lte: now } },
{ 'srs.jp_en_reading.next_review': { $lte: now } },
{ 'srs.en_jp_writing.next_review': { $lte: now } },
{ 'srs.writing_practice.next_review': { $lte: now } },
]
const dueItems = await ReviewItemModel.countDocuments({
userId,
srs_next_review: { $lte: now },
$or: dueConditions,
})
// Waiting items = have next_review set to the future in any field
const waitingConditions = [
{ 'srs.jp_en_meaning.next_review': { $gt: now } },
{ 'srs.jp_en_reading.next_review': { $gt: now } },
{ 'srs.en_jp_writing.next_review': { $gt: now } },
{ 'srs.writing_practice.next_review': { $gt: now } },
]
const waitingItems = await ReviewItemModel.countDocuments({
userId,
srs_next_review: { $gt: now },
$or: waitingConditions,
})
// Average SRS score per type (optional — kept from original)
const kanjiAgg = await ReviewItemModel.aggregate([
{ $match: { userId, type: 'kanji' } },
{ $group: { _id: null, avgScore: { $avg: '$srs_score' } } },
])
const vocabAgg = await ReviewItemModel.aggregate([
{ $match: { userId, type: 'vocab' } },
{ $group: { _id: null, avgScore: { $avg: '$srs_score' } } },

View File

@@ -1,17 +0,0 @@
import express, { type Router, type Request, type Response } from 'express'
import { KanjiModel } from '../../../models/kanji.model.ts'
const router = express.Router()
router.get('/', async (_req: Request, res: Response) => {
try {
const doc = await KanjiModel.find()
res.json(doc)
} catch (error) {
console.error('Error fetching Kanji Subjects', error)
res.status(500).json({ error: 'Failed to fetch Kanji Subjects' })
}
})
export default router as Router

View File

@@ -1,18 +0,0 @@
import express, { type Router, type Request, type Response } from 'express'
import { VocabularyModel } from '../../../models/vocabulary.model.ts'
const router = express.Router()
router.get('/', async (_req: Request, res: Response) => {
try {
const doc = await VocabularyModel.find()
res.json(doc)
} catch (error) {
console.error('Error fetching API key:', error)
res.status(500).json({ error: 'Failed to fetch API key' })
}
})
export default router as Router

View File

@@ -11,8 +11,6 @@ import { connectMongo } from './db/connect.ts'
import keyRouter from './api/v1/key/index.ts'
import syncRouter from './api/v1/wanikani/sync.ts'
import kanjiRouter from './api/v1/subject/kanji.ts'
import vocabRouter from './api/v1/subject/vocab.ts'
import authRouter from './api/v1/auth/index.ts'
import userRoutes from './api/v1/user/index.ts'
import subjectRoutes from './api/v1/subject/index.ts'
@@ -56,11 +54,8 @@ if (process.env.NODE_ENV === 'production') {
})
)
}
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }))
app.use('/api/v1/key', verifyAccessToken, keyRouter)
app.use('/api/v1/subject/kanji', verifyAccessToken, kanjiRouter)
app.use('/api/v1/subject/vocab', verifyAccessToken, vocabRouter)
app.use('/api/v1/user', verifyAccessToken, userRoutes)
app.use('/api/v1/wanikani', verifyAccessToken, syncRouter)
app.use('/api/v1/auth', authRouter)

View File

@@ -1,15 +0,0 @@
import mongoose from 'mongoose'
import type { KanjiItem } from '../types/wanikani.ts'
const KanjiSchema = new mongoose.Schema<KanjiItem>({
userId: { type: String, required: true },
characters: String,
meanings: Array,
readings: Array,
auxiliary_meanings: Array,
level: Number,
slug: String,
srs_score: Number,
})
export const KanjiModel = mongoose.model('Kanji', KanjiSchema)

View File

@@ -1,16 +0,0 @@
import mongoose from 'mongoose'
import type { VocabularyItem } from '../types/wanikani.ts'
const VocabSchema = new mongoose.Schema<VocabularyItem>({
userId: { type: String, required: true },
characters: String,
meanings: Array,
readings: Array,
auxiliary_meanings: Array,
pronunciation_audios: Array,
level: Number,
slug: String,
srs_score: Number,
})
export const VocabularyModel = mongoose.model('Vocabulary', VocabSchema)