- 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>
344 lines
8.2 KiB
Markdown
344 lines
8.2 KiB
Markdown
# 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
|
|
```
|
|
|
|
---
|