Public Access
feat: add storage management features including user storage quota and usage tracking
- 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:
1 parent
c002f42459
commit
acbcfde617
15 files changed
+694
-14
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user