refactor: optimize project structure, migrate to SQLite, and add new features

fix:
  - Fix TypeScript type errors in api-wrapper.ts (ApiResponse type)
  - Fix backward compatibility in database.ts getSettings() for missing fields
  - Fix default value for weekNavigationEnabled (changed from true to false)
  - Fix API routes error handling with unified wrapper
  - Fix duplicate toggle switch code in admin.tsx (6 instances)
  - Fix inconsistent authentication check in API routes (unified with withAuth)
  - Fix error message text in loading-context.tsx (improved user experience)

add:
  - Add database.ts: SQLite database layer with better-sqlite3 for persistent storage
    * Groups management (CRUD operations)
    * Settings management with caching
    * Admin password hashing with bcrypt
    * Automatic database initialization and migration
  - Add api-wrapper.ts utility for unified API route handling
    * withAuth wrapper for protected routes
    * withMethods wrapper for public routes
    * Consistent error handling and method validation
  - Add validation.ts utility with centralized validation functions
    * validateCourse - course validation (1-5)
    * validateGroupId - group ID format validation
    * validatePassword - password strength validation
  - Add showAddGroupButton setting to control visibility of 'Add Group' button on homepage
  - Add toggle switch component in admin.tsx for reusable UI (replaces 6 duplicate instances)
  - Add CourseSelect component in admin.tsx for reusable course selection
  - Add DialogFooterButtons component in admin.tsx for reusable dialog footer
  - Add unified loadData function in admin.tsx to reduce code duplication
  - Add change-password.ts API endpoint for admin password management
  - Add logs.ts API endpoint for viewing error logs in admin panel
  - Add logErrorToFile function in logger.ts for persistent error logging
  - Add comprehensive error logging in schedule.ts (parsing, fetch, timeout, network errors)
  - Add comprehensive project structure documentation in README.md
  - Add architecture and code organization section in README.md
  - Add database information section in README.md
  - Add SQLite and bcrypt to tech stack documentation
  - Add better-sqlite3 and bcrypt dependencies to package.json
  - Add .gitignore rules for error.log and database files (data/, *.db, *.db-shm, *.db-wal)

refactor:
  - Refactor admin.tsx: extract reusable components (toggle, select, dialog footer)
  - Refactor API routes to use withAuth wrapper for consistent authentication
  - Refactor API routes to use validation utilities instead of inline validation
  - Refactor groups.ts and groups.json: move to old/data/ directory (deprecated, now using SQLite)
  - Refactor settings-loader.ts: migrate from JSON to SQLite database
  - Refactor groups-loader.ts: migrate from JSON to SQLite database
  - Refactor database.ts: improve backward compatibility for settings migration
  - Refactor admin.tsx: unify data loading functions (loadGroupsList, loadSettingsList)
  - Refactor index.tsx: add showAddGroupButton prop and conditional rendering
  - Refactor API routes: consistent error handling and method validation
  - Refactor README.md: update tech stack, project structure, and admin panel documentation
  - Refactor auth.ts: improve session management and cookie handling
  - Refactor schedule.ts: improve error handling with detailed logging and error types
  - Refactor logger.ts: add file-based error logging functionality
  - Refactor loading-context.tsx: improve error message clarity

remove:
  - Remove hello.ts test API endpoint
  - Remove groups.ts and groups.json (moved to old/data/, replaced by SQLite)

update:
  - Update .gitignore to exclude old data files, database files, and error logs
  - Update package.json: add better-sqlite3, bcrypt and their type definitions
  - Update README.md with new features, architecture, and database information
  - Update all API routes to use new wrapper system
  - Update admin panel with new settings and improved UI
  - Update sitemap.xml with cache usage comment
This commit is contained in:
kilyabin
2025-12-03 21:44:07 +04:00
parent 0907581cc0
commit e46a2419c3
27 changed files with 1937 additions and 627 deletions

View File

@@ -0,0 +1,39 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
import { changePassword } from '@/shared/data/database'
import { validatePassword } from '@/shared/utils/validation'
type ResponseData = ApiResponse
async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
const { oldPassword, newPassword } = req.body
if (!oldPassword || typeof oldPassword !== 'string') {
res.status(400).json({ error: 'Old password is required' })
return
}
if (!newPassword || typeof newPassword !== 'string') {
res.status(400).json({ error: 'New password is required' })
return
}
// Валидация нового пароля (минимум 8 символов)
if (!validatePassword(newPassword)) {
res.status(400).json({ error: 'New password must be at least 8 characters long' })
return
}
const success = await changePassword(oldPassword, newPassword)
if (success) {
res.status(200).json({ success: true })
} else {
res.status(401).json({ error: 'Invalid old password' })
}
}
export default withAuth(handler, ['POST'])

