# Backend Architecture ## Service Architecture (Traditional Server) ### Controller/Route Organization ``` backend/src/ ├── routes/ │ ├── index.js (main router) │ ├── books.js │ ├── logs.js │ └── health.js ├── controllers/ │ ├── booksController.js │ └── logsController.js ├── services/ │ ├── openLibraryService.js │ └── paceCalculationService.js ├── middleware/ │ ├── errorHandler.js │ ├── validateRequest.js │ └── cors.js ├── utils/ │ ├── logger.js │ └── validation.js ├── prisma/ │ └── client.js (Prisma client singleton) └── server.js ``` ### Controller Template ```typescript // controllers/booksController.js const { PrismaClient } = require('@prisma/client'); const { body, query, validationResult } = require('express-validator'); const openLibraryService = require('../services/openLibraryService'); const paceService = require('../services/paceCalculationService'); const prisma = new PrismaClient(); // Search books via Open Library exports.searchBooks = [ query('q') .trim() .notEmpty().withMessage('Query is required') .isLength({ max: 200 }).withMessage('Query too long'), async (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ error: 'Validation failed', details: errors.array() }); } try { const results = await openLibraryService.searchBooks(req.query.q); res.json({ results }); } catch (error) { next(error); } } ]; // Get all active books with progress exports.getActiveBooks = async (req, res, next) => { try { const books = await prisma.book.findMany({ where: { status: 'reading' }, include: { readingLogs: { orderBy: { logDate: 'desc' }, take: 1, // Latest log }, }, orderBy: { deadlineDate: 'asc' }, }); // Enrich with progress calculations const booksWithProgress = await Promise.all( books.map(async (book) => { const currentPage = book.readingLogs[0]?.currentPage || 0; const progress = await paceService.calculateProgress(book, currentPage); return { ...book, currentPage, ...progress, }; }) ); res.json({ books: booksWithProgress }); } catch (error) { next(error); } }; // Add a new book exports.addBook = [ body('title').trim().notEmpty().isLength({ max: 500 }), body('author').optional().trim().isLength({ max: 500 }), body('totalPages').isInt({ min: 1 }), body('coverUrl').optional().isURL().isLength({ max: 1000 }), body('deadlineDate').isISO8601().toDate(), async (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ error: 'Validation failed', details: errors.array() }); } // Validate deadline is in the future if (req.body.deadlineDate <= new Date()) { return res.status(400).json({ error: 'Deadline must be in the future' }); } try { const book = await prisma.book.create({ data: req.body, }); res.status(201).json(book); } catch (error) { next(error); } } ]; // Get book by ID exports.getBook = async (req, res, next) => { try { const book = await prisma.book.findUnique({ where: { id: parseInt(req.params.bookId) }, }); if (!book) { return res.status(404).json({ error: 'Book not found' }); } res.json(book); } catch (error) { next(error); } }; // Delete book exports.deleteBook = async (req, res, next) => { try { await prisma.book.delete({ where: { id: parseInt(req.params.bookId) }, }); res.status(204).send(); } catch (error) { if (error.code === 'P2025') { return res.status(404).json({ error: 'Book not found' }); } next(error); } }; // Get book progress exports.getProgress = async (req, res, next) => { try { const book = await prisma.book.findUnique({ where: { id: parseInt(req.params.bookId) }, include: { readingLogs: { orderBy: { logDate: 'desc' }, take: 1, }, }, }); if (!book) { return res.status(404).json({ error: 'Book not found' }); } const currentPage = book.readingLogs[0]?.currentPage || 0; const progress = await paceService.calculateProgress(book, currentPage); res.json({ bookId: book.id, currentPage, totalPages: book.totalPages, ...progress, }); } catch (error) { next(error); } }; ``` ## Database Architecture ### Schema Design See **Database Schema** section above for complete Prisma schema. ### Data Access Layer ```typescript // services/paceCalculationService.js const { PrismaClient } = require('@prisma/client'); const { differenceInDays, subDays } = require('date-fns'); const prisma = new PrismaClient(); /** * Calculate required pages per day to finish on time */ function calculateRequiredPace(totalPages, currentPage, deadlineDate) { const pagesRemaining = totalPages - currentPage; const daysRemaining = differenceInDays(new Date(deadlineDate), new Date()); if (daysRemaining <= 0) { return pagesRemaining; // All pages must be read today/overdue } if (pagesRemaining <= 0) { return 0; // Book finished } return pagesRemaining / daysRemaining; } /** * Calculate actual reading pace from last N days of logs */ async function calculateActualPace(bookId, days = 7) { const startDate = subDays(new Date(), days); const logs = await prisma.readingLog.findMany({ where: { bookId, logDate: { gte: startDate, }, }, orderBy: { logDate: 'asc' }, }); if (logs.length < 2) { return null; // Insufficient data } const firstLog = logs[0]; const lastLog = logs[logs.length - 1]; const pagesRead = lastLog.currentPage - firstLog.currentPage; const daysElapsed = differenceInDays(new Date(lastLog.logDate), new Date(firstLog.logDate)); if (daysElapsed === 0) { return null; // Same day logs, can't calculate pace } return pagesRead / daysElapsed; } /** * Determine status based on required vs actual pace */ function calculateStatus(requiredPace, actualPace) { if (actualPace === null) { return 'unknown'; // Not enough data } if (actualPace >= requiredPace) { return 'on-track'; } if (actualPace >= requiredPace * 0.9) { return 'slightly-behind'; // Within 10% } return 'behind'; } /** * Calculate complete progress object for a book */ async function calculateProgress(book, currentPage) { const requiredPace = calculateRequiredPace(book.totalPages, currentPage, book.deadlineDate); const actualPace = await calculateActualPace(book.id, 7); const status = calculateStatus(requiredPace, actualPace); const pagesRemaining = book.totalPages - currentPage; const daysRemaining = differenceInDays(new Date(book.deadlineDate), new Date()); const lastLog = await prisma.readingLog.findFirst({ where: { bookId: book.id }, orderBy: { logDate: 'desc' }, }); return { pagesRemaining, daysRemaining, requiredPace, actualPace, status, lastLoggedDate: lastLog?.logDate || null, }; } module.exports = { calculateRequiredPace, calculateActualPace, calculateStatus, calculateProgress, }; ``` ## Authentication and Authorization **MVP:** No authentication required (single-user deployment). **Future (v1.1 Multi-User):** - Add JWT-based authentication - Use Passport.js or custom middleware - Store JWT in httpOnly cookie or localStorage - Protect API routes with auth middleware - Add user_id foreign key to Book model **Auth Flow (Future):** ```mermaid sequenceDiagram actor User participant FE as Frontend participant API as Backend API participant DB as Database User->>FE: Enter credentials FE->>API: POST /api/auth/login API->>DB: Verify credentials DB-->>API: User found API->>API: Generate JWT token API-->>FE: JWT token (httpOnly cookie) FE->>API: GET /api/books (with cookie) API->>API: Verify JWT token API->>DB: Query books for user_id DB-->>API: Books API-->>FE: Books data ``` ---