books/docs/architecture/backend-architecture.md
Greg fa8acef423 Epic 1, Story 1.1: Project Initialization & Repository Setup
- Initialize Git repository with main branch
- Create comprehensive .gitignore for Node.js, React, and environment files
- Set up directory structure (frontend/, backend/, docs/)
- Create detailed README.md with project overview and setup instructions
- Add .env.example with all required environment variables
- Configure Prettier for consistent code formatting

All acceptance criteria met:
 Git repository initialized with appropriate .gitignore
 Directory structure matches Technical Assumptions
 README.md created with project overview and setup docs
 .env.example file with all required environment variables
 Prettier config files added for code formatting consistency

🤖 Generated with Claude Code (https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 15:12:30 +01:00

8.2 KiB

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

// 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

// 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):

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