feat: add storage management features including user storage quota and usage tracking
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 24s

- Updated database schema to include `storage_quota_bytes` and `plan` for users.
- Implemented storage usage calculations in `storageRepo` and added tests for storage functionality.
- Created `StorageManager` component to display and manage user storage.
- Enhanced user settings to show storage details and warnings for high usage.
- Added translations for new storage-related strings in English and Dutch.
- Updated styles for storage overview and details in CSS.
This commit is contained in:
LaurensBartelds committed 2026-04-27 15:29:43 +02:00
1 parent c002f42459
commit acbcfde617
15 files changed
+694 -14

No files matched your search

+11 -5
View File
@@ -4,17 +4,23 @@ import path from 'path'
import { config } from '../config.js'
import { runMigrations } from './migrations.js'
const DB_PATH = path.isAbsolute(config.dbPath)
? config.dbPath
: path.join(process.cwd(), config.dbPath)
const DB_PATH = config.dbPath === ':memory:'
? ':memory:'
: path.isAbsolute(config.dbPath)
? config.dbPath
: path.join(process.cwd(), config.dbPath)
let db: Database.Database | null = null
export function getDb(): Database.Database {
if (!db) {
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
if (DB_PATH !== ':memory:') {
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
}
db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
if (DB_PATH !== ':memory:') {
db.pragma('journal_mode = WAL')
}
db.pragma('foreign_keys = ON')
runMigrations(db)
}
+10
View File
@@ -61,6 +61,16 @@ export function runMigrations(db: Database.Database): void {
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`);
}
// ── Add storage_quota_bytes if missing ──
if (!cols.some((c) => c.name === "storage_quota_bytes")) {
db.exec(`ALTER TABLE users ADD COLUMN storage_quota_bytes INTEGER NOT NULL DEFAULT 1073741824`);
}
// ── Add plan if missing ──
if (!cols.some((c) => c.name === "plan")) {
db.exec(`ALTER TABLE users ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'`);
}
// ── Promote first user to admin if no admins exist ──
const adminCount = db
.prepare(`SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'`)
@@ -0,0 +1,157 @@
import { describe, it, expect } from 'vitest'
// Set env vars before any module that reads them at import time
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-for-unit-tests-only'
process.env.DB_PATH = ':memory:'
// Dynamic imports ensure env vars are set before module initialisation
const { getDb, closeDb } = await import('../db/database.js')
const userRepo = await import('./userRepo.js')
const storageRepo = await import('./storageRepo.js')
describe('storageRepo', () => {
it('returns zero usage for a new user', () => {
const { id: userId } = userRepo.createUser({
email: 'storage-test@example.com',
passwordHash: 'hash',
displayName: 'Storage Test',
emailVerified: 1,
})
const usage = storageRepo.getUserStorageUsage(userId)
expect(usage.usedBytes).toBe(0)
expect(usage.examBytes).toBe(0)
expect(usage.resultBytes).toBe(0)
})
it('calculates exam JSON length correctly', () => {
const { id: userId } = userRepo.createUser({
email: 'storage-exam@example.com',
passwordHash: 'hash',
displayName: 'Exam Storage',
emailVerified: 1,
})
const db = getDb()
const examJson = JSON.stringify({ title: 'Test Exam', questions: [] })
db.prepare(`
INSERT INTO saved_exams (user_id, title, exam_json)
VALUES (?, ?, ?)
`).run(userId, 'Test Exam', examJson)
const usage = storageRepo.getUserStorageUsage(userId)
expect(usage.examBytes).toBe(examJson.length)
expect(usage.usedBytes).toBe(examJson.length)
})
it('calculates result JSON length correctly', () => {
const { id: userId } = userRepo.createUser({
email: 'storage-result@example.com',
passwordHash: 'hash',
displayName: 'Result Storage',
emailVerified: 1,
})
const db = getDb()
const stateJson = JSON.stringify({ answers: [1, 2, 3] })
const examJson = JSON.stringify({ title: 'Result Exam' })
db.prepare(`
INSERT INTO exam_results
(user_id, title, score_points, total_points, correct_count, graded_count, elapsed_ms, state_json, exam_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(userId, 'Result Exam', 10, 20, 5, 10, 60000, stateJson, examJson)
const usage = storageRepo.getUserStorageUsage(userId)
expect(usage.resultBytes).toBe(stateJson.length + examJson.length)
expect(usage.usedBytes).toBe(stateJson.length + examJson.length)
})
it('aggregates multiple exams and results', () => {
const { id: userId } = userRepo.createUser({
email: 'storage-multi@example.com',
passwordHash: 'hash',
displayName: 'Multi Storage',
emailVerified: 1,
})
const db = getDb()
const examJson1 = JSON.stringify({ title: 'Exam 1' })
const examJson2 = JSON.stringify({ title: 'Exam 2', extra: 'data' })
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
.run(userId, 'Exam 1', examJson1)
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
.run(userId, 'Exam 2', examJson2)
const stateJson = JSON.stringify({ answers: [] })
db.prepare(`
INSERT INTO exam_results
(user_id, title, score_points, total_points, correct_count, graded_count, elapsed_ms, state_json, exam_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(userId, 'Result', 5, 10, 3, 5, 30000, stateJson, examJson1)
const usage = storageRepo.getUserStorageUsage(userId)
expect(usage.examBytes).toBe(examJson1.length + examJson2.length)
expect(usage.resultBytes).toBe(stateJson.length + examJson1.length)
expect(usage.usedBytes).toBe(examJson1.length + examJson2.length + stateJson.length + examJson1.length)
})
it('lists storage items sorted by size desc', () => {
const { id: userId } = userRepo.createUser({
email: 'storage-items@example.com',
passwordHash: 'hash',
displayName: 'Items Storage',
emailVerified: 1,
})
const db = getDb()
const smallExam = JSON.stringify({ title: 'Small' })
const largeExam = JSON.stringify({ title: 'Large Exam', content: 'x'.repeat(100) })
const stateJson = JSON.stringify({ a: 'y'.repeat(50) })
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
.run(userId, 'Small Exam', smallExam)
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
.run(userId, 'Large Exam', largeExam)
db.prepare(`
INSERT INTO exam_results
(user_id, title, score_points, total_points, correct_count, graded_count, elapsed_ms, state_json, exam_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(userId, 'Result', 5, 10, 3, 5, 30000, stateJson, smallExam)
const items = storageRepo.listStorageItems(userId)
expect(items.length).toBe(3)
expect(items[0].title).toBe('Large Exam')
expect(items[0].type).toBe('exam')
expect(items[1].title).toBe('Result')
expect(items[1].type).toBe('result')
expect(items[2].title).toBe('Small Exam')
expect(items[2].type).toBe('exam')
})
it('does not include other users data', () => {
const { id: userA } = userRepo.createUser({
email: 'user-a@example.com',
passwordHash: 'hash',
displayName: 'User A',
emailVerified: 1,
})
const { id: userB } = userRepo.createUser({
email: 'user-b@example.com',
passwordHash: 'hash',
displayName: 'User B',
emailVerified: 1,
})
const db = getDb()
const examJson = JSON.stringify({ title: 'A Exam' })
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
.run(userA, 'A Exam', examJson)
const usageA = storageRepo.getUserStorageUsage(userA)
const usageB = storageRepo.getUserStorageUsage(userB)
expect(usageA.usedBytes).toBe(examJson.length)
expect(usageB.usedBytes).toBe(0)
})
})
+61
View File
@@ -0,0 +1,61 @@
import { getDb } from '../db/database.js'
export interface StorageUsage {
usedBytes: number
examBytes: number
resultBytes: number
}
export interface StorageItem {
id: number
title: string
type: 'exam' | 'result'
sizeBytes: number
createdAt: number
}
export function getUserStorageUsage(userId: number): StorageUsage {
const db = getDb()
const examRow = db.prepare(`
SELECT COALESCE(SUM(LENGTH(exam_json)), 0) AS total
FROM saved_exams
WHERE user_id = ?
`).get(userId) as { total: number }
const resultRow = db.prepare(`
SELECT COALESCE(SUM(LENGTH(state_json) + LENGTH(exam_json)), 0) AS total
FROM exam_results
WHERE user_id = ?
`).get(userId) as { total: number }
return {
usedBytes: examRow.total + resultRow.total,
examBytes: examRow.total,
resultBytes: resultRow.total,
}
}
export function listStorageItems(userId: number): StorageItem[] {
const db = getDb()
const exams = db.prepare(`
SELECT id, title, LENGTH(exam_json) AS size_bytes, created_at
FROM saved_exams
WHERE user_id = ?
ORDER BY size_bytes DESC
`).all(userId) as { id: number; title: string; size_bytes: number; created_at: number }[]
const results = db.prepare(`
SELECT id, title, LENGTH(state_json) + LENGTH(exam_json) AS size_bytes, completed_at AS created_at
FROM exam_results
WHERE user_id = ?
ORDER BY size_bytes DESC
`).all(userId) as { id: number; title: string; size_bytes: number; created_at: number }[]
const items: StorageItem[] = [
...exams.map((e) => ({ id: e.id, title: e.title, type: 'exam' as const, sizeBytes: e.size_bytes, createdAt: e.created_at })),
...results.map((r) => ({ id: r.id, title: r.title, type: 'result' as const, sizeBytes: r.size_bytes, createdAt: r.created_at })),
]
items.sort((a, b) => b.sizeBytes - a.sizeBytes)
return items
}
+19 -3
View File
@@ -11,6 +11,8 @@ export interface DbUser {
verify_expires: number | null
reset_token: string | null
reset_expires: number | null
storage_quota_bytes: number
plan: string
created_at: number
updated_at: number
}
@@ -23,6 +25,8 @@ export interface CreateUserData {
emailVerified?: number
verifyToken?: string | null
verifyExpires?: number | null
storageQuotaBytes?: number
plan?: string
}
export interface UpdateUserData {
@@ -34,13 +38,15 @@ export interface UpdateUserData {
verifyExpires?: number | null
resetToken?: string | null
resetExpires?: number | null
storageQuotaBytes?: number
plan?: string
}
export function createUser(data: CreateUserData): { id: number } {
const db = getDb()
const result = db.prepare(`
INSERT INTO users (email, password_hash, display_name, role, email_verified, verify_token, verify_expires)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (email, password_hash, display_name, role, email_verified, verify_token, verify_expires, storage_quota_bytes, plan)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
data.email.toLowerCase().trim(),
data.passwordHash,
@@ -48,7 +54,9 @@ export function createUser(data: CreateUserData): { id: number } {
data.role ?? 'user',
data.emailVerified ?? 1,
data.verifyToken ?? null,
data.verifyExpires ?? null
data.verifyExpires ?? null,
data.storageQuotaBytes ?? 1073741824,
data.plan ?? 'free'
)
return { id: result.lastInsertRowid as number }
}
@@ -110,6 +118,14 @@ export function updateUser(id: number, data: UpdateUserData): boolean {
fields.push('reset_expires = ?')
values.push(data.resetExpires)
}
if (data.storageQuotaBytes !== undefined) {
fields.push('storage_quota_bytes = ?')
values.push(data.storageQuotaBytes)
}
if (data.plan !== undefined) {
fields.push('plan = ?')
values.push(data.plan)
}
if (fields.length === 0) return false
+9
View File
@@ -3,6 +3,7 @@ import * as userRepo from '../repositories/userRepo.js'
import { signToken, generateVerificationToken, hashToken } from '../utils/tokens.js'
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js'
import { AppError } from '../middleware/error.js'
import * as storageRepo from '../repositories/storageRepo.js'
import type { RegisterInput, LoginInput, UpdateProfileInput, ChangePasswordInput, VerifyEmailInput, ResetPasswordInput } from '../validators/authSchemas.js'
const SALT_ROUNDS = 10
@@ -93,12 +94,20 @@ export function getMe(userId: number) {
if (!user) {
throw new AppError(404, 'User not found')
}
const usage = storageRepo.getUserStorageUsage(userId)
const usedPercent = user.storage_quota_bytes > 0
? Math.round((usage.usedBytes / user.storage_quota_bytes) * 100)
: 0
return {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
emailVerified: !!user.email_verified,
storageQuotaBytes: user.storage_quota_bytes,
usedBytes: usage.usedBytes,
usedPercent,
plan: user.plan,
}
}