View File

@@ -1,20 +1,20 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { requireAuth } from '@/shared/utils/auth'
import { loadGroups, saveGroups, GroupsData } from '@/shared/data/groups-loader'
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
import { loadGroups, saveGroups, clearGroupsCache, GroupsData } from '@/shared/data/groups-loader'
import { validateGroupId, validateCourse } from '@/shared/utils/validation'
type ResponseData = {
type ResponseData = ApiResponse<{
groups?: GroupsData
success?: boolean
error?: string
}
}>
async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
if (req.method === 'GET') {
// Получение списка групп
const groups = loadGroups()
// Получение списка групп (всегда свежие данные для админ-панели)
clearGroupsCache()
const groups = loadGroups(true)
res.status(200).json({ groups })
return
}
@@ -28,6 +28,11 @@ async function handler(
return
}
if (!validateGroupId(id)) {
res.status(400).json({ error: 'Group ID must contain only lowercase letters, numbers, dashes and underscores' })
return
}
if (!parseId || typeof parseId !== 'number') {
res.status(400).json({ error: 'Parse ID must be a number' })
return
@@ -40,17 +45,11 @@ async function handler(
// Валидация курса (1-5)
const groupCourse = course !== undefined ? Number(course) : 1
if (!Number.isInteger(groupCourse) || groupCourse < 1 || groupCourse > 5) {
if (!validateCourse(groupCourse)) {
res.status(400).json({ error: 'Course must be a number between 1 and 5' })
return
}
// Валидация ID (только латинские буквы, цифры, дефисы и подчеркивания)
if (!/^[a-z0-9_-]+$/.test(id)) {
res.status(400).json({ error: 'Group ID must contain only lowercase letters, numbers, dashes and underscores' })
return
}
const groups = loadGroups()
// Проверка на дубликат
@@ -66,23 +65,14 @@ async function handler(
course: groupCourse
}
try {
saveGroups(groups)
res.status(200).json({ success: true, groups })
} catch (error) {
console.error('Error saving groups:', error)
res.status(500).json({ error: 'Failed to save groups' })
}
saveGroups(groups)
// Сбрасываем кеш и загружаем свежие данные из БД
clearGroupsCache()
const updatedGroups = loadGroups(true)
res.status(200).json({ success: true, groups: updatedGroups })
return
}
res.status(405).json({ error: 'Method not allowed' })
}
export default function protectedHandler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
return requireAuth(req, res, handler)
}
export default withAuth(handler, ['GET', 'POST'])

View File

@@ -1,12 +1,11 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { requireAuth } from '@/shared/utils/auth'
import { loadGroups, saveGroups, GroupsData } from '@/shared/data/groups-loader'
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
import { loadGroups, saveGroups, clearGroupsCache, GroupsData } from '@/shared/data/groups-loader'
import { validateCourse } from '@/shared/utils/validation'
type ResponseData = {
success?: boolean
type ResponseData = ApiResponse<{
groups?: GroupsData
error?: string
}
}>
async function handler(
req: NextApiRequest,
@@ -19,7 +18,8 @@ async function handler(
return
}
const groups = loadGroups()
// Загружаем группы с проверкой кеша
let groups = loadGroups()
if (req.method === 'PUT') {
// Редактирование группы
@@ -40,12 +40,9 @@ async function handler(
return
}
if (course !== undefined) {
const groupCourse = Number(course)
if (!Number.isInteger(groupCourse) || groupCourse < 1 || groupCourse > 5) {
res.status(400).json({ error: 'Course must be a number between 1 and 5' })
return
}
if (course !== undefined && !validateCourse(course)) {
res.status(400).json({ error: 'Course must be a number between 1 and 5' })
return
}
// Обновляем группу
@@ -56,13 +53,11 @@ async function handler(
course: course !== undefined ? Number(course) : currentGroup.course
}
try {
saveGroups(groups)
res.status(200).json({ success: true, groups })
} catch (error) {
console.error('Error saving groups:', error)
res.status(500).json({ error: 'Failed to save groups' })
}
saveGroups(groups)
// Сбрасываем кеш и загружаем свежие данные из БД
clearGroupsCache()
const updatedGroups = loadGroups(true)
res.status(200).json({ success: true, groups: updatedGroups })
return
}
@@ -75,23 +70,14 @@ async function handler(
delete groups[id]
try {
saveGroups(groups)
res.status(200).json({ success: true, groups })
} catch (error) {
console.error('Error saving groups:', error)
res.status(500).json({ error: 'Failed to save groups' })
}
saveGroups(groups)
// Сбрасываем кеш и загружаем свежие данные из БД
clearGroupsCache()
const updatedGroups = loadGroups(true)
res.status(200).json({ success: true, groups: updatedGroups })
return
}
res.status(405).json({ error: 'Method not allowed' })
}
export default function protectedHandler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
return requireAuth(req, res, handler)
}
export default withAuth(handler, ['PUT', 'DELETE'])

View File

@@ -80,7 +80,7 @@ function recordFailedAttempt(ip: string): void {
})
}
export default function handler(
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
@@ -109,7 +109,8 @@ export default function handler(
return
}
if (verifyPassword(password)) {
const isValid = await verifyPassword(password)
if (isValid) {
// Успешный вход - сбрасываем rate limit
rateLimitMap.delete(clientIP)
setSessionCookie(res)

View File

@@ -0,0 +1,36 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
import fs from 'fs'
import path from 'path'
type ResponseData = ApiResponse<{
logs?: string
}>
async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
// Путь к файлу логов (в корне проекта)
const logPath = path.join(process.cwd(), 'error.log')
// Проверяем существование файла
if (!fs.existsSync(logPath)) {
res.status(200).json({ success: true, logs: 'Файл логов пуст или не существует.' })
return
}
// Читаем файл
const logs = fs.readFileSync(logPath, 'utf8')
// Если файл пуст
if (!logs || logs.trim().length === 0) {
res.status(200).json({ success: true, logs: 'Файл логов пуст.' })
return
}
res.status(200).json({ success: true, logs })
}
export default withAuth(handler, ['GET'])

View File

@@ -1,33 +1,37 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { requireAuth } from '@/shared/utils/auth'
import { loadSettings, saveSettings, AppSettings } from '@/shared/data/settings-loader'
import { withAuth, ApiResponse } from '@/shared/utils/api-wrapper'
import { loadSettings, saveSettings, clearSettingsCache, AppSettings } from '@/shared/data/settings-loader'
type ResponseData = {
type ResponseData = ApiResponse<{
settings?: AppSettings
success?: boolean
error?: string
}
}>
async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
if (req.method === 'GET') {
// Получение настроек
const settings = loadSettings()
// Получение настроек (всегда свежие данные для админ-панели)
clearSettingsCache()
const settings = loadSettings(true)
res.status(200).json({ settings })
return
}
if (req.method === 'PUT') {
// Обновление настроек
const { weekNavigationEnabled, debug } = req.body
const { weekNavigationEnabled, showAddGroupButton, debug } = req.body
if (typeof weekNavigationEnabled !== 'boolean') {
res.status(400).json({ error: 'weekNavigationEnabled must be a boolean' })
return
}
if (showAddGroupButton !== undefined && typeof showAddGroupButton !== 'boolean') {
res.status(400).json({ error: 'showAddGroupButton must be a boolean' })
return
}
// Валидация debug опций (только в dev режиме)
if (debug !== undefined) {
if (typeof debug !== 'object' || debug === null) {
@@ -51,32 +55,20 @@ async function handler(
const settings: AppSettings = {
weekNavigationEnabled,
showAddGroupButton: showAddGroupButton !== undefined ? showAddGroupButton : true,
...(debug !== undefined && { debug })
}
try {
saveSettings(settings)
// Сбрасываем кеш и загружаем свежие настройки для подтверждения
const { clearSettingsCache } = await import('@/shared/data/settings-loader')
clearSettingsCache()
const savedSettings = loadSettings()
res.status(200).json({ success: true, settings: savedSettings })
} catch (error) {
console.error('Error saving settings:', error)
res.status(500).json({ error: 'Failed to save settings' })
}
saveSettings(settings)
// Сбрасываем кеш и загружаем свежие настройки для подтверждения
clearSettingsCache()
const savedSettings = loadSettings(true)
res.status(200).json({ success: true, settings: savedSettings })
return
}
res.status(405).json({ error: 'Method not allowed' })
}
export default function protectedHandler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
return requireAuth(req, res, handler)
}
export default withAuth(handler, ['GET', 'PUT'])