Add comprehensive LLM development documentation (#939)
* Increase coverage * Add comprehensive LLM development documentation - Add CLAUDE.md as main documentation index - Create 8 detailed documentation files in docs/: - architecture.md: Tech stack, data models, auth system - directory-structure.md: Complete file tree with paths - backend-patterns.md: Module architecture and patterns - database.md: Models, migrations, and workflows - development-workflow.md: Setup and daily development - code-conventions.md: Style guide and best practices - testing.md: Test organization and patterns - common-tasks.md: How-to guides for frequent tasks - Update .gitignore to allow project-level CLAUDE.md - 4,285 lines of comprehensive documentation - Organized for easy navigation with cross-links - LLM-optimized with absolute paths and code examples * fixup! Add comprehensive LLM development documentation
This commit is contained in:
parent
25b11086e2
commit
3486541272
19 changed files with 6086 additions and 2 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -6,8 +6,8 @@ certs/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.cursor
|
.cursor
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
CLAUDE.md
|
# CLAUDE.md - project documentation (committed)
|
||||||
CLAUDE.local.md
|
CLAUDE.local.md # User-specific customizations (ignored)
|
||||||
|
|
||||||
.byebug_history
|
.byebug_history
|
||||||
node_modules
|
node_modules
|
||||||
|
|
|
||||||
166
CLAUDE.md
Normal file
166
CLAUDE.md
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
# Tududi - Developer Guide
|
||||||
|
|
||||||
|
This documentation is designed for AI assistants and developers working with the tududi codebase. For user-facing documentation, see [README.md](README.md). For contribution guidelines, see [CONTRIBUTING.md](.github/CONTRIBUTING.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Tududi is a self-hosted task management system with hierarchical organization (Areas > Projects > Tasks), smart recurring tasks, and multi-channel integration.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18 + TypeScript, Express + Sequelize, SQLite
|
||||||
|
|
||||||
|
**Get Started:**
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/chrisvel/tududi.git
|
||||||
|
cd tududi
|
||||||
|
npm install
|
||||||
|
npm run db:init
|
||||||
|
npm start # Frontend on :8080, Backend on :3002
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation Index
|
||||||
|
|
||||||
|
### Core Documentation
|
||||||
|
|
||||||
|
1. **[Architecture Overview](docs/architecture.md)**
|
||||||
|
- Tech stack details
|
||||||
|
- Request flow diagram
|
||||||
|
- Data model hierarchy
|
||||||
|
- Authentication methods
|
||||||
|
|
||||||
|
2. **[Directory Structure](docs/directory-structure.md)**
|
||||||
|
- Complete file tree with absolute paths
|
||||||
|
- Critical paths reference
|
||||||
|
- Backend and frontend organization
|
||||||
|
|
||||||
|
3. **[Backend Patterns](docs/backend-patterns.md)**
|
||||||
|
- Module architecture pattern
|
||||||
|
- How to add new modules
|
||||||
|
- Module communication
|
||||||
|
- Repository and service patterns
|
||||||
|
|
||||||
|
4. **[Database & Migrations](docs/database.md)**
|
||||||
|
- Key models and relationships
|
||||||
|
- Migration workflow
|
||||||
|
- Migration best practices
|
||||||
|
- Common migration operations
|
||||||
|
|
||||||
|
5. **[Development Workflow](docs/development-workflow.md)**
|
||||||
|
- Initial setup
|
||||||
|
- Daily development (two-server process)
|
||||||
|
- Environment variables
|
||||||
|
- Adding new features (complete walkthrough)
|
||||||
|
- Database management commands
|
||||||
|
|
||||||
|
6. **[Code Conventions](docs/code-conventions.md)**
|
||||||
|
- Language usage (TypeScript/JavaScript)
|
||||||
|
- Backend patterns (async/await, repository)
|
||||||
|
- Frontend patterns (components, state)
|
||||||
|
- Naming conventions
|
||||||
|
- API route conventions
|
||||||
|
|
||||||
|
7. **[Testing](docs/testing.md)**
|
||||||
|
- Test organization
|
||||||
|
- Running tests
|
||||||
|
- Testing requirements
|
||||||
|
- Test patterns (Arrange-Act-Assert)
|
||||||
|
|
||||||
|
8. **[Common Tasks](docs/common-tasks.md)**
|
||||||
|
- Add field to model
|
||||||
|
- Create new backend module
|
||||||
|
- Add React component
|
||||||
|
- Update database schema
|
||||||
|
- Fix a bug (TDD workflow)
|
||||||
|
- Add translations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
### What This Project Does
|
||||||
|
|
||||||
|
Tududi is a self-hosted task management system designed around hierarchical organization and smart automation. It prioritizes user flow over rigid structures - a productivity tool that doesn't "fight back."
|
||||||
|
|
||||||
|
**Core Philosophy:**
|
||||||
|
- [Designing a Life Management System That Doesn't Fight Back](https://medium.com/@chrisveleris/designing-a-life-management-system-that-doesnt-fight-back-2fd58773e857)
|
||||||
|
- [From Task to Table: How I Finally Got to the Korean Burger](https://medium.com/@chrisveleris/from-task-to-table-how-i-finally-got-to-the-korean-burger-01245a14d491)
|
||||||
|
|
||||||
|
**Key Capabilities:**
|
||||||
|
- **Hierarchical Organization:** Areas > Projects > Tasks > Subtasks
|
||||||
|
- **Smart Recurring Tasks:** Multiple patterns with parent-child tracking
|
||||||
|
- **Multi-Language Support:** 24 languages via i18next
|
||||||
|
- **Collaboration:** Project sharing with granular permissions
|
||||||
|
- **REST API:** Swagger docs + personal API tokens
|
||||||
|
- **Telegram Integration:** Create tasks via messages, daily digests
|
||||||
|
- **Tag System:** Flexible tagging across tasks, notes, projects
|
||||||
|
|
||||||
|
**Target Users:** Self-hosting individuals and teams managing personal or collaborative productivity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- React 18 + TypeScript 5.6
|
||||||
|
- Webpack 5 (build) + webpack-dev-server (development)
|
||||||
|
- Tailwind CSS 3.4 + Heroicons
|
||||||
|
- Zustand (global state) + SWR (server state)
|
||||||
|
- React Router 6, i18next (24 languages)
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- Express 4.21 + Sequelize 6.37 (ORM)
|
||||||
|
- SQLite 5.1 (WAL mode, optimized)
|
||||||
|
- bcrypt + express-session (auth)
|
||||||
|
- Swagger (API docs), Multer (uploads)
|
||||||
|
- node-cron (scheduling), Nodemailer (email)
|
||||||
|
|
||||||
|
**Testing:**
|
||||||
|
- Jest (backend + frontend)
|
||||||
|
- Playwright (E2E)
|
||||||
|
- Supertest (API integration tests)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Paths Quick Reference
|
||||||
|
|
||||||
|
| Task | Location |
|
||||||
|
|------|----------|
|
||||||
|
| Add backend feature | `/backend/modules/[feature]/` |
|
||||||
|
| Create model | `/backend/models/[model].js` |
|
||||||
|
| Database migration | `/backend/migrations/` |
|
||||||
|
| React component | `/frontend/components/[Feature]/` |
|
||||||
|
| API routes | `/backend/modules/[module]/routes.js` |
|
||||||
|
| Global state | `/frontend/store/useStore.ts` |
|
||||||
|
| API client | `/frontend/utils/[resource]Service.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
| Document | Audience | Purpose |
|
||||||
|
|----------|----------|---------|
|
||||||
|
| [README.md](README.md) | Users | Features, Docker setup, quick start |
|
||||||
|
| [CONTRIBUTING.md](.github/CONTRIBUTING.md) | Contributors | PR workflow, code of conduct |
|
||||||
|
| [docs.tududi.com](https://docs.tududi.com) | End users | Full user documentation |
|
||||||
|
| [Swagger API docs](http://localhost:3002/api-docs) | API consumers | API endpoints (after auth) |
|
||||||
|
| **CLAUDE.md** | Developers, AI | Codebase architecture, patterns |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## External Resources
|
||||||
|
|
||||||
|
- **Roadmap:** [GitHub Project](https://github.com/users/chrisvel/projects/2)
|
||||||
|
- **Community:**
|
||||||
|
- [Discord](https://discord.gg/fkbeJ9CmcH)
|
||||||
|
- [Reddit](https://www.reddit.com/r/tududi/)
|
||||||
|
- [Issues](https://github.com/chrisvel/tududi/issues)
|
||||||
|
- [Discussions](https://github.com/chrisvel/tududi/discussions)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Version:** 1.0.0
|
||||||
|
**Last Updated:** 2026-03-13
|
||||||
|
**Maintainer:** Update when architecture changes or patterns evolve
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const { requireAuth } = require('../../../middleware/auth');
|
const { requireAuth } = require('../../../middleware/auth');
|
||||||
const { User } = require('../../../models');
|
const { User } = require('../../../models');
|
||||||
|
const { createApiToken } = require('../../../modules/users/apiTokenService');
|
||||||
|
|
||||||
describe('Auth Middleware', () => {
|
describe('Auth Middleware', () => {
|
||||||
let req, res, next;
|
let req, res, next;
|
||||||
|
|
@ -135,4 +136,154 @@ describe('Auth Middleware', () => {
|
||||||
User.findByPk = originalFindByPk;
|
User.findByPk = originalFindByPk;
|
||||||
console.error = originalConsoleError;
|
console.error = originalConsoleError;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- API Token (Bearer) authentication ---
|
||||||
|
|
||||||
|
describe('Bearer token authentication', () => {
|
||||||
|
let user;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { sequelize } = require('../../../models');
|
||||||
|
await sequelize.query('DELETE FROM api_tokens');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
user = await User.create({
|
||||||
|
email: 'token-user@example.com',
|
||||||
|
password_digest: await bcrypt.hash('password123', 10),
|
||||||
|
});
|
||||||
|
// No session – forces the bearer path
|
||||||
|
req.session = null;
|
||||||
|
req.headers = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should authenticate with a valid Bearer token', async () => {
|
||||||
|
const { rawToken } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
name: 'test',
|
||||||
|
});
|
||||||
|
req.headers = { authorization: `Bearer ${rawToken}` };
|
||||||
|
|
||||||
|
await requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(req.currentUser.id).toBe(user.id);
|
||||||
|
expect(req.authToken).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 for an invalid Bearer token', async () => {
|
||||||
|
req.headers = { authorization: 'Bearer invalid-token-value' };
|
||||||
|
|
||||||
|
await requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Invalid or expired API token',
|
||||||
|
});
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 for a revoked token', async () => {
|
||||||
|
const { rawToken, tokenRecord } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
name: 'revoked',
|
||||||
|
});
|
||||||
|
await tokenRecord.update({ revoked_at: new Date() });
|
||||||
|
req.headers = { authorization: `Bearer ${rawToken}` };
|
||||||
|
|
||||||
|
await requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Invalid or expired API token',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 for an expired token', async () => {
|
||||||
|
const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
const { rawToken } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
name: 'expired',
|
||||||
|
expiresAt: pastDate,
|
||||||
|
});
|
||||||
|
req.headers = { authorization: `Bearer ${rawToken}` };
|
||||||
|
|
||||||
|
await requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Invalid or expired API token',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when Authorization header has no token value', async () => {
|
||||||
|
req.headers = { authorization: 'Bearer ' };
|
||||||
|
|
||||||
|
await requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 for non-Bearer scheme', async () => {
|
||||||
|
req.headers = { authorization: 'Basic abc123' };
|
||||||
|
|
||||||
|
await requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when token user no longer exists', async () => {
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
console.error = jest.fn();
|
||||||
|
|
||||||
|
const { rawToken } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
name: 'orphan',
|
||||||
|
});
|
||||||
|
// Destroying the user cascade-deletes associated tokens,
|
||||||
|
// so the token lookup itself fails rather than the user lookup
|
||||||
|
await user.destroy();
|
||||||
|
req.headers = { authorization: `Bearer ${rawToken}` };
|
||||||
|
|
||||||
|
await requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update last_used_at when token has not been used recently', async () => {
|
||||||
|
const { rawToken, tokenRecord } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
name: 'fresh',
|
||||||
|
});
|
||||||
|
// Ensure last_used_at is old enough to trigger update
|
||||||
|
await tokenRecord.update({
|
||||||
|
last_used_at: new Date(Date.now() - 10 * 60 * 1000),
|
||||||
|
});
|
||||||
|
req.headers = { authorization: `Bearer ${rawToken}` };
|
||||||
|
|
||||||
|
await requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
// Give the fire-and-forget update a moment to complete
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
await tokenRecord.reload();
|
||||||
|
// last_used_at should be updated to roughly now
|
||||||
|
expect(
|
||||||
|
Date.now() - tokenRecord.last_used_at.getTime()
|
||||||
|
).toBeLessThan(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip health check even with no session and no token', async () => {
|
||||||
|
req.path = '/api/health';
|
||||||
|
req.headers = {};
|
||||||
|
|
||||||
|
await requireAuth(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
265
backend/tests/unit/middleware/authorize.test.js
Normal file
265
backend/tests/unit/middleware/authorize.test.js
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
const { hasAccess } = require('../../../middleware/authorize');
|
||||||
|
const permissionsService = require('../../../services/permissionsService');
|
||||||
|
|
||||||
|
jest.mock('../../../services/permissionsService');
|
||||||
|
|
||||||
|
describe('authorize middleware – hasAccess', () => {
|
||||||
|
let req, res, next;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
req = {
|
||||||
|
currentUser: { id: 42 },
|
||||||
|
session: { userId: 42 },
|
||||||
|
params: { uid: 'abc123' },
|
||||||
|
};
|
||||||
|
res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
|
||||||
|
next = jest.fn();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- UID resolution ---
|
||||||
|
|
||||||
|
it('should resolve uid from a function', async () => {
|
||||||
|
permissionsService.getAccess.mockResolvedValue('rw');
|
||||||
|
const mw = hasAccess('ro', 'project', (r) => r.params.uid);
|
||||||
|
|
||||||
|
await mw(req, res, next);
|
||||||
|
|
||||||
|
expect(permissionsService.getAccess).toHaveBeenCalledWith(
|
||||||
|
42,
|
||||||
|
'project',
|
||||||
|
'abc123'
|
||||||
|
);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve uid from a static string', async () => {
|
||||||
|
permissionsService.getAccess.mockResolvedValue('rw');
|
||||||
|
const mw = hasAccess('ro', 'project', 'static-uid');
|
||||||
|
|
||||||
|
await mw(req, res, next);
|
||||||
|
|
||||||
|
expect(permissionsService.getAccess).toHaveBeenCalledWith(
|
||||||
|
42,
|
||||||
|
'project',
|
||||||
|
'static-uid'
|
||||||
|
);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve uid from an async function', async () => {
|
||||||
|
permissionsService.getAccess.mockResolvedValue('rw');
|
||||||
|
const mw = hasAccess('ro', 'task', async (r) => r.params.uid);
|
||||||
|
|
||||||
|
await mw(req, res, next);
|
||||||
|
|
||||||
|
expect(permissionsService.getAccess).toHaveBeenCalledWith(
|
||||||
|
42,
|
||||||
|
'task',
|
||||||
|
'abc123'
|
||||||
|
);
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 when uid resolves to null', async () => {
|
||||||
|
const mw = hasAccess('ro', 'project', () => null);
|
||||||
|
|
||||||
|
await mw(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(404);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'Not found' });
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 when uid resolves to undefined', async () => {
|
||||||
|
const mw = hasAccess('ro', 'project', () => undefined);
|
||||||
|
|
||||||
|
await mw(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(404);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 when uid resolves to empty string', async () => {
|
||||||
|
const mw = hasAccess('ro', 'project', () => '');
|
||||||
|
|
||||||
|
await mw(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(404);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Permission hierarchy ---
|
||||||
|
|
||||||
|
it('should allow access when user has exact required level (ro)', async () => {
|
||||||
|
permissionsService.getAccess.mockResolvedValue('ro');
|
||||||
|
const mw = hasAccess('ro', 'project', () => 'uid1');
|
||||||
|
|
||||||
|
await mw(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow access when user has higher than required (rw > ro)', async () => {
|
||||||
|
permissionsService.getAccess.mockResolvedValue('rw');
|
||||||
|
const mw = hasAccess('ro', 'project', () => 'uid1');
|
||||||
|
|
||||||
|
await mw(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow access when user has admin and rw is required', async () => {
|
||||||
|
permissionsService.getAccess.mockResolvedValue('admin');
|
||||||
|
const mw = hasAccess('rw', 'project', () => 'uid1');
|
||||||
|
|
||||||
|
await mw(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny access when user has ro but rw is required', async () => {
|
||||||
|
permissionsService.getAccess.mockResolvedValue('ro');
|
||||||
|
const mw = hasAccess('rw', 'project', () => 'uid1');
|
||||||
|
|
||||||
|
await mw(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' });
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny access when user has none', async () => {
|
||||||
|
permissionsService.getAccess.mockResolvedValue('none');
|
||||||
|
const mw = hasAccess('ro', 'project', () => 'uid1');
|
||||||
|
|
||||||
|
await mw(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' });
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- User ID extraction ---
|
||||||
|
|
||||||
|
it('should prefer currentUser.id over session.userId', async () => {
|
||||||
|
req.currentUser = { id: 100 };
|
||||||
|
req.session = { userId: 200 };
|
||||||
|
permissionsService.getAccess.mockResolvedValue('rw');
|
||||||
|
const mw = hasAccess('ro', 'project', () => 'uid1');
|
||||||
|
|
||||||
|
await mw(req, res, next);
|
||||||
|
|
||||||
|
expect(permissionsService.getAccess).toHaveBeenCalledWith(
|
||||||
|
100,
|
||||||
|
'project',
|
||||||
|
'uid1'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to session.userId when currentUser is missing', async () => {
|
||||||
|
req.currentUser = null;
|
||||||
|
req.session = { userId: 200 };
|
||||||
|
permissionsService.getAccess.mockResolvedValue('rw');
|
||||||
|
const mw = hasAccess('ro', 'project', () => 'uid1');
|
||||||
|
|
||||||
|
await mw(req, res, next);
|
||||||
|
|
||||||
|
expect(permissionsService.getAccess).toHaveBeenCalledWith(
|
||||||
|
200,
|
||||||
|
'project',
|
||||||
|
'uid1'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Options ---
|
||||||
|
|
||||||
|
it('should use custom notFoundMessage', async () => {
|
||||||
|
const mw = hasAccess('ro', 'project', () => null, {
|
||||||
|
notFoundMessage: 'Project not found',
|
||||||
|
});
|
||||||
|
|
||||||
|
await mw(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(404);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'Project not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 instead of 403 when forbiddenStatus is 404', async () => {
|
||||||
|
permissionsService.getAccess.mockResolvedValue('none');
|
||||||
|
const mw = hasAccess('rw', 'project', () => 'uid1', {
|
||||||
|
forbiddenStatus: 404,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mw(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(404);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom notFoundMessage with forbiddenStatus 404', async () => {
|
||||||
|
permissionsService.getAccess.mockResolvedValue('none');
|
||||||
|
const mw = hasAccess('rw', 'project', () => 'uid1', {
|
||||||
|
forbiddenStatus: 404,
|
||||||
|
notFoundMessage: 'Task not found',
|
||||||
|
});
|
||||||
|
|
||||||
|
await mw(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(404);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: 'Task not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Resource types ---
|
||||||
|
|
||||||
|
it('should pass correct resource type for tasks', async () => {
|
||||||
|
permissionsService.getAccess.mockResolvedValue('rw');
|
||||||
|
const mw = hasAccess('rw', 'task', () => 'task-uid');
|
||||||
|
|
||||||
|
await mw(req, res, next);
|
||||||
|
|
||||||
|
expect(permissionsService.getAccess).toHaveBeenCalledWith(
|
||||||
|
42,
|
||||||
|
'task',
|
||||||
|
'task-uid'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass correct resource type for notes', async () => {
|
||||||
|
permissionsService.getAccess.mockResolvedValue('rw');
|
||||||
|
const mw = hasAccess('rw', 'note', () => 'note-uid');
|
||||||
|
|
||||||
|
await mw(req, res, next);
|
||||||
|
|
||||||
|
expect(permissionsService.getAccess).toHaveBeenCalledWith(
|
||||||
|
42,
|
||||||
|
'note',
|
||||||
|
'note-uid'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Error handling ---
|
||||||
|
|
||||||
|
it('should forward errors from getAccess to next()', async () => {
|
||||||
|
const error = new Error('Database failure');
|
||||||
|
permissionsService.getAccess.mockRejectedValue(error);
|
||||||
|
const mw = hasAccess('ro', 'project', () => 'uid1');
|
||||||
|
|
||||||
|
await mw(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledWith(error);
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should forward errors from getResourceUid to next()', async () => {
|
||||||
|
const error = new Error('UID lookup failed');
|
||||||
|
const mw = hasAccess('ro', 'project', () => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
await mw(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledWith(error);
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
258
backend/tests/unit/services/applyPerms.test.js
Normal file
258
backend/tests/unit/services/applyPerms.test.js
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
const { applyPerms } = require('../../../services/applyPerms');
|
||||||
|
const { sequelize, Permission, User } = require('../../../models');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
|
||||||
|
describe('applyPerms', () => {
|
||||||
|
let owner, target;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await sequelize.query('DELETE FROM permissions');
|
||||||
|
const hash = await bcrypt.hash('pass', 10);
|
||||||
|
owner = await User.create({
|
||||||
|
email: 'owner@test.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
target = await User.create({
|
||||||
|
email: 'target@test.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a new permission on upsert when none exists', async () => {
|
||||||
|
await sequelize.transaction(async (tx) => {
|
||||||
|
await applyPerms(tx, {
|
||||||
|
upserts: [
|
||||||
|
{
|
||||||
|
userId: target.id,
|
||||||
|
resourceType: 'project',
|
||||||
|
resourceUid: 'proj-uid-1',
|
||||||
|
accessLevel: 'ro',
|
||||||
|
propagation: 'direct',
|
||||||
|
grantedByUserId: owner.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
deletes: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const perm = await Permission.findOne({
|
||||||
|
where: {
|
||||||
|
user_id: target.id,
|
||||||
|
resource_type: 'project',
|
||||||
|
resource_uid: 'proj-uid-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(perm).not.toBeNull();
|
||||||
|
expect(perm.access_level).toBe('ro');
|
||||||
|
expect(perm.propagation).toBe('direct');
|
||||||
|
expect(perm.granted_by_user_id).toBe(owner.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should upgrade access level on upsert when permission already exists (ro -> rw)', async () => {
|
||||||
|
// Create existing ro permission
|
||||||
|
await Permission.create({
|
||||||
|
user_id: target.id,
|
||||||
|
resource_type: 'project',
|
||||||
|
resource_uid: 'proj-uid-2',
|
||||||
|
access_level: 'ro',
|
||||||
|
propagation: 'direct',
|
||||||
|
granted_by_user_id: owner.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sequelize.transaction(async (tx) => {
|
||||||
|
await applyPerms(tx, {
|
||||||
|
upserts: [
|
||||||
|
{
|
||||||
|
userId: target.id,
|
||||||
|
resourceType: 'project',
|
||||||
|
resourceUid: 'proj-uid-2',
|
||||||
|
accessLevel: 'rw',
|
||||||
|
grantedByUserId: owner.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
deletes: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const perm = await Permission.findOne({
|
||||||
|
where: {
|
||||||
|
user_id: target.id,
|
||||||
|
resource_type: 'project',
|
||||||
|
resource_uid: 'proj-uid-2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(perm.access_level).toBe('rw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep rw when upserting ro on existing rw permission', async () => {
|
||||||
|
await Permission.create({
|
||||||
|
user_id: target.id,
|
||||||
|
resource_type: 'project',
|
||||||
|
resource_uid: 'proj-uid-3',
|
||||||
|
access_level: 'rw',
|
||||||
|
propagation: 'direct',
|
||||||
|
granted_by_user_id: owner.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sequelize.transaction(async (tx) => {
|
||||||
|
await applyPerms(tx, {
|
||||||
|
upserts: [
|
||||||
|
{
|
||||||
|
userId: target.id,
|
||||||
|
resourceType: 'project',
|
||||||
|
resourceUid: 'proj-uid-3',
|
||||||
|
accessLevel: 'ro',
|
||||||
|
grantedByUserId: owner.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
deletes: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const perm = await Permission.findOne({
|
||||||
|
where: {
|
||||||
|
user_id: target.id,
|
||||||
|
resource_type: 'project',
|
||||||
|
resource_uid: 'proj-uid-3',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(perm.access_level).toBe('rw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete a permission', async () => {
|
||||||
|
await Permission.create({
|
||||||
|
user_id: target.id,
|
||||||
|
resource_type: 'task',
|
||||||
|
resource_uid: 'task-uid-1',
|
||||||
|
access_level: 'rw',
|
||||||
|
propagation: 'direct',
|
||||||
|
granted_by_user_id: owner.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sequelize.transaction(async (tx) => {
|
||||||
|
await applyPerms(tx, {
|
||||||
|
upserts: [],
|
||||||
|
deletes: [
|
||||||
|
{
|
||||||
|
userId: target.id,
|
||||||
|
resourceType: 'task',
|
||||||
|
resourceUid: 'task-uid-1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const perm = await Permission.findOne({
|
||||||
|
where: {
|
||||||
|
user_id: target.id,
|
||||||
|
resource_type: 'task',
|
||||||
|
resource_uid: 'task-uid-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(perm).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty upserts and deletes', async () => {
|
||||||
|
await sequelize.transaction(async (tx) => {
|
||||||
|
await applyPerms(tx, { upserts: [], deletes: [] });
|
||||||
|
});
|
||||||
|
// No error thrown
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple upserts and deletes in one call', async () => {
|
||||||
|
await Permission.create({
|
||||||
|
user_id: target.id,
|
||||||
|
resource_type: 'note',
|
||||||
|
resource_uid: 'note-to-delete',
|
||||||
|
access_level: 'ro',
|
||||||
|
propagation: 'direct',
|
||||||
|
granted_by_user_id: owner.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sequelize.transaction(async (tx) => {
|
||||||
|
await applyPerms(tx, {
|
||||||
|
upserts: [
|
||||||
|
{
|
||||||
|
userId: target.id,
|
||||||
|
resourceType: 'project',
|
||||||
|
resourceUid: 'new-proj',
|
||||||
|
accessLevel: 'rw',
|
||||||
|
propagation: 'direct',
|
||||||
|
grantedByUserId: owner.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: target.id,
|
||||||
|
resourceType: 'task',
|
||||||
|
resourceUid: 'new-task',
|
||||||
|
accessLevel: 'ro',
|
||||||
|
propagation: 'inherited',
|
||||||
|
grantedByUserId: owner.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
deletes: [
|
||||||
|
{
|
||||||
|
userId: target.id,
|
||||||
|
resourceType: 'note',
|
||||||
|
resourceUid: 'note-to-delete',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const projPerm = await Permission.findOne({
|
||||||
|
where: { user_id: target.id, resource_uid: 'new-proj' },
|
||||||
|
});
|
||||||
|
const taskPerm = await Permission.findOne({
|
||||||
|
where: { user_id: target.id, resource_uid: 'new-task' },
|
||||||
|
});
|
||||||
|
const notePerm = await Permission.findOne({
|
||||||
|
where: { user_id: target.id, resource_uid: 'note-to-delete' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(projPerm).not.toBeNull();
|
||||||
|
expect(projPerm.access_level).toBe('rw');
|
||||||
|
expect(taskPerm).not.toBeNull();
|
||||||
|
expect(taskPerm.access_level).toBe('ro');
|
||||||
|
expect(taskPerm.propagation).toBe('inherited');
|
||||||
|
expect(notePerm).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set default propagation to direct when not specified', async () => {
|
||||||
|
await sequelize.transaction(async (tx) => {
|
||||||
|
await applyPerms(tx, {
|
||||||
|
upserts: [
|
||||||
|
{
|
||||||
|
userId: target.id,
|
||||||
|
resourceType: 'project',
|
||||||
|
resourceUid: 'proj-default',
|
||||||
|
accessLevel: 'ro',
|
||||||
|
grantedByUserId: owner.id,
|
||||||
|
// no propagation
|
||||||
|
},
|
||||||
|
],
|
||||||
|
deletes: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const perm = await Permission.findOne({
|
||||||
|
where: { user_id: target.id, resource_uid: 'proj-default' },
|
||||||
|
});
|
||||||
|
expect(perm.propagation).toBe('direct');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not fail when deleting a non-existent permission', async () => {
|
||||||
|
await sequelize.transaction(async (tx) => {
|
||||||
|
await applyPerms(tx, {
|
||||||
|
upserts: [],
|
||||||
|
deletes: [
|
||||||
|
{
|
||||||
|
userId: target.id,
|
||||||
|
resourceType: 'project',
|
||||||
|
resourceUid: 'does-not-exist',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Should not throw
|
||||||
|
});
|
||||||
|
});
|
||||||
332
backend/tests/unit/services/permissionsService.test.js
Normal file
332
backend/tests/unit/services/permissionsService.test.js
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
const {
|
||||||
|
getAccess,
|
||||||
|
getSharedUidsForUser,
|
||||||
|
ownershipOrPermissionWhere,
|
||||||
|
ACCESS,
|
||||||
|
} = require('../../../services/permissionsService');
|
||||||
|
const {
|
||||||
|
User,
|
||||||
|
Project,
|
||||||
|
Task,
|
||||||
|
Note,
|
||||||
|
Permission,
|
||||||
|
sequelize,
|
||||||
|
} = require('../../../models');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
|
||||||
|
describe('permissionsService', () => {
|
||||||
|
let owner, otherUser, adminUser;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await sequelize.query('DELETE FROM permissions');
|
||||||
|
await sequelize.query('DELETE FROM roles');
|
||||||
|
const hash = await bcrypt.hash('pass', 10);
|
||||||
|
// First user created gets admin role automatically via afterCreate hook
|
||||||
|
adminUser = await User.create({
|
||||||
|
email: 'admin@test.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
owner = await User.create({
|
||||||
|
email: 'owner@test.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
otherUser = await User.create({
|
||||||
|
email: 'other@test.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAccess', () => {
|
||||||
|
// --- Projects ---
|
||||||
|
|
||||||
|
it('should return rw for project owner', async () => {
|
||||||
|
const project = await Project.create({
|
||||||
|
name: 'P1',
|
||||||
|
user_id: owner.id,
|
||||||
|
});
|
||||||
|
const access = await getAccess(owner.id, 'project', project.uid);
|
||||||
|
expect(access).toBe('rw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return none for non-owner without permission', async () => {
|
||||||
|
const project = await Project.create({
|
||||||
|
name: 'P1',
|
||||||
|
user_id: owner.id,
|
||||||
|
});
|
||||||
|
const access = await getAccess(
|
||||||
|
otherUser.id,
|
||||||
|
'project',
|
||||||
|
project.uid
|
||||||
|
);
|
||||||
|
expect(access).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return admin for admin user', async () => {
|
||||||
|
const project = await Project.create({
|
||||||
|
name: 'P1',
|
||||||
|
user_id: owner.id,
|
||||||
|
});
|
||||||
|
const access = await getAccess(
|
||||||
|
adminUser.uid,
|
||||||
|
'project',
|
||||||
|
project.uid
|
||||||
|
);
|
||||||
|
expect(access).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return none for non-existent project', async () => {
|
||||||
|
const access = await getAccess(
|
||||||
|
owner.id,
|
||||||
|
'project',
|
||||||
|
'nonexistent-uid'
|
||||||
|
);
|
||||||
|
expect(access).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return shared permission level for project', async () => {
|
||||||
|
const project = await Project.create({
|
||||||
|
name: 'P1',
|
||||||
|
user_id: owner.id,
|
||||||
|
});
|
||||||
|
await Permission.create({
|
||||||
|
user_id: otherUser.id,
|
||||||
|
resource_type: 'project',
|
||||||
|
resource_uid: project.uid,
|
||||||
|
access_level: 'ro',
|
||||||
|
propagation: 'direct',
|
||||||
|
granted_by_user_id: owner.id,
|
||||||
|
});
|
||||||
|
const access = await getAccess(
|
||||||
|
otherUser.id,
|
||||||
|
'project',
|
||||||
|
project.uid
|
||||||
|
);
|
||||||
|
expect(access).toBe('ro');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Tasks ---
|
||||||
|
|
||||||
|
it('should return rw for task owner', async () => {
|
||||||
|
const task = await Task.create({ name: 'T1', user_id: owner.id });
|
||||||
|
const access = await getAccess(owner.id, 'task', task.uid);
|
||||||
|
expect(access).toBe('rw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return none for non-owner task without permission', async () => {
|
||||||
|
const task = await Task.create({ name: 'T1', user_id: owner.id });
|
||||||
|
const access = await getAccess(otherUser.id, 'task', task.uid);
|
||||||
|
expect(access).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return none for non-existent task', async () => {
|
||||||
|
const access = await getAccess(owner.id, 'task', 'no-such-task');
|
||||||
|
expect(access).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should inherit task access from parent project permission', async () => {
|
||||||
|
const project = await Project.create({
|
||||||
|
name: 'P1',
|
||||||
|
user_id: owner.id,
|
||||||
|
});
|
||||||
|
const task = await Task.create({
|
||||||
|
name: 'T1',
|
||||||
|
user_id: owner.id,
|
||||||
|
project_id: project.id,
|
||||||
|
});
|
||||||
|
await Permission.create({
|
||||||
|
user_id: otherUser.id,
|
||||||
|
resource_type: 'project',
|
||||||
|
resource_uid: project.uid,
|
||||||
|
access_level: 'rw',
|
||||||
|
propagation: 'direct',
|
||||||
|
granted_by_user_id: owner.id,
|
||||||
|
});
|
||||||
|
const access = await getAccess(otherUser.id, 'task', task.uid);
|
||||||
|
expect(access).toBe('rw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return shared permission for directly shared task', async () => {
|
||||||
|
const task = await Task.create({ name: 'T1', user_id: owner.id });
|
||||||
|
await Permission.create({
|
||||||
|
user_id: otherUser.id,
|
||||||
|
resource_type: 'task',
|
||||||
|
resource_uid: task.uid,
|
||||||
|
access_level: 'ro',
|
||||||
|
propagation: 'direct',
|
||||||
|
granted_by_user_id: owner.id,
|
||||||
|
});
|
||||||
|
const access = await getAccess(otherUser.id, 'task', task.uid);
|
||||||
|
expect(access).toBe('ro');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Notes ---
|
||||||
|
|
||||||
|
it('should return rw for note owner', async () => {
|
||||||
|
const note = await Note.create({ title: 'N1', user_id: owner.id });
|
||||||
|
const access = await getAccess(owner.id, 'note', note.uid);
|
||||||
|
expect(access).toBe('rw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return none for non-owner note without permission', async () => {
|
||||||
|
const note = await Note.create({ title: 'N1', user_id: owner.id });
|
||||||
|
const access = await getAccess(otherUser.id, 'note', note.uid);
|
||||||
|
expect(access).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should inherit note access from parent project permission', async () => {
|
||||||
|
const project = await Project.create({
|
||||||
|
name: 'P1',
|
||||||
|
user_id: owner.id,
|
||||||
|
});
|
||||||
|
const note = await Note.create({
|
||||||
|
title: 'N1',
|
||||||
|
user_id: owner.id,
|
||||||
|
project_id: project.id,
|
||||||
|
});
|
||||||
|
await Permission.create({
|
||||||
|
user_id: otherUser.id,
|
||||||
|
resource_type: 'project',
|
||||||
|
resource_uid: project.uid,
|
||||||
|
access_level: 'ro',
|
||||||
|
propagation: 'direct',
|
||||||
|
granted_by_user_id: owner.id,
|
||||||
|
});
|
||||||
|
const access = await getAccess(otherUser.id, 'note', note.uid);
|
||||||
|
expect(access).toBe('ro');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSharedUidsForUser', () => {
|
||||||
|
it('should return empty array when no permissions exist', async () => {
|
||||||
|
const uids = await getSharedUidsForUser('project', otherUser.id);
|
||||||
|
expect(uids).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return shared resource uids', async () => {
|
||||||
|
const project = await Project.create({
|
||||||
|
name: 'P1',
|
||||||
|
user_id: owner.id,
|
||||||
|
});
|
||||||
|
await Permission.create({
|
||||||
|
user_id: otherUser.id,
|
||||||
|
resource_type: 'project',
|
||||||
|
resource_uid: project.uid,
|
||||||
|
access_level: 'ro',
|
||||||
|
propagation: 'direct',
|
||||||
|
granted_by_user_id: owner.id,
|
||||||
|
});
|
||||||
|
const uids = await getSharedUidsForUser('project', otherUser.id);
|
||||||
|
expect(uids).toContain(project.uid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deduplicate uids', async () => {
|
||||||
|
const project = await Project.create({
|
||||||
|
name: 'P1',
|
||||||
|
user_id: owner.id,
|
||||||
|
});
|
||||||
|
// Create two permissions for the same resource (different propagation)
|
||||||
|
await Permission.create({
|
||||||
|
user_id: otherUser.id,
|
||||||
|
resource_type: 'project',
|
||||||
|
resource_uid: project.uid,
|
||||||
|
access_level: 'ro',
|
||||||
|
propagation: 'direct',
|
||||||
|
granted_by_user_id: owner.id,
|
||||||
|
});
|
||||||
|
const uids = await getSharedUidsForUser('project', otherUser.id);
|
||||||
|
const uniqueUids = [...new Set(uids)];
|
||||||
|
expect(uids.length).toBe(uniqueUids.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ownershipOrPermissionWhere', () => {
|
||||||
|
it('should include user_id condition for owned resources', async () => {
|
||||||
|
const where = await ownershipOrPermissionWhere('project', owner.id);
|
||||||
|
expect(where).toBeDefined();
|
||||||
|
// Should contain an Op.or with user_id condition
|
||||||
|
const orKey = Object.getOwnPropertySymbols(where)[0];
|
||||||
|
expect(orKey).toBeDefined();
|
||||||
|
const conditions = where[orKey];
|
||||||
|
expect(conditions.some((c) => c.user_id === owner.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include shared resource uids when permissions exist', async () => {
|
||||||
|
const project = await Project.create({
|
||||||
|
name: 'Shared',
|
||||||
|
user_id: owner.id,
|
||||||
|
});
|
||||||
|
await Permission.create({
|
||||||
|
user_id: otherUser.id,
|
||||||
|
resource_type: 'project',
|
||||||
|
resource_uid: project.uid,
|
||||||
|
access_level: 'rw',
|
||||||
|
propagation: 'direct',
|
||||||
|
granted_by_user_id: owner.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const where = await ownershipOrPermissionWhere(
|
||||||
|
'project',
|
||||||
|
otherUser.id
|
||||||
|
);
|
||||||
|
const orKey = Object.getOwnPropertySymbols(where)[0];
|
||||||
|
const conditions = where[orKey];
|
||||||
|
// Should have a uid IN condition with the shared project uid
|
||||||
|
const uidCondition = conditions.find((c) => c.uid);
|
||||||
|
expect(uidCondition).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use cache when provided', async () => {
|
||||||
|
const cache = new Map();
|
||||||
|
const where1 = await ownershipOrPermissionWhere(
|
||||||
|
'project',
|
||||||
|
owner.id,
|
||||||
|
cache
|
||||||
|
);
|
||||||
|
const where2 = await ownershipOrPermissionWhere(
|
||||||
|
'project',
|
||||||
|
owner.id,
|
||||||
|
cache
|
||||||
|
);
|
||||||
|
expect(where1).toBe(where2); // same reference from cache
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include tasks from shared projects for task resource type', async () => {
|
||||||
|
const project = await Project.create({
|
||||||
|
name: 'P1',
|
||||||
|
user_id: owner.id,
|
||||||
|
});
|
||||||
|
await Task.create({
|
||||||
|
name: 'T1',
|
||||||
|
user_id: owner.id,
|
||||||
|
project_id: project.id,
|
||||||
|
});
|
||||||
|
await Permission.create({
|
||||||
|
user_id: otherUser.id,
|
||||||
|
resource_type: 'project',
|
||||||
|
resource_uid: project.uid,
|
||||||
|
access_level: 'rw',
|
||||||
|
propagation: 'direct',
|
||||||
|
granted_by_user_id: owner.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const where = await ownershipOrPermissionWhere(
|
||||||
|
'task',
|
||||||
|
otherUser.id
|
||||||
|
);
|
||||||
|
const orKey = Object.getOwnPropertySymbols(where)[0];
|
||||||
|
const conditions = where[orKey];
|
||||||
|
// Should have a project_id IN condition
|
||||||
|
const projectCondition = conditions.find((c) => c.project_id);
|
||||||
|
expect(projectCondition).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ACCESS constants', () => {
|
||||||
|
it('should export expected access levels', () => {
|
||||||
|
expect(ACCESS.NONE).toBe('none');
|
||||||
|
expect(ACCESS.RO).toBe('ro');
|
||||||
|
expect(ACCESS.RW).toBe('rw');
|
||||||
|
expect(ACCESS.ADMIN).toBe('admin');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
84
backend/tests/unit/services/rolesService.test.js
Normal file
84
backend/tests/unit/services/rolesService.test.js
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
const { isAdmin } = require('../../../services/rolesService');
|
||||||
|
const { User, Role, sequelize } = require('../../../models');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
|
||||||
|
describe('rolesService', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await sequelize.query('DELETE FROM roles');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAdmin', () => {
|
||||||
|
it('should return false for null uid', async () => {
|
||||||
|
expect(await isAdmin(null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for undefined uid', async () => {
|
||||||
|
expect(await isAdmin(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for empty string uid', async () => {
|
||||||
|
expect(await isAdmin('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when user does not exist', async () => {
|
||||||
|
expect(await isAdmin('nonexistent-uid')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for first user (auto-admin via afterCreate hook)', async () => {
|
||||||
|
const hash = await bcrypt.hash('pass', 10);
|
||||||
|
// First user created when no admin exists becomes admin automatically
|
||||||
|
const user = await User.create({
|
||||||
|
email: 'first@example.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
expect(await isAdmin(user.uid)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-first user (non-admin)', async () => {
|
||||||
|
const hash = await bcrypt.hash('pass', 10);
|
||||||
|
// First user becomes admin
|
||||||
|
await User.create({
|
||||||
|
email: 'first@example.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
// Second user is not admin
|
||||||
|
const second = await User.create({
|
||||||
|
email: 'second@example.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
expect(await isAdmin(second.uid)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when user role has is_admin=false', async () => {
|
||||||
|
const hash = await bcrypt.hash('pass', 10);
|
||||||
|
const user = await User.create({
|
||||||
|
email: 'demoted@example.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
// The hook created an admin role; update it to non-admin
|
||||||
|
await Role.update(
|
||||||
|
{ is_admin: false },
|
||||||
|
{ where: { user_id: user.id } }
|
||||||
|
);
|
||||||
|
expect(await isAdmin(user.uid)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when user role has is_admin=true', async () => {
|
||||||
|
const hash = await bcrypt.hash('pass', 10);
|
||||||
|
await User.create({
|
||||||
|
email: 'first@example.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
const user = await User.create({
|
||||||
|
email: 'promoted@example.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
// The hook created a non-admin role; update it to admin
|
||||||
|
await Role.update(
|
||||||
|
{ is_admin: true },
|
||||||
|
{ where: { user_id: user.id } }
|
||||||
|
);
|
||||||
|
expect(await isAdmin(user.uid)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
154
backend/tests/unit/shared/BaseRepository.test.js
Normal file
154
backend/tests/unit/shared/BaseRepository.test.js
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
const BaseRepository = require('../../../shared/database/BaseRepository');
|
||||||
|
const { User } = require('../../../models');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
|
||||||
|
describe('BaseRepository', () => {
|
||||||
|
let repo;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
repo = new BaseRepository(User);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create a record', async () => {
|
||||||
|
const hash = await bcrypt.hash('pass', 10);
|
||||||
|
const user = await repo.create({
|
||||||
|
email: 'base-repo@test.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
expect(user.id).toBeDefined();
|
||||||
|
expect(user.email).toBe('base-repo@test.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findById', () => {
|
||||||
|
it('should find a record by primary key', async () => {
|
||||||
|
const hash = await bcrypt.hash('pass', 10);
|
||||||
|
const created = await repo.create({
|
||||||
|
email: 'findbyid@test.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
const found = await repo.findById(created.id);
|
||||||
|
expect(found).not.toBeNull();
|
||||||
|
expect(found.email).toBe('findbyid@test.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for non-existent id', async () => {
|
||||||
|
const found = await repo.findById(999999);
|
||||||
|
expect(found).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findOne', () => {
|
||||||
|
it('should find a record by where clause', async () => {
|
||||||
|
const hash = await bcrypt.hash('pass', 10);
|
||||||
|
await repo.create({
|
||||||
|
email: 'findone@test.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
const found = await repo.findOne({ email: 'findone@test.com' });
|
||||||
|
expect(found).not.toBeNull();
|
||||||
|
expect(found.email).toBe('findone@test.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when no match', async () => {
|
||||||
|
const found = await repo.findOne({ email: 'nonexistent@test.com' });
|
||||||
|
expect(found).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findAll', () => {
|
||||||
|
it('should return all matching records', async () => {
|
||||||
|
const hash = await bcrypt.hash('pass', 10);
|
||||||
|
await repo.create({
|
||||||
|
email: 'findall1@test.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
await repo.create({
|
||||||
|
email: 'findall2@test.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
const all = await repo.findAll();
|
||||||
|
expect(all.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter with where clause', async () => {
|
||||||
|
const hash = await bcrypt.hash('pass', 10);
|
||||||
|
await repo.create({
|
||||||
|
email: 'specific@test.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
const results = await repo.findAll({ email: 'specific@test.com' });
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].email).toBe('specific@test.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('should update instance fields', async () => {
|
||||||
|
const hash = await bcrypt.hash('pass', 10);
|
||||||
|
const user = await repo.create({
|
||||||
|
email: 'update@test.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
await repo.update(user, { email: 'updated@test.com' });
|
||||||
|
await user.reload();
|
||||||
|
expect(user.email).toBe('updated@test.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('destroy', () => {
|
||||||
|
it('should delete a record', async () => {
|
||||||
|
const hash = await bcrypt.hash('pass', 10);
|
||||||
|
const user = await repo.create({
|
||||||
|
email: 'destroy@test.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
const id = user.id;
|
||||||
|
await repo.destroy(user);
|
||||||
|
const found = await repo.findById(id);
|
||||||
|
expect(found).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('count', () => {
|
||||||
|
it('should count all records', async () => {
|
||||||
|
const hash = await bcrypt.hash('pass', 10);
|
||||||
|
await repo.create({
|
||||||
|
email: 'count1@test.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
const c = await repo.count();
|
||||||
|
expect(c).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should count with where clause', async () => {
|
||||||
|
const hash = await bcrypt.hash('pass', 10);
|
||||||
|
await repo.create({
|
||||||
|
email: 'countfilter@test.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
const c = await repo.count({ email: 'countfilter@test.com' });
|
||||||
|
expect(c).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exists', () => {
|
||||||
|
it('should return true when record exists', async () => {
|
||||||
|
const hash = await bcrypt.hash('pass', 10);
|
||||||
|
await repo.create({
|
||||||
|
email: 'exists@test.com',
|
||||||
|
password_digest: hash,
|
||||||
|
});
|
||||||
|
const result = await repo.exists({ email: 'exists@test.com' });
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when record does not exist', async () => {
|
||||||
|
const result = await repo.exists({
|
||||||
|
email: 'doesnotexist@test.com',
|
||||||
|
});
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
166
backend/tests/unit/shared/errorHandler.test.js
Normal file
166
backend/tests/unit/shared/errorHandler.test.js
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
const errorHandler = require('../../../shared/middleware/errorHandler');
|
||||||
|
const {
|
||||||
|
AppError,
|
||||||
|
NotFoundError,
|
||||||
|
ValidationError,
|
||||||
|
ConflictError,
|
||||||
|
UnauthorizedError,
|
||||||
|
ForbiddenError,
|
||||||
|
} = require('../../../shared/errors');
|
||||||
|
|
||||||
|
describe('errorHandler middleware', () => {
|
||||||
|
let req, res, next;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
req = {};
|
||||||
|
res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
|
||||||
|
next = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- AppError subclasses ---
|
||||||
|
|
||||||
|
it('should handle NotFoundError with 404', () => {
|
||||||
|
const err = new NotFoundError('Task not found');
|
||||||
|
errorHandler(err, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(404);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Task not found',
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle ValidationError with 400', () => {
|
||||||
|
const err = new ValidationError('Title is required');
|
||||||
|
errorHandler(err, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Title is required',
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle ConflictError with 409', () => {
|
||||||
|
const err = new ConflictError();
|
||||||
|
errorHandler(err, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(409);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Resource already exists',
|
||||||
|
code: 'CONFLICT',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle UnauthorizedError with 401', () => {
|
||||||
|
const err = new UnauthorizedError();
|
||||||
|
errorHandler(err, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Unauthorized',
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle ForbiddenError with 403', () => {
|
||||||
|
const err = new ForbiddenError();
|
||||||
|
errorHandler(err, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Forbidden',
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle generic AppError with custom status', () => {
|
||||||
|
const err = new AppError('Rate limited', 429, 'RATE_LIMITED');
|
||||||
|
errorHandler(err, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(429);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Rate limited',
|
||||||
|
code: 'RATE_LIMITED',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Sequelize errors ---
|
||||||
|
|
||||||
|
it('should handle SequelizeValidationError with 400', () => {
|
||||||
|
const err = {
|
||||||
|
name: 'SequelizeValidationError',
|
||||||
|
errors: [
|
||||||
|
{ message: 'email must be unique' },
|
||||||
|
{ message: 'name cannot be null' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
errorHandler(err, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'email must be unique, name cannot be null',
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle SequelizeUniqueConstraintError with 409', () => {
|
||||||
|
const err = { name: 'SequelizeUniqueConstraintError' };
|
||||||
|
errorHandler(err, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(409);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'A resource with this identifier already exists.',
|
||||||
|
code: 'CONFLICT',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Unknown errors ---
|
||||||
|
|
||||||
|
it('should handle unknown errors with 500 in non-production', () => {
|
||||||
|
const originalEnv = process.env.NODE_ENV;
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
|
const err = new Error('Something unexpected');
|
||||||
|
errorHandler(err, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(500);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Something unexpected',
|
||||||
|
code: 'INTERNAL_ERROR',
|
||||||
|
});
|
||||||
|
|
||||||
|
process.env.NODE_ENV = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should hide error message in production', () => {
|
||||||
|
const originalEnv = process.env.NODE_ENV;
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
|
||||||
|
const err = new Error('Database credentials leaked');
|
||||||
|
errorHandler(err, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(500);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
error: 'Internal server error',
|
||||||
|
code: 'INTERNAL_ERROR',
|
||||||
|
});
|
||||||
|
|
||||||
|
process.env.NODE_ENV = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use statusCode from unknown error if present', () => {
|
||||||
|
const err = new Error('Bad gateway');
|
||||||
|
err.statusCode = 502;
|
||||||
|
errorHandler(err, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(502);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to 500 when no statusCode on unknown error', () => {
|
||||||
|
const err = new Error('Oops');
|
||||||
|
errorHandler(err, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
360
backend/tests/unit/utils/migration-utils.test.js
Normal file
360
backend/tests/unit/utils/migration-utils.test.js
Normal file
|
|
@ -0,0 +1,360 @@
|
||||||
|
const {
|
||||||
|
safeAddColumns,
|
||||||
|
safeCreateTable,
|
||||||
|
safeAddIndex,
|
||||||
|
safeRemoveColumn,
|
||||||
|
safeChangeColumn,
|
||||||
|
} = require('../../../utils/migration-utils');
|
||||||
|
|
||||||
|
describe('migration-utils', () => {
|
||||||
|
let queryInterface;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queryInterface = {
|
||||||
|
showAllTables: jest.fn(),
|
||||||
|
describeTable: jest.fn(),
|
||||||
|
addColumn: jest.fn(),
|
||||||
|
removeColumn: jest.fn(),
|
||||||
|
createTable: jest.fn(),
|
||||||
|
showIndex: jest.fn(),
|
||||||
|
addIndex: jest.fn(),
|
||||||
|
changeColumn: jest.fn(),
|
||||||
|
sequelize: {
|
||||||
|
getDialect: jest.fn().mockReturnValue('postgres'),
|
||||||
|
query: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('safeAddColumns', () => {
|
||||||
|
it('should skip when table does not exist', async () => {
|
||||||
|
queryInterface.showAllTables.mockResolvedValue(['other_table']);
|
||||||
|
await safeAddColumns(queryInterface, 'missing_table', [
|
||||||
|
{ name: 'col1', definition: { type: 'TEXT' } },
|
||||||
|
]);
|
||||||
|
expect(queryInterface.addColumn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add column when it does not exist', async () => {
|
||||||
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||||
|
queryInterface.describeTable.mockResolvedValue({
|
||||||
|
id: {},
|
||||||
|
title: {},
|
||||||
|
});
|
||||||
|
const definition = { type: 'TEXT' };
|
||||||
|
|
||||||
|
await safeAddColumns(queryInterface, 'tasks', [
|
||||||
|
{ name: 'description', definition },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(queryInterface.addColumn).toHaveBeenCalledWith(
|
||||||
|
'tasks',
|
||||||
|
'description',
|
||||||
|
definition
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip column when it already exists', async () => {
|
||||||
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||||
|
queryInterface.describeTable.mockResolvedValue({
|
||||||
|
id: {},
|
||||||
|
title: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await safeAddColumns(queryInterface, 'tasks', [
|
||||||
|
{ name: 'title', definition: { type: 'TEXT' } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(queryInterface.addColumn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add multiple columns, skipping existing ones', async () => {
|
||||||
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||||
|
queryInterface.describeTable.mockResolvedValue({
|
||||||
|
id: {},
|
||||||
|
title: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await safeAddColumns(queryInterface, 'tasks', [
|
||||||
|
{ name: 'title', definition: { type: 'TEXT' } }, // exists
|
||||||
|
{ name: 'priority', definition: { type: 'INTEGER' } }, // new
|
||||||
|
{ name: 'due_date', definition: { type: 'DATE' } }, // new
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(queryInterface.addColumn).toHaveBeenCalledTimes(2);
|
||||||
|
expect(queryInterface.addColumn).toHaveBeenCalledWith(
|
||||||
|
'tasks',
|
||||||
|
'priority',
|
||||||
|
{ type: 'INTEGER' }
|
||||||
|
);
|
||||||
|
expect(queryInterface.addColumn).toHaveBeenCalledWith(
|
||||||
|
'tasks',
|
||||||
|
'due_date',
|
||||||
|
{ type: 'DATE' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should re-throw errors from addColumn', async () => {
|
||||||
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||||
|
queryInterface.describeTable.mockResolvedValue({ id: {} });
|
||||||
|
queryInterface.addColumn.mockRejectedValue(new Error('DB error'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
safeAddColumns(queryInterface, 'tasks', [
|
||||||
|
{ name: 'col', definition: { type: 'TEXT' } },
|
||||||
|
])
|
||||||
|
).rejects.toThrow('DB error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('safeCreateTable', () => {
|
||||||
|
it('should create table when it does not exist', async () => {
|
||||||
|
queryInterface.showAllTables.mockResolvedValue([]);
|
||||||
|
const definition = { id: { type: 'INTEGER', primaryKey: true } };
|
||||||
|
|
||||||
|
await safeCreateTable(queryInterface, 'new_table', definition);
|
||||||
|
|
||||||
|
expect(queryInterface.createTable).toHaveBeenCalledWith(
|
||||||
|
'new_table',
|
||||||
|
definition
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip when table already exists', async () => {
|
||||||
|
queryInterface.showAllTables.mockResolvedValue(['existing_table']);
|
||||||
|
|
||||||
|
await safeCreateTable(queryInterface, 'existing_table', {});
|
||||||
|
|
||||||
|
expect(queryInterface.createTable).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should re-throw errors from createTable', async () => {
|
||||||
|
queryInterface.showAllTables.mockResolvedValue([]);
|
||||||
|
queryInterface.createTable.mockRejectedValue(
|
||||||
|
new Error('Create failed')
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
safeCreateTable(queryInterface, 'fail_table', {})
|
||||||
|
).rejects.toThrow('Create failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('safeAddIndex', () => {
|
||||||
|
it('should skip when table does not exist', async () => {
|
||||||
|
queryInterface.showAllTables.mockResolvedValue([]);
|
||||||
|
await safeAddIndex(queryInterface, 'missing_table', ['col1']);
|
||||||
|
expect(queryInterface.addIndex).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add index when it does not exist', async () => {
|
||||||
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||||
|
queryInterface.showIndex.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await safeAddIndex(queryInterface, 'tasks', ['user_id'], {
|
||||||
|
name: 'idx_user',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queryInterface.addIndex).toHaveBeenCalledWith(
|
||||||
|
'tasks',
|
||||||
|
['user_id'],
|
||||||
|
{ name: 'idx_user' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip when a matching index already exists', async () => {
|
||||||
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||||
|
queryInterface.showIndex.mockResolvedValue([
|
||||||
|
{ fields: [{ attribute: 'user_id' }] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await safeAddIndex(queryInterface, 'tasks', ['user_id']);
|
||||||
|
|
||||||
|
expect(queryInterface.addIndex).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw on addIndex failure (swallows error)', async () => {
|
||||||
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||||
|
queryInterface.showIndex.mockResolvedValue([]);
|
||||||
|
queryInterface.addIndex.mockRejectedValue(new Error('Index error'));
|
||||||
|
|
||||||
|
// safeAddIndex swallows errors (no throw in the catch)
|
||||||
|
await expect(
|
||||||
|
safeAddIndex(queryInterface, 'tasks', ['col'])
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('safeRemoveColumn', () => {
|
||||||
|
it('should skip when column does not exist', async () => {
|
||||||
|
queryInterface.describeTable.mockResolvedValue({
|
||||||
|
id: {},
|
||||||
|
title: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await safeRemoveColumn(queryInterface, 'tasks', 'nonexistent');
|
||||||
|
|
||||||
|
expect(queryInterface.removeColumn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove column on non-SQLite dialect', async () => {
|
||||||
|
queryInterface.describeTable.mockResolvedValue({
|
||||||
|
id: {},
|
||||||
|
title: {},
|
||||||
|
old_col: {},
|
||||||
|
});
|
||||||
|
queryInterface.sequelize.getDialect.mockReturnValue('postgres');
|
||||||
|
|
||||||
|
await safeRemoveColumn(queryInterface, 'tasks', 'old_col');
|
||||||
|
|
||||||
|
expect(queryInterface.removeColumn).toHaveBeenCalledWith(
|
||||||
|
'tasks',
|
||||||
|
'old_col'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use table recreation strategy on SQLite', async () => {
|
||||||
|
queryInterface.describeTable.mockResolvedValue({
|
||||||
|
id: {
|
||||||
|
type: 'INTEGER',
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
title: { type: 'TEXT', allowNull: true },
|
||||||
|
remove_me: { type: 'TEXT', allowNull: true },
|
||||||
|
});
|
||||||
|
queryInterface.sequelize.getDialect.mockReturnValue('sqlite');
|
||||||
|
|
||||||
|
await safeRemoveColumn(queryInterface, 'tasks', 'remove_me');
|
||||||
|
|
||||||
|
// Should have called multiple queries for SQLite recreation
|
||||||
|
const calls = queryInterface.sequelize.query.mock.calls.map(
|
||||||
|
(c) => c[0]
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
calls.some((q) => q.includes('PRAGMA foreign_keys = OFF'))
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
calls.some((q) => q.includes('CREATE TABLE tasks_new'))
|
||||||
|
).toBe(true);
|
||||||
|
expect(calls.some((q) => q.includes('INSERT INTO tasks_new'))).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(calls.some((q) => q.includes('DROP TABLE tasks'))).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
calls.some((q) =>
|
||||||
|
q.includes('ALTER TABLE tasks_new RENAME TO tasks')
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
calls.some((q) => q.includes('PRAGMA foreign_keys = ON'))
|
||||||
|
).toBe(true);
|
||||||
|
// remove_me should not appear in CREATE TABLE
|
||||||
|
const createCall = calls.find((q) =>
|
||||||
|
q.includes('CREATE TABLE tasks_new')
|
||||||
|
);
|
||||||
|
expect(createCall).not.toContain('remove_me');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should re-throw errors', async () => {
|
||||||
|
queryInterface.describeTable.mockRejectedValue(
|
||||||
|
new Error('Describe failed')
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
safeRemoveColumn(queryInterface, 'tasks', 'col')
|
||||||
|
).rejects.toThrow('Describe failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('safeChangeColumn', () => {
|
||||||
|
it('should skip when table does not exist', async () => {
|
||||||
|
queryInterface.showAllTables.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await safeChangeColumn(queryInterface, 'missing', 'col', {
|
||||||
|
type: 'TEXT',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queryInterface.changeColumn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip when column does not exist', async () => {
|
||||||
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||||
|
queryInterface.describeTable.mockResolvedValue({ id: {} });
|
||||||
|
|
||||||
|
await safeChangeColumn(queryInterface, 'tasks', 'nonexistent', {
|
||||||
|
type: 'TEXT',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queryInterface.changeColumn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should change column on non-SQLite dialect', async () => {
|
||||||
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||||
|
queryInterface.describeTable.mockResolvedValue({
|
||||||
|
id: {},
|
||||||
|
title: { type: 'TEXT', allowNull: true },
|
||||||
|
});
|
||||||
|
queryInterface.sequelize.getDialect.mockReturnValue('postgres');
|
||||||
|
const newDef = { type: 'VARCHAR(255)', allowNull: false };
|
||||||
|
|
||||||
|
await safeChangeColumn(queryInterface, 'tasks', 'title', newDef);
|
||||||
|
|
||||||
|
expect(queryInterface.changeColumn).toHaveBeenCalledWith(
|
||||||
|
'tasks',
|
||||||
|
'title',
|
||||||
|
newDef
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use table recreation on SQLite', async () => {
|
||||||
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||||
|
queryInterface.describeTable.mockResolvedValue({
|
||||||
|
id: {
|
||||||
|
type: 'INTEGER',
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
title: { type: 'TEXT', allowNull: true },
|
||||||
|
});
|
||||||
|
queryInterface.showIndex.mockResolvedValue([]);
|
||||||
|
queryInterface.sequelize.getDialect.mockReturnValue('sqlite');
|
||||||
|
|
||||||
|
await safeChangeColumn(queryInterface, 'tasks', 'title', {
|
||||||
|
type: { toSql: () => 'VARCHAR(255)' },
|
||||||
|
allowNull: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls = queryInterface.sequelize.query.mock.calls.map(
|
||||||
|
(c) => c[0]
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
calls.some((q) => q.includes('CREATE TABLE tasks_new'))
|
||||||
|
).toBe(true);
|
||||||
|
const createCall = calls.find((q) =>
|
||||||
|
q.includes('CREATE TABLE tasks_new')
|
||||||
|
);
|
||||||
|
expect(createCall).toContain('VARCHAR(255)');
|
||||||
|
expect(createCall).toContain('NOT NULL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should re-throw errors', async () => {
|
||||||
|
queryInterface.showAllTables.mockResolvedValue(['tasks']);
|
||||||
|
queryInterface.describeTable.mockResolvedValue({ id: {}, col: {} });
|
||||||
|
queryInterface.sequelize.getDialect.mockReturnValue('postgres');
|
||||||
|
queryInterface.changeColumn.mockRejectedValue(
|
||||||
|
new Error('Change failed')
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
safeChangeColumn(queryInterface, 'tasks', 'col', {
|
||||||
|
type: 'TEXT',
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Change failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
29
backend/tests/unit/utils/uid.test.js
Normal file
29
backend/tests/unit/utils/uid.test.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
const { uid } = require('../../../utils/uid');
|
||||||
|
|
||||||
|
describe('uid utility', () => {
|
||||||
|
it('should return a string', () => {
|
||||||
|
const id = uid();
|
||||||
|
expect(typeof id).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a 15-character string', () => {
|
||||||
|
const id = uid();
|
||||||
|
expect(id).toHaveLength(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate different ids on successive calls', () => {
|
||||||
|
const ids = new Set();
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
ids.add(uid());
|
||||||
|
}
|
||||||
|
expect(ids.size).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only contain characters from the allowed alphabet', () => {
|
||||||
|
// The allowed alphabet is '0123456789abcdefghijkmnpqrstuvwxyz'
|
||||||
|
// (no 'o' or 'l' to avoid ambiguity)
|
||||||
|
// Note: in test env nanoid is mocked, so this validates the mock contract
|
||||||
|
const id = uid();
|
||||||
|
expect(id).toMatch(/^[0-9a-z]+$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
350
docs/architecture.md
Normal file
350
docs/architecture.md
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
# Architecture Overview
|
||||||
|
|
||||||
|
[← Back to Index](../CLAUDE.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
### Frontend Stack
|
||||||
|
|
||||||
|
- **Framework:** React 18.3.1
|
||||||
|
- **Language:** TypeScript 5.6.2
|
||||||
|
- **Build Tool:** Webpack 5 with hot module replacement
|
||||||
|
- **Styling:** Tailwind CSS 3.4.13 + Heroicons
|
||||||
|
- **State Management:** Zustand 5.0.3 (global state), SWR 2.2.5 (server state)
|
||||||
|
- **Routing:** React Router DOM 6.26.2
|
||||||
|
- **Internationalization:** i18next + react-i18next (24 languages)
|
||||||
|
- **Charts/Analytics:** Recharts 2.15.4
|
||||||
|
- **Drag & Drop:** @dnd-kit (sortable tasks)
|
||||||
|
- **Development:** webpack-dev-server with proxy configuration
|
||||||
|
|
||||||
|
### Backend Stack
|
||||||
|
|
||||||
|
- **Framework:** Express.js 4.21.2
|
||||||
|
- **ORM:** Sequelize 6.37.7
|
||||||
|
- **Database:** SQLite 5.1.7 (WAL mode, memory-mapped I/O for performance)
|
||||||
|
- **Authentication:** bcrypt + express-session + connect-session-sequelize
|
||||||
|
- **Security:** Helmet, CORS, express-rate-limit
|
||||||
|
- **API Documentation:** Swagger (swagger-jsdoc + swagger-ui-express)
|
||||||
|
- **File Upload:** Multer
|
||||||
|
- **Email:** Nodemailer 7.0.10
|
||||||
|
- **Scheduling:** node-cron 4.1.0 (for recurring tasks)
|
||||||
|
- **Date/Time:** date-fns 4.1.0 + date-fns-tz
|
||||||
|
|
||||||
|
### Testing Stack
|
||||||
|
|
||||||
|
- **Backend:** Jest + Supertest + supertest-session
|
||||||
|
- **Frontend:** Jest + React Testing Library
|
||||||
|
- **E2E:** Playwright
|
||||||
|
- **Linting:** ESLint 8 + Prettier 3.6.2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request Flow
|
||||||
|
|
||||||
|
### Development Mode
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Development Flow │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Browser (http://localhost:8080) │
|
||||||
|
│ ↓ │
|
||||||
|
│ ┌─────────────────────┐ │
|
||||||
|
│ │ Webpack Dev Server │ │
|
||||||
|
│ │ Port: 8080 │ │
|
||||||
|
│ │ - Hot reload │ │
|
||||||
|
│ │ - Proxy /api/* │ │
|
||||||
|
│ │ - Proxy /locales/*│ │
|
||||||
|
│ └──────────┬──────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ │ Proxies requests to backend │
|
||||||
|
│ ↓ │
|
||||||
|
│ ┌─────────────────────┐ │
|
||||||
|
│ │ Express Server │ │
|
||||||
|
│ │ Port: 3002 │ │
|
||||||
|
│ │ - API endpoints │ │
|
||||||
|
│ │ - Session auth │ │
|
||||||
|
│ │ - Rate limiting │ │
|
||||||
|
│ └──────────┬──────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ↓ │
|
||||||
|
│ ┌─────────────────────┐ │
|
||||||
|
│ │ Sequelize ORM │ │
|
||||||
|
│ │ - Model layer │ │
|
||||||
|
│ │ - Relationships │ │
|
||||||
|
│ │ - Migrations │ │
|
||||||
|
│ └──────────┬──────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ↓ │
|
||||||
|
│ ┌─────────────────────┐ │
|
||||||
|
│ │ SQLite Database │ │
|
||||||
|
│ │ (database.sqlite) │ │
|
||||||
|
│ │ - WAL mode │ │
|
||||||
|
│ │ - Optimized I/O │ │
|
||||||
|
│ └─────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Mode
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Production Flow │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Browser → Express Server (Port 3002) │
|
||||||
|
│ ↓ │
|
||||||
|
│ ├─ Serves static files from /dist │
|
||||||
|
│ │ (compiled React app) │
|
||||||
|
│ │ │
|
||||||
|
│ └─ /api routes → Sequelize → SQLite │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model Hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────┐
|
||||||
|
│ User │
|
||||||
|
│ (Authentication, Settings, Timezone) │
|
||||||
|
└───────────────────┬────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ 1:N (One user has many areas)
|
||||||
|
↓
|
||||||
|
┌───────────────────────┐
|
||||||
|
│ Area │ (Optional organizational layer)
|
||||||
|
│ (e.g., Work, │
|
||||||
|
│ Personal, Health) │
|
||||||
|
└──────────┬────────────┘
|
||||||
|
│
|
||||||
|
│ 1:N (One area has many projects)
|
||||||
|
↓
|
||||||
|
┌───────────────────────┐ ┌──────────┐
|
||||||
|
│ Project │────────────────│ Tag │
|
||||||
|
│ (Website Redesign, │ N:M │ (urgent,│
|
||||||
|
│ Marketing Campaign) │ │ design, │
|
||||||
|
└──────────┬────────────┘ │ bug) │
|
||||||
|
│ └─────┬────┘
|
||||||
|
│ 1:N (One project has │
|
||||||
|
│ many tasks) │ N:M
|
||||||
|
↓ │
|
||||||
|
┌───────────────────────┐ │
|
||||||
|
│ Task │────────────────────┘
|
||||||
|
│ (Design Homepage, │ N:M (Tasks can
|
||||||
|
│ Write Blog Post) │ have tags)
|
||||||
|
└──────────┬────────────┘
|
||||||
|
│
|
||||||
|
│ 1:N (Task can have subtasks)
|
||||||
|
↓
|
||||||
|
┌───────────────────────┐
|
||||||
|
│ Subtask │ (Self-referential)
|
||||||
|
│ (Create mockup, │
|
||||||
|
│ Get feedback) │
|
||||||
|
└───────────────────────┘
|
||||||
|
|
||||||
|
|
||||||
|
Additional Relationships:
|
||||||
|
|
||||||
|
Project 1:N Note (One project has many notes)
|
||||||
|
Note N:M Tag (Notes can have tags)
|
||||||
|
|
||||||
|
Task → RecurringCompletion (Tracks completion history)
|
||||||
|
Task → TaskEvent (Audit log of all changes)
|
||||||
|
Task → TaskAttachment (File attachments)
|
||||||
|
|
||||||
|
Special Task Relationships:
|
||||||
|
- parent_task_id: Links subtasks to parent task
|
||||||
|
- recurring_parent_id: Links recurring instances to original pattern
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication & Authorization
|
||||||
|
|
||||||
|
### Dual Authentication Methods
|
||||||
|
|
||||||
|
**1. Session-Based (Primary - Web Interface)**
|
||||||
|
|
||||||
|
- Express session with Sequelize store
|
||||||
|
- HttpOnly cookies (30-day expiration)
|
||||||
|
- Session data stored in database table
|
||||||
|
- SameSite: 'lax' for CSRF protection
|
||||||
|
- Implementation: `/backend/middleware/auth.js`
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
1. User logs in with email/password
|
||||||
|
2. bcrypt verifies password hash
|
||||||
|
3. Session created and stored in database
|
||||||
|
4. Session cookie sent to browser
|
||||||
|
5. Subsequent requests include cookie
|
||||||
|
6. Middleware validates session and attaches user
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Bearer Token (API Access)**
|
||||||
|
|
||||||
|
- Personal API tokens for automation/integrations
|
||||||
|
- Format: `Authorization: Bearer YOUR_TOKEN`
|
||||||
|
- Token prefix: `tt_` + 64 hex characters
|
||||||
|
- Stored as bcrypt hash in database
|
||||||
|
- Optional expiration and abilities (JSON)
|
||||||
|
- Generated via web UI: Profile > API Tokens
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
1. User generates token in web interface
|
||||||
|
2. Token hash stored with prefix (first 12 chars)
|
||||||
|
3. API requests include: Authorization: Bearer tt_xxx...
|
||||||
|
4. Middleware validates token hash
|
||||||
|
5. Updates last_used_at timestamp
|
||||||
|
6. Attaches user to request
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission System
|
||||||
|
|
||||||
|
**Permission Levels:**
|
||||||
|
- `NONE` (0) - No access
|
||||||
|
- `RO` (1) - Read-only access
|
||||||
|
- `RW` (2) - Read-write access
|
||||||
|
- `ADMIN` (3) - Full administrative access
|
||||||
|
|
||||||
|
**Access Rules:**
|
||||||
|
|
||||||
|
1. **Ownership** → Automatic RW access
|
||||||
|
- User has RW for resources they created
|
||||||
|
|
||||||
|
2. **Project Sharing** → Granted via Permission model
|
||||||
|
- Permission record grants RO/RW/ADMIN to specific user
|
||||||
|
- resource_type: 'project', 'task', 'note'
|
||||||
|
- resource_uid: unique identifier
|
||||||
|
|
||||||
|
3. **Inheritance**
|
||||||
|
- Tasks inherit access from parent Project
|
||||||
|
- Notes inherit access from parent Project
|
||||||
|
- Subtasks inherit access from parent Task
|
||||||
|
|
||||||
|
**Authorization Middleware:**
|
||||||
|
```javascript
|
||||||
|
// Location: /backend/middleware/authorize.js
|
||||||
|
|
||||||
|
hasAccess(requiredAccess, resourceType, getResourceUid, options)
|
||||||
|
|
||||||
|
// Usage in routes:
|
||||||
|
router.get('/task/:id',
|
||||||
|
hasAccess('ro', 'task', (req) => req.params.id),
|
||||||
|
async (req, res) => { ... }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Modules Overview
|
||||||
|
|
||||||
|
### Backend Modules (19 total)
|
||||||
|
|
||||||
|
Located in `/backend/modules/`, each follows consistent architecture:
|
||||||
|
|
||||||
|
| Module | Purpose | Complexity |
|
||||||
|
|--------|---------|------------|
|
||||||
|
| **tasks** | Task management, subtasks, recurring | High - most complex module |
|
||||||
|
| **projects** | Project CRUD and organization | Medium |
|
||||||
|
| **areas** | Area categorization | Low |
|
||||||
|
| **notes** | Note-taking system | Medium |
|
||||||
|
| **tags** | Tagging system | Low |
|
||||||
|
| **users** | User management | Medium |
|
||||||
|
| **auth** | Authentication (login/register) | Medium |
|
||||||
|
| **shares** | Project sharing & permissions | High |
|
||||||
|
| **telegram** | Telegram bot integration | Medium |
|
||||||
|
| **inbox** | Quick capture inbox | Low |
|
||||||
|
| **habits** | Habit tracking | Medium |
|
||||||
|
| **notifications** | In-app notifications | Medium |
|
||||||
|
| **search** | Universal search | Medium |
|
||||||
|
| **views** | Saved custom views | Low |
|
||||||
|
| **admin** | Admin operations | Low |
|
||||||
|
| **backup** | Backup/restore functionality | High |
|
||||||
|
| **feature-flags** | Feature flag management | Low |
|
||||||
|
| **quotes** | Daily quotes | Low |
|
||||||
|
| **url** | URL handling | Low |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema Highlights
|
||||||
|
|
||||||
|
**20+ Sequelize Models** in `/backend/models/`
|
||||||
|
|
||||||
|
**Core Tables:**
|
||||||
|
- **Users** - Authentication, settings, preferences
|
||||||
|
- **Areas** - Organizational categories
|
||||||
|
- **Projects** - Project grouping
|
||||||
|
- **Tasks** - Main task entity (11 indexes for performance)
|
||||||
|
- **Notes** - Project notes
|
||||||
|
- **Tags** - Flexible tagging
|
||||||
|
- **Permissions** - Sharing and access control
|
||||||
|
- **ApiTokens** - API authentication
|
||||||
|
- **RecurringCompletions** - Recurring task history
|
||||||
|
- **TaskEvents** - Audit log
|
||||||
|
- **TaskAttachments** - File uploads
|
||||||
|
- **InboxItems** - Quick capture
|
||||||
|
- **Notifications** - User notifications
|
||||||
|
- **Roles** - User role system
|
||||||
|
- **Views** - Saved custom views
|
||||||
|
- **Backups** - Backup records
|
||||||
|
- **Settings** - Application config
|
||||||
|
- **Actions** - Audit trail
|
||||||
|
|
||||||
|
**Junction Tables (Many-to-Many):**
|
||||||
|
- tasks_tags
|
||||||
|
- notes_tags
|
||||||
|
- projects_tags
|
||||||
|
|
||||||
|
**Special Fields in Tasks:**
|
||||||
|
- `recurrence_type`: daily, weekly, monthly, monthly_weekday, monthly_last_day
|
||||||
|
- `recurrence_interval`: Custom intervals (every 2 weeks, etc.)
|
||||||
|
- `parent_task_id`: Links subtasks to parent (self-referential)
|
||||||
|
- `recurring_parent_id`: Links recurring instances to original pattern
|
||||||
|
|
||||||
|
**Performance Optimizations:**
|
||||||
|
- WAL (Write-Ahead Logging) mode
|
||||||
|
- PRAGMA synchronous=NORMAL
|
||||||
|
- 64MB cache size
|
||||||
|
- 256MB memory-mapped I/O
|
||||||
|
- Memory-based temp storage
|
||||||
|
- 11 indexes on Tasks table for slow I/O systems
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Architecture
|
||||||
|
|
||||||
|
**Component Organization:**
|
||||||
|
- Feature-based structure in `/frontend/components/`
|
||||||
|
- Shared components in `/frontend/components/Shared/`
|
||||||
|
- Each feature has dedicated directory (Task/, Project/, Area/, etc.)
|
||||||
|
|
||||||
|
**State Management:**
|
||||||
|
- **Global State:** Zustand store (`/frontend/store/useStore.ts`)
|
||||||
|
- Task cache
|
||||||
|
- Project cache
|
||||||
|
- UI state (modals, filters, selections)
|
||||||
|
- **Server State:** SWR for data fetching
|
||||||
|
- Automatic revalidation
|
||||||
|
- Optimistic updates
|
||||||
|
- Cache management
|
||||||
|
- **Local State:** React useState for component-specific data
|
||||||
|
|
||||||
|
**Key Frontend Patterns:**
|
||||||
|
- Functional components with hooks (no class components)
|
||||||
|
- TypeScript interfaces in `/frontend/entities/`
|
||||||
|
- API services in `/frontend/utils/[resource]Service.ts`
|
||||||
|
- Custom hooks in `/frontend/hooks/`
|
||||||
|
- Context providers in `/frontend/contexts/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Back to Index](../CLAUDE.md)
|
||||||
649
docs/backend-patterns.md
Normal file
649
docs/backend-patterns.md
Normal file
|
|
@ -0,0 +1,649 @@
|
||||||
|
# Backend Module Architecture
|
||||||
|
|
||||||
|
[← Back to Index](../CLAUDE.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Module Structure
|
||||||
|
|
||||||
|
All feature modules in `/backend/modules/` follow a consistent architecture pattern. This ensures code is organized, maintainable, and easy to navigate.
|
||||||
|
|
||||||
|
### Typical Module Directory
|
||||||
|
|
||||||
|
```
|
||||||
|
/backend/modules/[module-name]/
|
||||||
|
├── routes.js # Express router with endpoint definitions
|
||||||
|
├── repository.js # Data access layer (Sequelize queries)
|
||||||
|
├── operations/ # Business logic operations (optional)
|
||||||
|
│ ├── create.js
|
||||||
|
│ ├── update.js
|
||||||
|
│ ├── delete.js
|
||||||
|
│ └── ...
|
||||||
|
├── queries/ # Complex query builders (optional)
|
||||||
|
├── core/ # Core utilities (optional)
|
||||||
|
│ ├── serializers.js # Format data for API responses
|
||||||
|
│ ├── parsers.js # Parse request data
|
||||||
|
│ └── builders.js # Build database objects
|
||||||
|
├── middleware/ # Route-specific middleware (optional)
|
||||||
|
│ └── access.js # Access control
|
||||||
|
└── utils/ # Module-specific utilities
|
||||||
|
└── validation.js # Input validation
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Not all modules have all directories. Simpler modules may only have `routes.js` and basic logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example: Tasks Module (Complex)
|
||||||
|
|
||||||
|
The tasks module (`/backend/modules/tasks/`) is the most comprehensive example, showing the full potential of the module pattern:
|
||||||
|
|
||||||
|
```
|
||||||
|
/backend/modules/tasks/
|
||||||
|
├── routes.js # Main routes: GET /tasks, POST /task, etc.
|
||||||
|
├── repository.js # Data access: findTaskById, findTasksForUser
|
||||||
|
├── recurringTaskService.js # Recurring task pattern logic (8.5KB)
|
||||||
|
├── taskEventService.js # Task activity logging
|
||||||
|
├── taskScheduler.js # Cron-based task scheduling with node-cron
|
||||||
|
│
|
||||||
|
├── operations/ # Business logic operations
|
||||||
|
│ ├── list.js # List operations and filtering
|
||||||
|
│ ├── completion.js # Task completion/status changes
|
||||||
|
│ ├── recurring.js # Recurrence pattern handling
|
||||||
|
│ ├── subtasks.js # Subtask CRUD operations
|
||||||
|
│ ├── tags.js # Tag assignment to tasks
|
||||||
|
│ ├── grouping.js # Task grouping logic
|
||||||
|
│ ├── sorting.js # Sort order logic
|
||||||
|
│ └── parent-child.js # Parent-child relationship handling
|
||||||
|
│
|
||||||
|
├── queries/
|
||||||
|
│ ├── query-builders.js # filterTasksByParams, buildWhereClause
|
||||||
|
│ ├── metrics-queries.js # Task metrics and analytics queries
|
||||||
|
│ └── metrics-computation.js # Metric calculations and aggregations
|
||||||
|
│
|
||||||
|
├── core/
|
||||||
|
│ ├── serializers.js # serializeTask, serializeTasks
|
||||||
|
│ ├── builders.js # buildTaskAttributes for create/update
|
||||||
|
│ ├── parsers.js # parseTaskInput from requests
|
||||||
|
│ └── comparators.js # Detect task changes for audit log
|
||||||
|
│
|
||||||
|
├── middleware/
|
||||||
|
│ └── access.js # Task-specific access control
|
||||||
|
│
|
||||||
|
└── utils/
|
||||||
|
├── constants.js # Task-specific constants (status codes, etc.)
|
||||||
|
├── validation.js # Task input validation rules
|
||||||
|
└── logging.js # Change tracking helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example: Projects Module (Simpler)
|
||||||
|
|
||||||
|
The projects module (`/backend/modules/projects/`) follows the same pattern but with less complexity:
|
||||||
|
|
||||||
|
```
|
||||||
|
/backend/modules/projects/
|
||||||
|
├── routes.js # Project CRUD endpoints
|
||||||
|
├── repository.js # Project data access (findAll, findById, create, etc.)
|
||||||
|
└── utils/
|
||||||
|
└── validation.js # Project validation (name required, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
This shows that modules scale based on complexity - simple features don't require the full directory structure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module Pattern Details
|
||||||
|
|
||||||
|
### routes.js - Express Router
|
||||||
|
|
||||||
|
**Purpose:** Define HTTP endpoints for the module
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
```javascript
|
||||||
|
// /backend/modules/[module]/routes.js
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const repository = require('./repository');
|
||||||
|
const { hasAccess } = require('../../middleware/authorize');
|
||||||
|
|
||||||
|
// List all resources (collection)
|
||||||
|
router.get('/[resources]', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const items = await repository.findAll(req.currentUser.id, req.query);
|
||||||
|
res.json(items);
|
||||||
|
} catch (error) {
|
||||||
|
next(error); // Pass to global error handler
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new resource (singular)
|
||||||
|
router.post('/[resource]', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const item = await repository.create(req.body, req.currentUser.id);
|
||||||
|
res.status(201).json(item);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single resource (with authorization)
|
||||||
|
router.get('/[resource]/:id',
|
||||||
|
hasAccess('ro', '[resource]', (req) => req.params.id),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const item = await repository.findById(req.params.id, req.currentUser.id);
|
||||||
|
if (!item) {
|
||||||
|
return res.status(404).json({ error: 'Not found' });
|
||||||
|
}
|
||||||
|
res.json(item);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update resource (with authorization)
|
||||||
|
router.put('/[resource]/:id',
|
||||||
|
hasAccess('rw', '[resource]', (req) => req.params.id),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const updated = await repository.update(req.params.id, req.body, req.currentUser.id);
|
||||||
|
res.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete resource
|
||||||
|
router.delete('/[resource]/:id',
|
||||||
|
hasAccess('rw', '[resource]', (req) => req.params.id),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await repository.destroy(req.params.id, req.currentUser.id);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Conventions:**
|
||||||
|
- Plural for collections: `GET /tasks`
|
||||||
|
- Singular for single resource: `GET /task/:id`, `POST /task`
|
||||||
|
- Always use try/catch
|
||||||
|
- Pass errors to `next(error)` for global error handler
|
||||||
|
- Use `hasAccess()` middleware for authorization
|
||||||
|
- Return proper HTTP status codes (200, 201, 204, 404, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### repository.js - Data Access Layer
|
||||||
|
|
||||||
|
**Purpose:** Abstract database queries from routes
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
```javascript
|
||||||
|
// /backend/modules/[module]/repository.js
|
||||||
|
const { Model } = require('../../models');
|
||||||
|
|
||||||
|
async function findAll(userId, filters = {}) {
|
||||||
|
return await Model.findAll({
|
||||||
|
where: {
|
||||||
|
user_id: userId,
|
||||||
|
...buildWhereClause(filters)
|
||||||
|
},
|
||||||
|
include: [...],
|
||||||
|
order: [['created_at', 'DESC']]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findById(id, userId) {
|
||||||
|
return await Model.findOne({
|
||||||
|
where: { id, user_id: userId },
|
||||||
|
include: [...]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(data, userId) {
|
||||||
|
return await Model.create({
|
||||||
|
...data,
|
||||||
|
user_id: userId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id, data, userId) {
|
||||||
|
const instance = await findById(id, userId);
|
||||||
|
if (!instance) {
|
||||||
|
throw new NotFoundError('Resource not found');
|
||||||
|
}
|
||||||
|
return await instance.update(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function destroy(id, userId) {
|
||||||
|
const instance = await findById(id, userId);
|
||||||
|
if (!instance) {
|
||||||
|
throw new NotFoundError('Resource not found');
|
||||||
|
}
|
||||||
|
await instance.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
findAll,
|
||||||
|
findById,
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
destroy
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why Repository Pattern:**
|
||||||
|
- Separates data access from business logic
|
||||||
|
- Makes testing easier (can mock repository)
|
||||||
|
- Centralizes query logic
|
||||||
|
- Prevents Model usage directly in routes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### core/serializers.js - Response Formatting
|
||||||
|
|
||||||
|
**Purpose:** Transform database objects into API-friendly format
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
```javascript
|
||||||
|
// /backend/modules/[module]/core/serializers.js
|
||||||
|
|
||||||
|
function serializeItem(item) {
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
uid: item.uid,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
created_at: item.created_at,
|
||||||
|
updated_at: item.updated_at,
|
||||||
|
// Include associations if loaded
|
||||||
|
tags: item.Tags ? item.Tags.map(serializeTag) : undefined,
|
||||||
|
user: item.User ? serializeUser(item.User) : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeItems(items) {
|
||||||
|
return items.map(serializeItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
serializeItem,
|
||||||
|
serializeItems
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage in routes:**
|
||||||
|
```javascript
|
||||||
|
const { serializeItem, serializeItems } = require('./core/serializers');
|
||||||
|
|
||||||
|
router.get('/items', async (req, res) => {
|
||||||
|
const items = await repository.findAll(req.currentUser.id);
|
||||||
|
res.json(serializeItems(items)); // Transform before sending
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### core/builders.js - Object Construction
|
||||||
|
|
||||||
|
**Purpose:** Build objects for database creation/update from request data
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
```javascript
|
||||||
|
// /backend/modules/[module]/core/builders.js
|
||||||
|
|
||||||
|
function buildItemAttributes(data, userId) {
|
||||||
|
const attributes = {
|
||||||
|
user_id: userId,
|
||||||
|
name: data.name?.trim(),
|
||||||
|
description: data.description?.trim() || null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optional fields
|
||||||
|
if (data.due_date) {
|
||||||
|
attributes.due_date = parseDate(data.due_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.priority !== undefined) {
|
||||||
|
attributes.priority = parseInt(data.priority, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildItemAttributes };
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```javascript
|
||||||
|
const { buildItemAttributes } = require('./core/builders');
|
||||||
|
|
||||||
|
router.post('/item', async (req, res) => {
|
||||||
|
const attributes = buildItemAttributes(req.body, req.currentUser.id);
|
||||||
|
const item = await repository.create(attributes);
|
||||||
|
res.status(201).json(serializeItem(item));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### operations/ - Business Logic
|
||||||
|
|
||||||
|
**Purpose:** Complex operations that don't fit in simple CRUD
|
||||||
|
|
||||||
|
**Example: operations/completion.js (from tasks module)**
|
||||||
|
```javascript
|
||||||
|
// /backend/modules/tasks/operations/completion.js
|
||||||
|
|
||||||
|
async function completeTask(taskId, userId, completionData) {
|
||||||
|
// Get task with associations
|
||||||
|
const task = await repository.findById(taskId, userId);
|
||||||
|
|
||||||
|
if (task.recurrence_type) {
|
||||||
|
// Handle recurring task completion
|
||||||
|
await recurringTaskService.handleCompletion(task, completionData);
|
||||||
|
} else {
|
||||||
|
// Simple completion
|
||||||
|
task.status = 2; // completed
|
||||||
|
task.completed_at = new Date();
|
||||||
|
await task.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log event
|
||||||
|
await taskEventService.logEvent(taskId, 'completed', userId);
|
||||||
|
|
||||||
|
// Update related subtasks
|
||||||
|
if (completionData.completeSubtasks) {
|
||||||
|
await completeSubtasks(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { completeTask };
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module Communication
|
||||||
|
|
||||||
|
### Accessing Other Modules
|
||||||
|
|
||||||
|
Modules can import other module repositories and services:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In /backend/modules/projects/routes.js
|
||||||
|
|
||||||
|
// Import task repository from tasks module
|
||||||
|
const taskRepository = require('../tasks/repository');
|
||||||
|
|
||||||
|
// Import shared service
|
||||||
|
const permissionsService = require('../../services/permissionsService');
|
||||||
|
|
||||||
|
router.get('/project/:id/tasks', async (req, res) => {
|
||||||
|
// Use task repository
|
||||||
|
const tasks = await taskRepository.findTasksForProject(req.params.id);
|
||||||
|
res.json(tasks);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avoid Circular Dependencies
|
||||||
|
|
||||||
|
**Bad:**
|
||||||
|
```javascript
|
||||||
|
// Module A imports Module B
|
||||||
|
const moduleB = require('../moduleB');
|
||||||
|
|
||||||
|
// Module B imports Module A
|
||||||
|
const moduleA = require('../moduleA'); // CIRCULAR!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Good:**
|
||||||
|
```javascript
|
||||||
|
// Extract shared logic to /backend/services/
|
||||||
|
// Both modules import from services
|
||||||
|
const sharedService = require('../../services/sharedService');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Add a New Module
|
||||||
|
|
||||||
|
**Example: Creating a "labels" module**
|
||||||
|
|
||||||
|
### Step 1: Create Directory Structure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /Users/chris/c0deLab/ProjectLand/tududi/backend/modules/labels
|
||||||
|
mkdir -p /Users/chris/c0deLab/ProjectLand/tududi/backend/modules/labels/utils
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Create routes.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// /backend/modules/labels/routes.js
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const repository = require('./repository');
|
||||||
|
|
||||||
|
router.get('/labels', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const labels = await repository.findAll(req.currentUser.id);
|
||||||
|
res.json(labels);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/label', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const label = await repository.create(req.body, req.currentUser.id);
|
||||||
|
res.status(201).json(label);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/label/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const label = await repository.update(req.params.id, req.body, req.currentUser.id);
|
||||||
|
res.json(label);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/label/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await repository.destroy(req.params.id, req.currentUser.id);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Create repository.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// /backend/modules/labels/repository.js
|
||||||
|
const { Label } = require('../../models');
|
||||||
|
|
||||||
|
async function findAll(userId) {
|
||||||
|
return await Label.findAll({
|
||||||
|
where: { user_id: userId },
|
||||||
|
order: [['name', 'ASC']]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findById(id, userId) {
|
||||||
|
return await Label.findOne({
|
||||||
|
where: { id, user_id: userId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(data, userId) {
|
||||||
|
return await Label.create({
|
||||||
|
name: data.name,
|
||||||
|
color: data.color || '#gray',
|
||||||
|
user_id: userId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id, data, userId) {
|
||||||
|
const label = await findById(id, userId);
|
||||||
|
if (!label) {
|
||||||
|
throw new Error('Label not found');
|
||||||
|
}
|
||||||
|
return await label.update({
|
||||||
|
name: data.name,
|
||||||
|
color: data.color
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function destroy(id, userId) {
|
||||||
|
const label = await findById(id, userId);
|
||||||
|
if (!label) {
|
||||||
|
throw new Error('Label not found');
|
||||||
|
}
|
||||||
|
await label.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
findAll,
|
||||||
|
findById,
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
destroy
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Create Model
|
||||||
|
|
||||||
|
See [Database documentation](database.md) for creating models and migrations.
|
||||||
|
|
||||||
|
### Step 5: Register Routes in app.js
|
||||||
|
|
||||||
|
Edit `/backend/app.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Add with other module registrations (around line 50-70)
|
||||||
|
|
||||||
|
// Labels module
|
||||||
|
app.use('/api/v1', require('./modules/labels/routes'));
|
||||||
|
app.use('/api', require('./modules/labels/routes')); // Backward compatibility
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Add Swagger Documentation
|
||||||
|
|
||||||
|
Edit `/backend/config/swagger.js` to add Label schema:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
components: {
|
||||||
|
schemas: {
|
||||||
|
// ... existing schemas ...
|
||||||
|
Label: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'integer' },
|
||||||
|
uid: { type: 'string' },
|
||||||
|
name: { type: 'string' },
|
||||||
|
color: { type: 'string' },
|
||||||
|
created_at: { type: 'string', format: 'date-time' },
|
||||||
|
updated_at: { type: 'string', format: 'date-time' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 7: Write Tests
|
||||||
|
|
||||||
|
Create `/backend/tests/integration/labels/labels.test.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const request = require('supertest');
|
||||||
|
const app = require('../../../app');
|
||||||
|
const { Label, User } = require('../../../models');
|
||||||
|
|
||||||
|
describe('Labels API', () => {
|
||||||
|
let user, authCookie;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
user = await User.create({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'password123'
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/login')
|
||||||
|
.send({ email: 'test@example.com', password: 'password123' });
|
||||||
|
authCookie = res.headers['set-cookie'];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Label.destroy({ where: {} });
|
||||||
|
await User.destroy({ where: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create label', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/v1/label')
|
||||||
|
.set('Cookie', authCookie)
|
||||||
|
.send({ name: 'Important', color: '#red' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body.name).toBe('Important');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should list user labels', async () => {
|
||||||
|
await Label.create({ name: 'Label 1', user_id: user.id });
|
||||||
|
await Label.create({ name: 'Label 2', user_id: user.id });
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/api/v1/labels')
|
||||||
|
.set('Cookie', authCookie);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 8: Create Frontend Integration (Optional)
|
||||||
|
|
||||||
|
Create `/frontend/utils/labelsService.ts` and components as needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module Checklist
|
||||||
|
|
||||||
|
When adding a new module, ensure:
|
||||||
|
|
||||||
|
- [ ] Directory created in `/backend/modules/[name]/`
|
||||||
|
- [ ] `routes.js` with all necessary endpoints
|
||||||
|
- [ ] `repository.js` with data access methods
|
||||||
|
- [ ] Model created (if new database table needed)
|
||||||
|
- [ ] Migration created and run
|
||||||
|
- [ ] Routes registered in `/backend/app.js`
|
||||||
|
- [ ] Swagger schema added
|
||||||
|
- [ ] Integration tests written
|
||||||
|
- [ ] Documentation updated (if public feature)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Back to Index](../CLAUDE.md)
|
||||||
509
docs/code-conventions.md
Normal file
509
docs/code-conventions.md
Normal file
|
|
@ -0,0 +1,509 @@
|
||||||
|
# Code Conventions & Patterns
|
||||||
|
|
||||||
|
[← Back to Index](../CLAUDE.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Language Usage
|
||||||
|
|
||||||
|
- **Frontend:** TypeScript (`.tsx`, `.ts` files)
|
||||||
|
- **Backend:** JavaScript with optional JSDoc types (`.js` files)
|
||||||
|
- **tsconfig.json:** Frontend only, `"strict": false`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Patterns
|
||||||
|
|
||||||
|
### 1. Async/Await (No Callbacks)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ Good - Use async/await
|
||||||
|
async function getTasks(userId) {
|
||||||
|
const tasks = await Task.findAll({ where: { user_id: userId } });
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ Bad - No callbacks
|
||||||
|
function getTasks(userId, callback) {
|
||||||
|
Task.findAll({ where: { user_id: userId } })
|
||||||
|
.then(tasks => callback(null, tasks))
|
||||||
|
.catch(err => callback(err));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Repository Pattern
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ Good - Use repository
|
||||||
|
// In routes.js
|
||||||
|
const repository = require('./repository');
|
||||||
|
const task = await repository.findTaskById(req.params.id, req.currentUser.id);
|
||||||
|
|
||||||
|
// ❌ Bad - Don't access Model directly in routes
|
||||||
|
const { Task } = require('../../models');
|
||||||
|
const task = await Task.findByPk(req.params.id);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Repository pattern separates data access from business logic, making testing easier and centralizing query logic.
|
||||||
|
|
||||||
|
### 3. Error Handling
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In routes.js
|
||||||
|
router.get('/task/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const task = await repository.findTaskById(req.params.id, req.currentUser.id);
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return res.status(404).json({ error: 'Task not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(task);
|
||||||
|
} catch (error) {
|
||||||
|
next(error); // Pass to global error handler
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Always:**
|
||||||
|
- Use try/catch in route handlers
|
||||||
|
- Pass errors to `next(error)` for global error handler
|
||||||
|
- Return proper HTTP status codes
|
||||||
|
- Use custom error classes from `/backend/shared/errors/`
|
||||||
|
|
||||||
|
### 4. Service Pattern
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Create singleton service or class
|
||||||
|
class TaskService {
|
||||||
|
async create(data, userId) {
|
||||||
|
// Validation
|
||||||
|
if (!data.name) {
|
||||||
|
throw new ValidationError('Task name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Business logic
|
||||||
|
const task = await Task.create({ ...data, user_id: userId });
|
||||||
|
|
||||||
|
// Additional operations
|
||||||
|
await taskEventService.logEvent(task.id, 'created');
|
||||||
|
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new TaskService(); // Export singleton
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Patterns
|
||||||
|
|
||||||
|
### 1. Functional Components with Hooks
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - Functional component
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface TaskItemProps {
|
||||||
|
task: Task;
|
||||||
|
onUpdate: (task: Task) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TaskItem: React.FC<TaskItemProps> = ({ task, onUpdate }) => {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Side effects here
|
||||||
|
}, [task]);
|
||||||
|
|
||||||
|
return <div>{task.name}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ❌ Bad - Class components (legacy pattern)
|
||||||
|
class TaskItem extends React.Component { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. State Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Global state - Zustand
|
||||||
|
import { useStore } from '../store/useStore';
|
||||||
|
|
||||||
|
const TaskList = () => {
|
||||||
|
const tasks = useStore(state => state.tasks);
|
||||||
|
const setTasks = useStore(state => state.setTasks);
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
|
||||||
|
// Server state - SWR
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { getTasks } from '../utils/tasksService';
|
||||||
|
|
||||||
|
const TaskList = () => {
|
||||||
|
const { data: tasks, error, mutate } = useSWR('/api/v1/tasks', getTasks);
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
|
||||||
|
// Local component state - useState
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Styling - Tailwind CSS
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - Tailwind utility classes
|
||||||
|
<div className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
{task.name}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Conditional classes
|
||||||
|
<div className={`p-4 ${task.completed ? 'opacity-50 line-through' : ''}`}>
|
||||||
|
{task.name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Complex conditionals - use clsx or classnames
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
<div className={clsx(
|
||||||
|
'p-4 rounded-lg',
|
||||||
|
task.completed && 'opacity-50 line-through',
|
||||||
|
task.priority === 2 && 'border-l-4 border-red-500'
|
||||||
|
)}>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
| Type | Convention | Example |
|
||||||
|
|------|------------|---------|
|
||||||
|
| **Files** | kebab-case | `recurring-task-service.js`, `task-item.tsx` |
|
||||||
|
| **React Components** | PascalCase | `TaskItem.tsx`, `ProjectForm.tsx` |
|
||||||
|
| **Functions** | camelCase | `findTaskById`, `createTask`, `handleSubmit` |
|
||||||
|
| **Classes** | PascalCase | `TaskService`, `BaseRepository` |
|
||||||
|
| **Constants** | UPPER_SNAKE_CASE | `API_VERSION`, `MAX_FILE_SIZE` |
|
||||||
|
| **Variables** | camelCase | `userId`, `taskList`, `isCompleted` |
|
||||||
|
| **Database Tables** | PascalCase | `Tasks`, `Projects`, `Users` |
|
||||||
|
| **Database Columns** | snake_case | `user_id`, `due_date`, `created_at` |
|
||||||
|
| **Interfaces (TS)** | PascalCase | `TaskProps`, `User`, `ApiResponse` |
|
||||||
|
| **Type Aliases (TS)** | PascalCase | `TaskStatus`, `Priority` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Route Conventions
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Singular for single resource operations
|
||||||
|
POST /api/v1/task // Create new task
|
||||||
|
GET /api/v1/task/:id // Get task by ID
|
||||||
|
PUT /api/v1/task/:id // Update task
|
||||||
|
DELETE /api/v1/task/:id // Delete task
|
||||||
|
|
||||||
|
// Plural for collection operations
|
||||||
|
GET /api/v1/tasks // List all tasks
|
||||||
|
GET /api/v1/tasks/today // Filtered list
|
||||||
|
GET /api/v1/tasks/upcoming // Another filtered list
|
||||||
|
|
||||||
|
// UID support (alternative to numeric ID)
|
||||||
|
GET /api/v1/task/uid/:uid // Get by UID
|
||||||
|
|
||||||
|
// Nested resources
|
||||||
|
GET /api/v1/project/:id/tasks // Tasks for a project
|
||||||
|
POST /api/v1/task/:id/tags // Add tags to task
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTP Status Codes
|
||||||
|
|
||||||
|
Use appropriate status codes:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Success
|
||||||
|
200 OK // Successful GET, PUT
|
||||||
|
201 Created // Successful POST
|
||||||
|
204 No Content // Successful DELETE
|
||||||
|
|
||||||
|
// Client errors
|
||||||
|
400 Bad Request // Invalid input
|
||||||
|
401 Unauthorized // Not authenticated
|
||||||
|
403 Forbidden // Not authorized
|
||||||
|
404 Not Found // Resource doesn't exist
|
||||||
|
409 Conflict // Duplicate resource
|
||||||
|
|
||||||
|
// Server errors
|
||||||
|
500 Internal Server Error // Unexpected error
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```javascript
|
||||||
|
// Success cases
|
||||||
|
res.json(task); // 200 (default)
|
||||||
|
res.status(201).json(task); // 201 for create
|
||||||
|
res.status(204).send(); // 204 for delete
|
||||||
|
|
||||||
|
// Error cases
|
||||||
|
res.status(400).json({ error: 'Invalid input' });
|
||||||
|
res.status(404).json({ error: 'Not found' });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit Message Style
|
||||||
|
|
||||||
|
Based on git log history:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use imperative mood
|
||||||
|
git commit -m "Fix sidebar toggle causing unnecessary reload"
|
||||||
|
git commit -m "Add priority level field to tasks"
|
||||||
|
git commit -m "Update database migration for recurring tasks"
|
||||||
|
|
||||||
|
# Reference issues (if applicable)
|
||||||
|
git commit -m "Fix subtask completion not persisting (#920)"
|
||||||
|
|
||||||
|
# Prefix releases
|
||||||
|
git commit -m "release: v0.89.0"
|
||||||
|
|
||||||
|
# Multi-line for complex changes
|
||||||
|
git commit -m "Add estimated time feature
|
||||||
|
|
||||||
|
- Add database migration for estimated_time field
|
||||||
|
- Update Task model and serializers
|
||||||
|
- Update Swagger documentation
|
||||||
|
- Add UI field in TaskForm component
|
||||||
|
- Add validation for positive values
|
||||||
|
- Add integration tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guidelines:**
|
||||||
|
- Start with a verb in imperative mood (Add, Fix, Update, Remove)
|
||||||
|
- Keep first line under 72 characters
|
||||||
|
- Reference issue numbers with (#123)
|
||||||
|
- Use body for detailed explanations (if needed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Documentation
|
||||||
|
|
||||||
|
### JSDoc for Backend Functions
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* Find all tasks for a user with optional filtering
|
||||||
|
*
|
||||||
|
* @param {number} userId - The user ID
|
||||||
|
* @param {Object} filters - Optional filters
|
||||||
|
* @param {string} [filters.status] - Filter by status
|
||||||
|
* @param {string} [filters.priority] - Filter by priority
|
||||||
|
* @returns {Promise<Task[]>} Array of tasks
|
||||||
|
*/
|
||||||
|
async function findAllTasks(userId, filters = {}) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript Interfaces
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Task entity representing a single task
|
||||||
|
*/
|
||||||
|
export interface Task {
|
||||||
|
/** Unique numeric ID */
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
/** Unique string identifier */
|
||||||
|
uid: string;
|
||||||
|
|
||||||
|
/** Task name/title */
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** Optional due date */
|
||||||
|
due_date: string | null;
|
||||||
|
|
||||||
|
/** Priority: 0 (low), 1 (medium), 2 (high) */
|
||||||
|
priority: 0 | 1 | 2;
|
||||||
|
|
||||||
|
/** Current status */
|
||||||
|
status: TaskStatus;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Organization
|
||||||
|
|
||||||
|
### Backend Module Files
|
||||||
|
|
||||||
|
```
|
||||||
|
/backend/modules/[module]/
|
||||||
|
├── routes.js # Keep routes simple, delegate to operations
|
||||||
|
├── repository.js # Only database queries
|
||||||
|
├── operations/ # Complex business logic
|
||||||
|
├── core/
|
||||||
|
│ ├── serializers.js # Only formatting
|
||||||
|
│ ├── builders.js # Only object construction
|
||||||
|
│ └── parsers.js # Only parsing
|
||||||
|
└── utils/
|
||||||
|
└── validation.js # Only validation
|
||||||
|
```
|
||||||
|
|
||||||
|
**Principle:** Single Responsibility - each file has one clear purpose
|
||||||
|
|
||||||
|
### Frontend Component Files
|
||||||
|
|
||||||
|
```
|
||||||
|
/frontend/components/Task/
|
||||||
|
├── TaskItem.tsx # Single task display
|
||||||
|
├── TaskList.tsx # List of tasks
|
||||||
|
├── TaskForm.tsx # Task creation/editing form
|
||||||
|
├── TaskFilters.tsx # Filter controls
|
||||||
|
└── SubtaskList.tsx # Subtask-specific component
|
||||||
|
```
|
||||||
|
|
||||||
|
**Principle:** Feature-based organization, components stay focused
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Conventions
|
||||||
|
|
||||||
|
### Test File Naming
|
||||||
|
|
||||||
|
```
|
||||||
|
// Backend
|
||||||
|
/backend/tests/unit/services/taskService.test.js
|
||||||
|
/backend/tests/integration/tasks/tasks.test.js
|
||||||
|
|
||||||
|
// Frontend
|
||||||
|
/frontend/components/Task/__tests__/TaskItem.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
describe('Feature or Component', () => {
|
||||||
|
// Setup
|
||||||
|
beforeEach(() => {
|
||||||
|
// Arrange common setup
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
afterEach(() => {
|
||||||
|
// Clean up
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do specific thing', () => {
|
||||||
|
// Arrange
|
||||||
|
const input = 'test';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = functionUnderTest(input);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toBe('expected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
### Input Validation
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ Always validate user input
|
||||||
|
if (!data.name || typeof data.name !== 'string') {
|
||||||
|
throw new ValidationError('Name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.priority !== undefined) {
|
||||||
|
const priority = parseInt(data.priority, 10);
|
||||||
|
if (isNaN(priority) || priority < 0 || priority > 2) {
|
||||||
|
throw new ValidationError('Invalid priority');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SQL Injection Prevention
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ Good - Sequelize handles parameterization
|
||||||
|
const tasks = await Task.findAll({
|
||||||
|
where: { user_id: userId, name: { [Op.like]: `%${searchTerm}%` } }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ❌ Bad - Raw queries (if absolutely necessary, use replacements)
|
||||||
|
await sequelize.query(
|
||||||
|
'SELECT * FROM Tasks WHERE user_id = :userId',
|
||||||
|
{
|
||||||
|
replacements: { userId },
|
||||||
|
type: QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Password Handling
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ Always hash passwords with bcrypt
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
// ❌ Never store plain text passwords
|
||||||
|
user.password = password; // NEVER DO THIS
|
||||||
|
```
|
||||||
|
|
||||||
|
### XSS Prevention
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ React automatically escapes content
|
||||||
|
<div>{task.name}</div>
|
||||||
|
|
||||||
|
// ⚠️ Only use dangerouslySetInnerHTML for trusted, sanitized content
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Best Practices
|
||||||
|
|
||||||
|
### Database Queries
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ Good - Use includes for associations
|
||||||
|
const tasks = await Task.findAll({
|
||||||
|
where: { user_id: userId },
|
||||||
|
include: [Project, Tag] // Eager loading
|
||||||
|
});
|
||||||
|
|
||||||
|
// ❌ Bad - N+1 queries
|
||||||
|
const tasks = await Task.findAll({ where: { user_id: userId } });
|
||||||
|
for (const task of tasks) {
|
||||||
|
task.project = await Project.findByPk(task.project_id); // N+1!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### React Rendering
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good - Memoize expensive computations
|
||||||
|
const sortedTasks = useMemo(
|
||||||
|
() => tasks.sort((a, b) => a.priority - b.priority),
|
||||||
|
[tasks]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ✅ Good - Prevent unnecessary re-renders
|
||||||
|
const TaskItem = React.memo(({ task }) => {
|
||||||
|
return <div>{task.name}</div>;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Back to Index](../CLAUDE.md)
|
||||||
540
docs/common-tasks.md
Normal file
540
docs/common-tasks.md
Normal file
|
|
@ -0,0 +1,540 @@
|
||||||
|
# Common Tasks Reference
|
||||||
|
|
||||||
|
[← Back to Index](../CLAUDE.md)
|
||||||
|
|
||||||
|
Quick reference for frequently performed development tasks with complete file lists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Add New Field to Existing Model
|
||||||
|
|
||||||
|
**Example: Add `estimated_time` field to Task**
|
||||||
|
|
||||||
|
### Files to Modify
|
||||||
|
|
||||||
|
1. **Create migration**
|
||||||
|
```bash
|
||||||
|
npm run migration:create -- --name add-estimated-time-to-tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `/backend/migrations/YYYYMMDDHHMMSS-add-estimated-time-to-tasks.js`
|
||||||
|
|
||||||
|
2. **Update model**
|
||||||
|
- `/backend/models/task.js` - Add field definition
|
||||||
|
|
||||||
|
3. **Update serializer**
|
||||||
|
- `/backend/modules/tasks/core/serializers.js` - Add to `serializeTask()`
|
||||||
|
|
||||||
|
4. **Update builder**
|
||||||
|
- `/backend/modules/tasks/core/builders.js` - Add to `buildTaskAttributes()`
|
||||||
|
|
||||||
|
5. **Add validation (if needed)**
|
||||||
|
- `/backend/modules/tasks/routes.js` - Add validation logic
|
||||||
|
|
||||||
|
6. **Update API docs**
|
||||||
|
- `/backend/config/swagger.js` - Update Task schema
|
||||||
|
|
||||||
|
7. **Update frontend types**
|
||||||
|
- `/frontend/entities/Task.ts` - Add to interface (if exists)
|
||||||
|
|
||||||
|
8. **Update UI**
|
||||||
|
- `/frontend/components/Task/TaskForm.tsx` - Add input field
|
||||||
|
- `/frontend/components/Task/TaskItem.tsx` - Display field (if needed)
|
||||||
|
|
||||||
|
9. **Add tests**
|
||||||
|
- `/backend/tests/integration/tasks/tasks.test.js` - Test CRUD with new field
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migration:create -- --name add-estimated-time-to-tasks
|
||||||
|
# Edit migration file
|
||||||
|
npm run migration:run
|
||||||
|
npm run backend:test
|
||||||
|
npm run lint:fix
|
||||||
|
npm run format:fix
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Create New Backend Module
|
||||||
|
|
||||||
|
**Example: Create "labels" module**
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. **Create directory structure**
|
||||||
|
```bash
|
||||||
|
mkdir -p /Users/chris/c0deLab/ProjectLand/tududi/backend/modules/labels
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create files**
|
||||||
|
- `/backend/modules/labels/routes.js` - Express routes
|
||||||
|
- `/backend/modules/labels/repository.js` - Data access
|
||||||
|
|
||||||
|
3. **Create model**
|
||||||
|
- `/backend/models/label.js` - Sequelize model
|
||||||
|
- Update `/backend/models/index.js` - Add associations
|
||||||
|
|
||||||
|
4. **Create migration**
|
||||||
|
```bash
|
||||||
|
npm run migration:create -- --name create-labels-table
|
||||||
|
```
|
||||||
|
- Edit `/backend/migrations/YYYYMMDDHHMMSS-create-labels-table.js`
|
||||||
|
|
||||||
|
5. **Register routes**
|
||||||
|
- Edit `/backend/app.js`:
|
||||||
|
```javascript
|
||||||
|
app.use('/api/v1', require('./modules/labels/routes'));
|
||||||
|
app.use('/api', require('./modules/labels/routes'));
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Add Swagger docs**
|
||||||
|
- Edit `/backend/config/swagger.js` - Add Label schema
|
||||||
|
|
||||||
|
7. **Write tests**
|
||||||
|
- Create `/backend/tests/integration/labels/labels.test.js`
|
||||||
|
|
||||||
|
8. **Frontend integration (optional)**
|
||||||
|
- `/frontend/entities/Label.ts` - TypeScript interface
|
||||||
|
- `/frontend/utils/labelsService.ts` - API client
|
||||||
|
- `/frontend/components/Label/` - Components
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migration:create -- --name create-labels-table
|
||||||
|
npm run migration:run
|
||||||
|
npm run backend:test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Add New React Component
|
||||||
|
|
||||||
|
**Example: Create `TaskPriorityBadge` component**
|
||||||
|
|
||||||
|
### Files to Create
|
||||||
|
|
||||||
|
1. **Component file**
|
||||||
|
- `/frontend/components/Task/TaskPriorityBadge.tsx`
|
||||||
|
|
||||||
|
2. **Test file (optional)**
|
||||||
|
- `/frontend/components/Task/__tests__/TaskPriorityBadge.test.tsx`
|
||||||
|
|
||||||
|
3. **Import in parent component**
|
||||||
|
- `/frontend/components/Task/TaskItem.tsx` - Import and use
|
||||||
|
|
||||||
|
### Example Component
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// TaskPriorityBadge.tsx
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface TaskPriorityBadgeProps {
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TaskPriorityBadge: React.FC<TaskPriorityBadgeProps> = ({ priority }) => {
|
||||||
|
const configs = {
|
||||||
|
0: { label: 'Low', className: 'bg-gray-200 text-gray-800' },
|
||||||
|
1: { label: 'Medium', className: 'bg-yellow-200 text-yellow-800' },
|
||||||
|
2: { label: 'High', className: 'bg-red-200 text-red-800' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = configs[priority] || configs[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-semibold ${config.className}`}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run frontend:test
|
||||||
|
npm run lint:fix
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Update Database Schema
|
||||||
|
|
||||||
|
**Example: Add index to Tasks table**
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. **Create migration**
|
||||||
|
```bash
|
||||||
|
npm run migration:create -- --name add-index-to-tasks-due-date
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Edit migration file**
|
||||||
|
`/backend/migrations/YYYYMMDDHHMMSS-add-index-to-tasks-due-date.js`
|
||||||
|
```javascript
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.addIndex('Tasks', ['due_date'], {
|
||||||
|
name: 'tasks_due_date_index'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.removeIndex('Tasks', 'tasks_due_date_index');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Test migration**
|
||||||
|
```bash
|
||||||
|
npm run migration:run
|
||||||
|
npm run migration:undo # Test rollback
|
||||||
|
npm run migration:run # Re-apply
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Update model (optional)**
|
||||||
|
- Index definition can be in migration only, or also in model
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migration:create -- --name add-index-to-tasks-due-date
|
||||||
|
npm run migration:run
|
||||||
|
npm run backend:test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Fix a Bug (TDD Workflow)
|
||||||
|
|
||||||
|
**Example: Fix bug where completed tasks show in Today view**
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. **Write failing test first**
|
||||||
|
|
||||||
|
Edit `/backend/tests/integration/tasks/tasks.test.js`:
|
||||||
|
```javascript
|
||||||
|
it('should not return completed tasks in Today view', async () => {
|
||||||
|
// Arrange - Create completed task
|
||||||
|
await Task.create({
|
||||||
|
name: 'Completed Task',
|
||||||
|
status: 2,
|
||||||
|
due_date: new Date().toISOString().split('T')[0],
|
||||||
|
user_id: user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/api/v1/tasks/today')
|
||||||
|
.set('Cookie', authCookie);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const completedTasks = response.body.filter(t => t.status === 2);
|
||||||
|
expect(completedTasks.length).toBe(0);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Run test - verify it fails**
|
||||||
|
```bash
|
||||||
|
npm run backend:test
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Fix the bug**
|
||||||
|
|
||||||
|
Edit `/backend/modules/tasks/queries/query-builders.js` or `/backend/modules/tasks/operations/list.js`:
|
||||||
|
```javascript
|
||||||
|
// Add status filter to Today query
|
||||||
|
where.status = { [Op.ne]: 2 }; // Exclude completed
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Run test - verify it passes**
|
||||||
|
```bash
|
||||||
|
npm run backend:test
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Run all checks**
|
||||||
|
```bash
|
||||||
|
npm run pre-push
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Commit**
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Fix completed tasks appearing in Today view (#123)
|
||||||
|
|
||||||
|
- Add status filter to Today query
|
||||||
|
- Add test to prevent regression"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Add Translation Key
|
||||||
|
|
||||||
|
### Backend Translations
|
||||||
|
|
||||||
|
Backend typically doesn't use translations (API responses in English).
|
||||||
|
|
||||||
|
### Frontend Translations
|
||||||
|
|
||||||
|
1. **Add key to English source**
|
||||||
|
|
||||||
|
Edit `/public/locales/en/translation.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task": {
|
||||||
|
"estimatedTime": "Estimated Time",
|
||||||
|
"estimatedTimePlaceholder": "Enter time in minutes",
|
||||||
|
"estimatedTimeUnit": "minutes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Use in component**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const TaskForm = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label>{t('task.estimatedTime')}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder={t('task.estimatedTimePlaceholder')}
|
||||||
|
/>
|
||||||
|
<span>{t('task.estimatedTimeUnit')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Sync translations (if tool available)**
|
||||||
|
```bash
|
||||||
|
npm run translations:sync
|
||||||
|
npm run translations:check
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Add API Endpoint Documentation
|
||||||
|
|
||||||
|
### Update Swagger Schema
|
||||||
|
|
||||||
|
Edit `/backend/config/swagger.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Update existing schema
|
||||||
|
components: {
|
||||||
|
schemas: {
|
||||||
|
Task: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'integer' },
|
||||||
|
uid: { type: 'string' },
|
||||||
|
name: { type: 'string' },
|
||||||
|
estimated_time: {
|
||||||
|
type: 'integer',
|
||||||
|
description: 'Estimated time in minutes',
|
||||||
|
nullable: true,
|
||||||
|
example: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Document New Endpoint with JSDoc
|
||||||
|
|
||||||
|
In `/backend/modules/tasks/routes.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/v1/task/{id}/estimate:
|
||||||
|
* put:
|
||||||
|
* summary: Update task estimated time
|
||||||
|
* tags: [Tasks]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* estimated_time:
|
||||||
|
* type: integer
|
||||||
|
* description: Time in minutes
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Task updated successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/Task'
|
||||||
|
* 404:
|
||||||
|
* description: Task not found
|
||||||
|
*/
|
||||||
|
router.put('/task/:id/estimate', async (req, res, next) => {
|
||||||
|
// Implementation
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Swagger Docs
|
||||||
|
|
||||||
|
1. Start server: `npm run backend:dev`
|
||||||
|
2. Login to app
|
||||||
|
3. Visit: http://localhost:3002/api-docs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Add Subtask to Task
|
||||||
|
|
||||||
|
**Example: Add subtasks to a task**
|
||||||
|
|
||||||
|
This is already implemented in tududi, but here's the pattern:
|
||||||
|
|
||||||
|
### Files Involved
|
||||||
|
|
||||||
|
1. **Migration**
|
||||||
|
- Already exists: `parent_task_id` field in Tasks table
|
||||||
|
|
||||||
|
2. **Model**
|
||||||
|
- `/backend/models/task.js` - Self-referential association
|
||||||
|
- `/backend/models/index.js` - Define relationship:
|
||||||
|
```javascript
|
||||||
|
Task.hasMany(Task, { as: 'Subtasks', foreignKey: 'parent_task_id' });
|
||||||
|
Task.belongsTo(Task, { as: 'ParentTask', foreignKey: 'parent_task_id' });
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Backend operations**
|
||||||
|
- `/backend/modules/tasks/operations/subtasks.js` - Subtask CRUD
|
||||||
|
|
||||||
|
4. **Frontend**
|
||||||
|
- `/frontend/components/Task/SubtaskList.tsx` - Display subtasks
|
||||||
|
- `/frontend/components/Task/TaskDetails.tsx` - Show subtasks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Add Permission/Sharing to Resource
|
||||||
|
|
||||||
|
**Example: Add sharing to a new resource type**
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. **Ensure Permission model supports resource type**
|
||||||
|
|
||||||
|
Check `/backend/models/permission.js`:
|
||||||
|
```javascript
|
||||||
|
resource_type: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
// Should support your resource type
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Use `hasAccess` middleware in routes**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { hasAccess } = require('../../middleware/authorize');
|
||||||
|
|
||||||
|
router.get('/myresource/:id',
|
||||||
|
hasAccess('ro', 'myresource', (req) => req.params.id),
|
||||||
|
async (req, res, next) => {
|
||||||
|
// Handler
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Implement sharing endpoints**
|
||||||
|
|
||||||
|
See `/backend/modules/shares/` for reference
|
||||||
|
|
||||||
|
4. **Frontend sharing UI**
|
||||||
|
|
||||||
|
See `/frontend/components/Project/ProjectSharing.tsx` for reference
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: Add Recurring Pattern to Task
|
||||||
|
|
||||||
|
**Example: Add new recurrence type**
|
||||||
|
|
||||||
|
### Files to Check/Modify
|
||||||
|
|
||||||
|
1. **Task model**
|
||||||
|
- `/backend/models/task.js` - Recurrence fields
|
||||||
|
|
||||||
|
2. **Recurring task service**
|
||||||
|
- `/backend/modules/tasks/recurringTaskService.js` - Add new pattern logic
|
||||||
|
|
||||||
|
3. **Task scheduler**
|
||||||
|
- `/backend/modules/tasks/taskScheduler.js` - Ensure cron job handles new pattern
|
||||||
|
|
||||||
|
4. **Frontend**
|
||||||
|
- `/frontend/components/Task/TaskForm.tsx` - Add UI for new pattern
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 11: Export Data (Backup)
|
||||||
|
|
||||||
|
**Example: Create database backup**
|
||||||
|
|
||||||
|
Already implemented in tududi:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Via API (after login)
|
||||||
|
POST /api/v1/backup
|
||||||
|
|
||||||
|
# Returns JSON file with all user data
|
||||||
|
```
|
||||||
|
|
||||||
|
See `/backend/modules/backup/` for implementation details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Command Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database
|
||||||
|
npm run db:init # Initialize database
|
||||||
|
npm run db:reset # Reset database
|
||||||
|
npm run migration:create -- --name # Create migration
|
||||||
|
npm run migration:run # Run migrations
|
||||||
|
npm run migration:undo # Rollback migration
|
||||||
|
|
||||||
|
# Development
|
||||||
|
npm start # Run both frontend + backend
|
||||||
|
npm run backend:dev # Backend only
|
||||||
|
npm run frontend:dev # Frontend only
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
npm test # Backend tests
|
||||||
|
npm run frontend:test # Frontend tests
|
||||||
|
npm run test:ui # E2E tests
|
||||||
|
npm run test:coverage # Coverage report
|
||||||
|
|
||||||
|
# Code Quality
|
||||||
|
npm run lint # Check linting
|
||||||
|
npm run lint:fix # Fix linting
|
||||||
|
npm run format:fix # Format code
|
||||||
|
npm run pre-push # All checks
|
||||||
|
|
||||||
|
# Build
|
||||||
|
npm run build # Production build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Back to Index](../CLAUDE.md)
|
||||||
508
docs/database.md
Normal file
508
docs/database.md
Normal file
|
|
@ -0,0 +1,508 @@
|
||||||
|
# Database & Migrations
|
||||||
|
|
||||||
|
[← Back to Index](../CLAUDE.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Models
|
||||||
|
|
||||||
|
All Sequelize models are defined in `/backend/models/` and associations are configured in `/backend/models/index.js`.
|
||||||
|
|
||||||
|
### Core Models
|
||||||
|
|
||||||
|
| Model | File | Purpose | Key Fields |
|
||||||
|
|-------|------|---------|------------|
|
||||||
|
| **User** | `user.js` | User accounts | email, password (bcrypt), settings, preferences, timezone |
|
||||||
|
| **Task** | `task.js` | Tasks with recurrence | name, due_date, priority, status, recurrence_type, parent_task_id |
|
||||||
|
| **Project** | `project.js` | Project grouping | name, area_id, user_id |
|
||||||
|
| **Area** | `area.js` | Area categorization | name, user_id |
|
||||||
|
| **Note** | `note.js` | Notes | text, project_id, user_id |
|
||||||
|
| **Tag** | `tag.js` | Tags | name, color, user_id |
|
||||||
|
| **Permission** | `permission.js` | Sharing/permissions | user_id, resource_type, resource_uid, access_level |
|
||||||
|
| **ApiToken** | `apiToken.js` | API tokens | user_id, token_hash, expires_at |
|
||||||
|
| **RecurringCompletion** | `recurringCompletion.js` | Recurring task history | task_id, completed_at, due_date |
|
||||||
|
| **TaskEvent** | `taskEvent.js` | Task audit log | task_id, user_id, action, changes |
|
||||||
|
| **TaskAttachment** | `taskAttachment.js` | File attachments | task_id, filename, path |
|
||||||
|
| **InboxItem** | `inboxItem.js` | Inbox entries | name, user_id |
|
||||||
|
| **Notification** | `notification.js` | User notifications | user_id, type, read, linked_resource |
|
||||||
|
| **Role** | `role.js` | User roles | name, is_admin |
|
||||||
|
| **View** | `view.js` | Saved views | name, filters, user_id |
|
||||||
|
| **Backup** | `backup.js` | Backup records | user_id, filename, created_at |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Model Relationships
|
||||||
|
|
||||||
|
Defined in `/backend/models/index.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// User relationships (one user has many resources)
|
||||||
|
User.hasMany(Task, { foreignKey: 'user_id' });
|
||||||
|
User.hasMany(Project, { foreignKey: 'user_id' });
|
||||||
|
User.hasMany(Area, { foreignKey: 'user_id' });
|
||||||
|
User.hasMany(Note, { foreignKey: 'user_id' });
|
||||||
|
|
||||||
|
// Hierarchical relationships
|
||||||
|
// Area > Project
|
||||||
|
Area.hasMany(Project, { foreignKey: 'area_id' });
|
||||||
|
Project.belongsTo(Area, { foreignKey: 'area_id' });
|
||||||
|
|
||||||
|
// Project > Task
|
||||||
|
Project.hasMany(Task, { foreignKey: 'project_id' });
|
||||||
|
Task.belongsTo(Project, { foreignKey: 'project_id' });
|
||||||
|
|
||||||
|
// Project > Note
|
||||||
|
Project.hasMany(Note, { foreignKey: 'project_id' });
|
||||||
|
Note.belongsTo(Project, { foreignKey: 'project_id' });
|
||||||
|
|
||||||
|
// Self-referential (subtasks and recurring tasks)
|
||||||
|
Task.hasMany(Task, { as: 'Subtasks', foreignKey: 'parent_task_id' });
|
||||||
|
Task.belongsTo(Task, { as: 'ParentTask', foreignKey: 'parent_task_id' });
|
||||||
|
|
||||||
|
// Many-to-many relationships (tags)
|
||||||
|
Task.belongsToMany(Tag, { through: 'tasks_tags' });
|
||||||
|
Tag.belongsToMany(Task, { through: 'tasks_tags' });
|
||||||
|
|
||||||
|
Project.belongsToMany(Tag, { through: 'projects_tags' });
|
||||||
|
Tag.belongsToMany(Project, { through: 'projects_tags' });
|
||||||
|
|
||||||
|
Note.belongsToMany(Tag, { through: 'notes_tags' });
|
||||||
|
Tag.belongsToMany(Note, { through: 'notes_tags' });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Workflow
|
||||||
|
|
||||||
|
### Creating a New Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migration:create -- --name add-field-to-tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates: `/backend/migrations/YYYYMMDDHHMMSS-add-field-to-tasks.js`
|
||||||
|
|
||||||
|
The timestamp ensures migrations run in chronological order.
|
||||||
|
|
||||||
|
### Migration File Template
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// backend/migrations/20260313120000-add-priority-level-to-tasks.js
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
// Changes to apply
|
||||||
|
await queryInterface.addColumn('Tasks', 'priority_level', {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: null
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
// How to reverse the changes
|
||||||
|
await queryInterface.removeColumn('Tasks', 'priority_level');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Apply all pending migrations
|
||||||
|
npm run migration:run
|
||||||
|
|
||||||
|
# Rollback last migration
|
||||||
|
npm run migration:undo
|
||||||
|
|
||||||
|
# Check migration status
|
||||||
|
npm run db:status
|
||||||
|
|
||||||
|
# Rollback all migrations (CAREFUL!)
|
||||||
|
npm run migration:undo:all
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Best Practices
|
||||||
|
|
||||||
|
### 1. Always Implement `down` Method
|
||||||
|
|
||||||
|
Make migrations reversible:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ Good - Reversible
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.removeColumn('Tasks', 'priority_level');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ Bad - Not reversible
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
// Not implemented
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Test Both Directions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test up
|
||||||
|
npm run migration:run
|
||||||
|
|
||||||
|
# Test down (rollback)
|
||||||
|
npm run migration:undo
|
||||||
|
|
||||||
|
# Re-apply to verify
|
||||||
|
npm run migration:run
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Never Modify Released Migrations
|
||||||
|
|
||||||
|
Once a migration is released (in main branch or deployed), create a new one instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ❌ Bad - Modifying existing migration
|
||||||
|
# Edit: migrations/20260101-create-tasks.js
|
||||||
|
|
||||||
|
# ✅ Good - Create new migration
|
||||||
|
npm run migration:create -- --name modify-tasks-table
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Update Corresponding Model
|
||||||
|
|
||||||
|
After creating migration, update the Sequelize model:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Migration adds field
|
||||||
|
await queryInterface.addColumn('Tasks', 'estimated_time', {
|
||||||
|
type: Sequelize.INTEGER
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update /backend/models/task.js
|
||||||
|
Task.init({
|
||||||
|
// ... existing fields ...
|
||||||
|
estimated_time: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Use Transactions for Data Migrations
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Multiple operations in transaction
|
||||||
|
await queryInterface.addColumn('Tasks', 'new_field', {...}, { transaction });
|
||||||
|
|
||||||
|
// Data migration
|
||||||
|
await queryInterface.sequelize.query(
|
||||||
|
'UPDATE Tasks SET new_field = old_field WHERE old_field IS NOT NULL',
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryInterface.removeColumn('Tasks', 'old_field', { transaction });
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Include Migrations in PRs
|
||||||
|
|
||||||
|
When schema changes are needed, always include the migration file in your PR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Migration Operations
|
||||||
|
|
||||||
|
### Add Column
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await queryInterface.addColumn('TableName', 'column_name', {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'default'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove Column
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await queryInterface.removeColumn('TableName', 'column_name');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Change Column Type
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await queryInterface.changeColumn('TableName', 'column_name', {
|
||||||
|
type: Sequelize.TEXT, // Changed from STRING
|
||||||
|
allowNull: true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rename Column
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await queryInterface.renameColumn('TableName', 'old_name', 'new_name');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add Index
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await queryInterface.addIndex('TableName', ['column_name'], {
|
||||||
|
name: 'index_name',
|
||||||
|
unique: false
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove Index
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await queryInterface.removeIndex('TableName', 'index_name');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Table
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await queryInterface.createTable('NewTable', {
|
||||||
|
id: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
uid: {
|
||||||
|
type: Sequelize.STRING(15),
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
user_id: {
|
||||||
|
type: Sequelize.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: 'Users',
|
||||||
|
key: 'id'
|
||||||
|
},
|
||||||
|
onUpdate: 'CASCADE',
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
type: Sequelize.DATE,
|
||||||
|
allowNull: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Drop Table
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await queryInterface.dropTable('TableName');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add Foreign Key
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await queryInterface.addConstraint('Tasks', {
|
||||||
|
fields: ['project_id'],
|
||||||
|
type: 'foreign key',
|
||||||
|
name: 'tasks_project_fk',
|
||||||
|
references: {
|
||||||
|
table: 'Projects',
|
||||||
|
field: 'id'
|
||||||
|
},
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
onUpdate: 'CASCADE'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Configuration
|
||||||
|
|
||||||
|
### Location
|
||||||
|
|
||||||
|
`/backend/config/database.js`
|
||||||
|
|
||||||
|
### SQLite Performance Optimizations
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Applied in models/index.js
|
||||||
|
await sequelize.query('PRAGMA journal_mode=WAL'); // Write-Ahead Logging
|
||||||
|
await sequelize.query('PRAGMA synchronous=NORMAL'); // Faster with single user
|
||||||
|
await sequelize.query('PRAGMA busy_timeout=5000'); // 5s timeout
|
||||||
|
await sequelize.query('PRAGMA cache_size=-64000'); // 64MB cache
|
||||||
|
await sequelize.query('PRAGMA mmap_size=268435456'); // 256MB memory-mapped I/O
|
||||||
|
await sequelize.query('PRAGMA temp_store=MEMORY'); // Memory-based temp storage
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- WAL mode: Better concurrency for writes
|
||||||
|
- Larger cache: Fewer disk reads
|
||||||
|
- Memory-mapped I/O: Faster random access
|
||||||
|
- Memory temp storage: Faster temp operations
|
||||||
|
|
||||||
|
### Database File Location
|
||||||
|
|
||||||
|
- **Development:** `/backend/database.sqlite`
|
||||||
|
- **Docker:** Mounted volume at `/app/backend/db/`
|
||||||
|
- **Test:** Separate test database (auto-created)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Management Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Initialize database (create + migrate)
|
||||||
|
npm run db:init
|
||||||
|
|
||||||
|
# Reset database (WIPES ALL DATA!)
|
||||||
|
npm run db:reset
|
||||||
|
|
||||||
|
# Seed development data
|
||||||
|
npm run db:seed
|
||||||
|
|
||||||
|
# Check migration status
|
||||||
|
npm run db:status
|
||||||
|
|
||||||
|
# Create new migration
|
||||||
|
npm run migration:create -- --name description
|
||||||
|
|
||||||
|
# Run pending migrations
|
||||||
|
npm run migration:run
|
||||||
|
|
||||||
|
# Rollback last migration
|
||||||
|
npm run migration:undo
|
||||||
|
|
||||||
|
# Rollback all migrations (CAREFUL!)
|
||||||
|
npm run migration:undo:all
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Model Definition Example
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// /backend/models/task.js
|
||||||
|
'use strict';
|
||||||
|
const { Model, DataTypes } = require('sequelize');
|
||||||
|
|
||||||
|
module.exports = (sequelize) => {
|
||||||
|
class Task extends Model {
|
||||||
|
static associate(models) {
|
||||||
|
// Defined in models/index.js
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Task.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
uid: {
|
||||||
|
type: DataTypes.STRING(15),
|
||||||
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
due_date: {
|
||||||
|
type: DataTypes.DATEONLY,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
priority: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0,
|
||||||
|
validate: {
|
||||||
|
min: 0,
|
||||||
|
max: 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
// Recurrence fields
|
||||||
|
recurrence_type: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
recurrence_interval: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
// Foreign keys
|
||||||
|
user_id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
project_id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
parent_task_id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'Task',
|
||||||
|
tableName: 'Tasks',
|
||||||
|
underscored: true,
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return Task;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Special Task Model Fields
|
||||||
|
|
||||||
|
The Task model has special fields for recurring tasks:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Recurrence pattern
|
||||||
|
recurrence_type: 'daily' | 'weekly' | 'monthly' | 'monthly_weekday' | 'monthly_last_day'
|
||||||
|
recurrence_interval: 1, 2, 3, ... (every N days/weeks/months)
|
||||||
|
recurrence_end_date: Optional end date for series
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
parent_task_id: Links subtasks to parent task (self-referential)
|
||||||
|
recurring_parent_id: Links recurring instances to original pattern
|
||||||
|
```
|
||||||
|
|
||||||
|
**Task Status Values:**
|
||||||
|
- 0: Not started
|
||||||
|
- 1: In progress
|
||||||
|
- 2: Done/Completed
|
||||||
|
- 3: Archived
|
||||||
|
- 4: Waiting
|
||||||
|
- 5: Cancelled
|
||||||
|
- 6: Planned
|
||||||
|
|
||||||
|
**Priority Values:**
|
||||||
|
- 0: Low
|
||||||
|
- 1: Medium
|
||||||
|
- 2: High
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Back to Index](../CLAUDE.md)
|
||||||
580
docs/development-workflow.md
Normal file
580
docs/development-workflow.md
Normal file
|
|
@ -0,0 +1,580 @@
|
||||||
|
# Development Workflow
|
||||||
|
|
||||||
|
[← Back to Index](../CLAUDE.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Initial Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **Node.js** v22+ (recommended - check package.json engines field)
|
||||||
|
- **npm** (comes with Node.js)
|
||||||
|
- **Git**
|
||||||
|
|
||||||
|
### Clone and Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone repository
|
||||||
|
git clone https://github.com/chrisvel/tududi.git
|
||||||
|
cd tududi
|
||||||
|
|
||||||
|
# Install all dependencies
|
||||||
|
# This installs both frontend and backend dependencies (monorepo setup)
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Initialize Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create database and run all migrations
|
||||||
|
npm run db:init
|
||||||
|
|
||||||
|
# This command:
|
||||||
|
# 1. Creates /backend/database.sqlite
|
||||||
|
# 2. Runs all migrations from /backend/migrations/
|
||||||
|
# 3. Sets up tables and relationships
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Test User (Optional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run user:create
|
||||||
|
|
||||||
|
# Interactive prompts:
|
||||||
|
# - Email address
|
||||||
|
# - Password
|
||||||
|
# - Timezone (defaults to system timezone)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Daily Development
|
||||||
|
|
||||||
|
Tududi runs two separate processes during development:
|
||||||
|
|
||||||
|
### Two-Server Development
|
||||||
|
|
||||||
|
**Terminal 1 - Backend (Express):**
|
||||||
|
```bash
|
||||||
|
npm run backend:dev
|
||||||
|
|
||||||
|
# Details:
|
||||||
|
# - Runs: nodemon backend/app.js
|
||||||
|
# - Server: http://localhost:3002
|
||||||
|
# - Auto-reloads: Yes (on file changes)
|
||||||
|
# - API endpoints: /api/v1/*
|
||||||
|
# - Swagger docs: /api-docs (after login)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Terminal 2 - Frontend (Webpack Dev Server):**
|
||||||
|
```bash
|
||||||
|
npm run frontend:dev
|
||||||
|
|
||||||
|
# Details:
|
||||||
|
# - Runs: webpack serve --mode development
|
||||||
|
# - Server: http://localhost:8080
|
||||||
|
# - Hot reload: Yes (React Fast Refresh)
|
||||||
|
# - Proxies /api/* to backend:3002
|
||||||
|
# - Proxies /locales/* to backend:3002
|
||||||
|
```
|
||||||
|
|
||||||
|
**Or run both simultaneously:**
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# Runs both backend:dev and frontend:dev in parallel
|
||||||
|
# Uses 'concurrently' package
|
||||||
|
# Logs from both processes interleaved
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessing the Application
|
||||||
|
|
||||||
|
Open http://localhost:8080 in your browser.
|
||||||
|
|
||||||
|
**Login with:**
|
||||||
|
- Email: The email you set during db:init or user:create
|
||||||
|
- Password: The password you set
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Create `/backend/.env` file (not tracked in git):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required
|
||||||
|
TUDUDI_SESSION_SECRET=your-random-secret-here-use-openssl-rand-hex-64
|
||||||
|
TUDUDI_USER_EMAIL=admin@example.com
|
||||||
|
TUDUDI_USER_PASSWORD=your-secure-password
|
||||||
|
|
||||||
|
# Optional - Server config
|
||||||
|
NODE_ENV=development
|
||||||
|
DB_FILE=database.sqlite
|
||||||
|
FRONTEND_URL=http://localhost:8080
|
||||||
|
BACKEND_URL=http://localhost:3002
|
||||||
|
PORT=3002
|
||||||
|
HOST=0.0.0.0
|
||||||
|
|
||||||
|
# Optional - Email
|
||||||
|
ENABLE_EMAIL=false
|
||||||
|
EMAIL_SMTP_HOST=smtp.example.com
|
||||||
|
EMAIL_SMTP_PORT=587
|
||||||
|
EMAIL_SMTP_SECURE=false
|
||||||
|
EMAIL_SMTP_USERNAME=user
|
||||||
|
EMAIL_SMTP_PASSWORD=pass
|
||||||
|
EMAIL_FROM_ADDRESS=noreply@example.com
|
||||||
|
EMAIL_FROM_NAME=Tududi
|
||||||
|
|
||||||
|
# Optional - Integrations
|
||||||
|
DISABLE_TELEGRAM=false
|
||||||
|
GOOGLE_CLIENT_ID=your-google-oauth-client-id
|
||||||
|
GOOGLE_CLIENT_SECRET=your-google-oauth-secret
|
||||||
|
GOOGLE_REDIRECT_URI=http://localhost:8080/auth/google/callback
|
||||||
|
|
||||||
|
# Optional - Features
|
||||||
|
DISABLE_SCHEDULER=false
|
||||||
|
SWAGGER_ENABLED=true
|
||||||
|
RATE_LIMITING_ENABLED=true
|
||||||
|
|
||||||
|
# Optional - Proxy (if behind reverse proxy)
|
||||||
|
TUDUDI_TRUST_PROXY=false
|
||||||
|
TUDUDI_ALLOWED_ORIGINS=http://localhost:8080
|
||||||
|
|
||||||
|
# Optional - Registration
|
||||||
|
REGISTRATION_TOKEN_EXPIRY_HOURS=24
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generate secure session secret:**
|
||||||
|
```bash
|
||||||
|
openssl rand -hex 64
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a New Feature (Complete Example)
|
||||||
|
|
||||||
|
**Example: Add "estimated_time" field to tasks**
|
||||||
|
|
||||||
|
This walkthrough shows all files to touch when adding a new field to an existing model.
|
||||||
|
|
||||||
|
### Step 1: Create Database Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migration:create -- --name add-estimated-time-to-tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit the created file `/backend/migrations/YYYYMMDDHHMMSS-add-estimated-time-to-tasks.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.addColumn('Tasks', 'estimated_time', {
|
||||||
|
type: Sequelize.INTEGER, // minutes
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: null
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.removeColumn('Tasks', 'estimated_time');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run migration:**
|
||||||
|
```bash
|
||||||
|
npm run migration:run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Update Sequelize Model
|
||||||
|
|
||||||
|
Edit `/backend/models/task.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
Task.init({
|
||||||
|
// ... existing fields ...
|
||||||
|
estimated_time: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'Estimated time in minutes'
|
||||||
|
},
|
||||||
|
// ... rest of fields ...
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'Task',
|
||||||
|
tableName: 'Tasks'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Update Serializer (API Response)
|
||||||
|
|
||||||
|
Edit `/backend/modules/tasks/core/serializers.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function serializeTask(task) {
|
||||||
|
return {
|
||||||
|
// ... existing fields ...
|
||||||
|
estimated_time: task.estimated_time,
|
||||||
|
// ... rest of fields ...
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Update Builder (API Input)
|
||||||
|
|
||||||
|
Edit `/backend/modules/tasks/core/builders.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function buildTaskAttributes(data, userId) {
|
||||||
|
const attributes = {
|
||||||
|
// ... existing fields ...
|
||||||
|
estimated_time: data.estimated_time ? parseInt(data.estimated_time, 10) : null,
|
||||||
|
// ... rest of fields ...
|
||||||
|
};
|
||||||
|
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Add Validation (Optional)
|
||||||
|
|
||||||
|
If validation needed, edit `/backend/modules/tasks/routes.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
router.put('/task/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Validate estimated_time
|
||||||
|
if (req.body.estimated_time !== undefined) {
|
||||||
|
const time = parseInt(req.body.estimated_time, 10);
|
||||||
|
if (isNaN(time) || time < 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Estimated time must be a positive number'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... rest of route handler ...
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Update Swagger Documentation
|
||||||
|
|
||||||
|
Edit `/backend/config/swagger.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Find Task schema
|
||||||
|
components: {
|
||||||
|
schemas: {
|
||||||
|
Task: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
// ... existing properties ...
|
||||||
|
estimated_time: {
|
||||||
|
type: 'integer',
|
||||||
|
description: 'Estimated time in minutes',
|
||||||
|
nullable: true,
|
||||||
|
example: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 7: Update Frontend TypeScript Interface
|
||||||
|
|
||||||
|
If TypeScript interface exists, edit `/frontend/entities/Task.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface Task {
|
||||||
|
// ... existing fields ...
|
||||||
|
estimated_time: number | null;
|
||||||
|
// ... rest of fields ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 8: Update Frontend Component
|
||||||
|
|
||||||
|
Edit `/frontend/components/Task/TaskForm.tsx`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Add input field
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Estimated Time (minutes)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={task.estimated_time || ''}
|
||||||
|
onChange={(e) => updateTask({
|
||||||
|
...task,
|
||||||
|
estimated_time: e.target.value ? parseInt(e.target.value) : null
|
||||||
|
})}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 9: Write Tests
|
||||||
|
|
||||||
|
Add tests in `/backend/tests/integration/tasks/tasks.test.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
it('should create task with estimated_time', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/v1/task')
|
||||||
|
.set('Cookie', authCookie)
|
||||||
|
.send({
|
||||||
|
name: 'Task with estimate',
|
||||||
|
estimated_time: 60
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body.estimated_time).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject negative estimated_time', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/v1/task')
|
||||||
|
.set('Cookie', authCookie)
|
||||||
|
.send({
|
||||||
|
name: 'Invalid task',
|
||||||
|
estimated_time: -10
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 10: Run Tests and Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run backend tests
|
||||||
|
npm run backend:test
|
||||||
|
|
||||||
|
# Run linting
|
||||||
|
npm run lint:fix
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
npm run format:fix
|
||||||
|
|
||||||
|
# Run all pre-push checks
|
||||||
|
npm run pre-push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 11: Commit Changes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Add estimated_time field to tasks
|
||||||
|
|
||||||
|
- Add database migration for estimated_time
|
||||||
|
- Update Task model and serializers
|
||||||
|
- Update Swagger documentation
|
||||||
|
- Add validation for positive values
|
||||||
|
- Add UI field in TaskForm component
|
||||||
|
- Add integration tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Reset database (WIPES ALL DATA!)
|
||||||
|
npm run db:reset
|
||||||
|
|
||||||
|
# Seed development data
|
||||||
|
npm run db:seed
|
||||||
|
|
||||||
|
# Check migration status
|
||||||
|
npm run db:status
|
||||||
|
|
||||||
|
# Create new migration
|
||||||
|
npm run migration:create -- --name description
|
||||||
|
|
||||||
|
# Run pending migrations
|
||||||
|
npm run migration:run
|
||||||
|
|
||||||
|
# Rollback last migration
|
||||||
|
npm run migration:undo
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check linting
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
# Auto-fix linting issues
|
||||||
|
npm run lint:fix
|
||||||
|
|
||||||
|
# Format code with Prettier
|
||||||
|
npm run format:fix
|
||||||
|
|
||||||
|
# Run all pre-push checks
|
||||||
|
# (lint + format + tests)
|
||||||
|
npm run pre-push
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend tests
|
||||||
|
npm test
|
||||||
|
# or
|
||||||
|
npm run backend:test
|
||||||
|
|
||||||
|
# Frontend tests
|
||||||
|
npm run frontend:test
|
||||||
|
|
||||||
|
# E2E tests
|
||||||
|
npm run test:ui # Headless
|
||||||
|
npm run test:ui:headed # With browser visible
|
||||||
|
|
||||||
|
# Coverage report
|
||||||
|
npm run test:coverage
|
||||||
|
|
||||||
|
# Watch mode (during development)
|
||||||
|
npm run test:watch
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branch Strategy
|
||||||
|
|
||||||
|
From CONTRIBUTING.md conventions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Feature branches
|
||||||
|
git checkout -b feature/description
|
||||||
|
|
||||||
|
# Bug fix branches
|
||||||
|
git checkout -b fix/description
|
||||||
|
|
||||||
|
# Refactoring branches
|
||||||
|
git checkout -b refactor/description
|
||||||
|
|
||||||
|
# Documentation branches
|
||||||
|
git checkout -b docs/description
|
||||||
|
|
||||||
|
# Test branches
|
||||||
|
git checkout -b test/description
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create feature branch from main
|
||||||
|
git checkout main
|
||||||
|
git pull origin main
|
||||||
|
git checkout -b feature/estimated-time
|
||||||
|
|
||||||
|
# Make changes, run tests
|
||||||
|
npm run pre-push
|
||||||
|
|
||||||
|
# Commit changes
|
||||||
|
git add .
|
||||||
|
git commit -m "Add estimated time feature"
|
||||||
|
|
||||||
|
# Before PR: rebase on main
|
||||||
|
git checkout main
|
||||||
|
git pull origin main
|
||||||
|
git checkout feature/estimated-time
|
||||||
|
git rebase main
|
||||||
|
|
||||||
|
# Push and create PR
|
||||||
|
git push origin feature/estimated-time
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build for Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build frontend
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Builds to: /dist/
|
||||||
|
# - Minified JavaScript
|
||||||
|
# - Optimized CSS
|
||||||
|
# - Hashed filenames for cache busting
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build Docker image
|
||||||
|
docker build -t tududi:latest .
|
||||||
|
|
||||||
|
# Run container
|
||||||
|
docker run \
|
||||||
|
-e TUDUDI_USER_EMAIL=admin@example.com \
|
||||||
|
-e TUDUDI_USER_PASSWORD=secure-password \
|
||||||
|
-e TUDUDI_SESSION_SECRET=$(openssl rand -hex 64) \
|
||||||
|
-v ~/tududi_db:/app/backend/db \
|
||||||
|
-v ~/tududi_uploads:/app/backend/uploads \
|
||||||
|
-p 3002:3002 \
|
||||||
|
-d tududi:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Port Already in Use
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find process using port 3002
|
||||||
|
lsof -ti:3002
|
||||||
|
|
||||||
|
# Kill process
|
||||||
|
kill -9 $(lsof -ti:3002)
|
||||||
|
|
||||||
|
# Or use different port
|
||||||
|
PORT=3003 npm run backend:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Locked
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop all servers
|
||||||
|
# Delete database file
|
||||||
|
rm backend/database.sqlite
|
||||||
|
|
||||||
|
# Reinitialize
|
||||||
|
npm run db:init
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module Not Found
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clean install
|
||||||
|
rm -rf node_modules package-lock.json
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Webpack Build Errors
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clear webpack cache
|
||||||
|
rm -rf node_modules/.cache
|
||||||
|
|
||||||
|
# Rebuild
|
||||||
|
npm run frontend:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Back to Index](../CLAUDE.md)
|
||||||
457
docs/directory-structure.md
Normal file
457
docs/directory-structure.md
Normal file
|
|
@ -0,0 +1,457 @@
|
||||||
|
# Directory Structure
|
||||||
|
|
||||||
|
[← Back to Index](../CLAUDE.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Root
|
||||||
|
|
||||||
|
```
|
||||||
|
/Users/chris/c0deLab/ProjectLand/tududi/
|
||||||
|
├── README.md # User-facing documentation
|
||||||
|
├── CLAUDE.md # This developer guide (index)
|
||||||
|
├── LICENSE # MIT License
|
||||||
|
├── package.json # Root scripts and dependencies (monorepo)
|
||||||
|
├── package-lock.json # Dependency lock file
|
||||||
|
│
|
||||||
|
├── Configuration Files
|
||||||
|
├── webpack.config.js # Frontend build configuration
|
||||||
|
├── tsconfig.json # TypeScript config (frontend only)
|
||||||
|
├── jest.config.js # Jest config for frontend tests
|
||||||
|
├── babel.config.js # Babel transpilation for Jest + Webpack
|
||||||
|
├── eslint.config.mjs # ESLint flat config
|
||||||
|
├── .prettierrc.json # Prettier code formatting
|
||||||
|
├── tailwind.config.js # Tailwind CSS customization
|
||||||
|
├── .sequelizerc # Sequelize CLI configuration
|
||||||
|
├── postcss.config.js # PostCSS config for Tailwind
|
||||||
|
│
|
||||||
|
├── Docker & Deployment
|
||||||
|
├── Dockerfile # Production Docker image (multi-stage)
|
||||||
|
├── docker-compose.yml # Development Docker setup
|
||||||
|
├── .dockerignore # Docker build exclusions
|
||||||
|
│
|
||||||
|
├── Git & GitHub
|
||||||
|
├── .gitignore
|
||||||
|
├── .github/
|
||||||
|
│ ├── CONTRIBUTING.md # Contribution guidelines
|
||||||
|
│ └── workflows/ # GitHub Actions (if any)
|
||||||
|
│
|
||||||
|
├── Source Code
|
||||||
|
├── backend/ # Express backend → See Backend Structure
|
||||||
|
├── frontend/ # React frontend → See Frontend Structure
|
||||||
|
├── public/ # Static assets (fonts, locales, images)
|
||||||
|
├── dist/ # Production build output
|
||||||
|
├── e2e/ # Playwright E2E tests
|
||||||
|
├── scripts/ # Build and utility scripts
|
||||||
|
├── docs/ # Documentation (this directory)
|
||||||
|
│
|
||||||
|
└── Other
|
||||||
|
├── screenshots/ # App screenshots for README
|
||||||
|
├── uploads/ # User file uploads (not in git)
|
||||||
|
├── test-results/ # Playwright test results
|
||||||
|
└── node_modules/ # Dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/Users/chris/c0deLab/ProjectLand/tududi/backend/
|
||||||
|
│
|
||||||
|
├── app.js # Main Express application entry point
|
||||||
|
│ # - Middleware setup (Helmet, CORS, compression)
|
||||||
|
│ # - Session management
|
||||||
|
│ # - Rate limiting
|
||||||
|
│ # - Module registration
|
||||||
|
│ # - Swagger integration
|
||||||
|
│ # - SPA fallback routing
|
||||||
|
│
|
||||||
|
├── modules/ # Feature modules (modular architecture)
|
||||||
|
│ │
|
||||||
|
│ ├── tasks/ # Task management (MOST COMPLEX MODULE)
|
||||||
|
│ │ ├── routes.js # Express routes
|
||||||
|
│ │ ├── repository.js # Data access layer
|
||||||
|
│ │ ├── recurringTaskService.js
|
||||||
|
│ │ ├── taskEventService.js
|
||||||
|
│ │ ├── taskScheduler.js # Cron-based scheduling
|
||||||
|
│ │ ├── operations/ # Business logic operations
|
||||||
|
│ │ │ ├── list.js # List operations
|
||||||
|
│ │ │ ├── completion.js # Status changes
|
||||||
|
│ │ │ ├── recurring.js # Recurrence handling
|
||||||
|
│ │ │ ├── subtasks.js # Subtask CRUD
|
||||||
|
│ │ │ ├── tags.js # Tag assignment
|
||||||
|
│ │ │ ├── grouping.js # Grouping logic
|
||||||
|
│ │ │ ├── sorting.js # Sort orders
|
||||||
|
│ │ │ └── parent-child.js # Hierarchy ops
|
||||||
|
│ │ ├── queries/ # Query builders
|
||||||
|
│ │ │ ├── query-builders.js
|
||||||
|
│ │ │ ├── metrics-queries.js
|
||||||
|
│ │ │ └── metrics-computation.js
|
||||||
|
│ │ ├── core/ # Core utilities
|
||||||
|
│ │ │ ├── serializers.js # Format API responses
|
||||||
|
│ │ │ ├── parsers.js # Parse request data
|
||||||
|
│ │ │ ├── builders.js # Build database objects
|
||||||
|
│ │ │ └── comparators.js # Detect changes
|
||||||
|
│ │ ├── middleware/
|
||||||
|
│ │ │ └── access.js # Access control
|
||||||
|
│ │ └── utils/
|
||||||
|
│ │ ├── constants.js
|
||||||
|
│ │ ├── validation.js
|
||||||
|
│ │ └── logging.js
|
||||||
|
│ │
|
||||||
|
│ ├── projects/ # Project management
|
||||||
|
│ │ ├── routes.js
|
||||||
|
│ │ ├── repository.js
|
||||||
|
│ │ └── utils/
|
||||||
|
│ │ └── validation.js
|
||||||
|
│ │
|
||||||
|
│ ├── areas/ # Area organization
|
||||||
|
│ ├── notes/ # Notes management
|
||||||
|
│ ├── tags/ # Tag system
|
||||||
|
│ ├── users/ # User management
|
||||||
|
│ ├── auth/ # Authentication (login/register)
|
||||||
|
│ ├── shares/ # Project sharing & permissions
|
||||||
|
│ ├── telegram/ # Telegram bot integration
|
||||||
|
│ ├── inbox/ # Inbox items
|
||||||
|
│ ├── habits/ # Habit tracking
|
||||||
|
│ ├── notifications/ # Notification system
|
||||||
|
│ ├── search/ # Universal search
|
||||||
|
│ ├── views/ # Saved views
|
||||||
|
│ ├── admin/ # Admin functions
|
||||||
|
│ ├── backup/ # Backup/restore (33KB, complex)
|
||||||
|
│ ├── feature-flags/ # Feature flag management
|
||||||
|
│ ├── quotes/ # Daily quotes
|
||||||
|
│ └── url/ # URL handling
|
||||||
|
│
|
||||||
|
├── models/ # Sequelize model definitions
|
||||||
|
│ ├── index.js # Model initialization & associations
|
||||||
|
│ ├── task.js # Task model (recurrence fields)
|
||||||
|
│ ├── project.js # Project model
|
||||||
|
│ ├── area.js # Area model
|
||||||
|
│ ├── note.js # Note model
|
||||||
|
│ ├── tag.js # Tag model
|
||||||
|
│ ├── user.js # User model (bcrypt password, settings)
|
||||||
|
│ ├── permission.js # Permission/sharing model
|
||||||
|
│ ├── apiToken.js # API token model
|
||||||
|
│ ├── recurringCompletion.js
|
||||||
|
│ ├── taskEvent.js # Task audit log
|
||||||
|
│ ├── taskAttachment.js
|
||||||
|
│ ├── inboxItem.js
|
||||||
|
│ ├── notification.js
|
||||||
|
│ ├── role.js
|
||||||
|
│ ├── view.js
|
||||||
|
│ ├── backup.js
|
||||||
|
│ ├── setting.js
|
||||||
|
│ └── action.js
|
||||||
|
│
|
||||||
|
├── migrations/ # Database migrations (64+ files)
|
||||||
|
│ ├── 20240101120000-initial-schema.js
|
||||||
|
│ ├── 20240115140000-add-recurring-tasks.js
|
||||||
|
│ └── ... (timestamped migration files)
|
||||||
|
│
|
||||||
|
├── seeders/ # Database seed data
|
||||||
|
│ └── (seed files if any)
|
||||||
|
│
|
||||||
|
├── middleware/ # Global middleware
|
||||||
|
│ ├── auth.js # Authentication (session + Bearer token)
|
||||||
|
│ ├── authorize.js # Authorization (permission checking)
|
||||||
|
│ ├── rateLimiter.js # Rate limiting config (5 different limiters)
|
||||||
|
│ ├── queryLogger.js # Development query logging
|
||||||
|
│ └── permissionCache.js
|
||||||
|
│
|
||||||
|
├── services/ # Cross-cutting services
|
||||||
|
│ ├── permissionsService.js # Main permissions service
|
||||||
|
│ ├── backupService.js # Backup/restore operations
|
||||||
|
│ ├── emailService.js # Email notifications
|
||||||
|
│ ├── logService.js # Error logging
|
||||||
|
│ ├── applyPerms.js # Apply permissions
|
||||||
|
│ └── permissionsCalculators.js # Permission calculations
|
||||||
|
│
|
||||||
|
├── shared/ # Shared utilities
|
||||||
|
│ ├── errors/ # Custom error classes
|
||||||
|
│ │ ├── AppError.js
|
||||||
|
│ │ ├── NotFoundError.js
|
||||||
|
│ │ ├── ValidationError.js
|
||||||
|
│ │ ├── ConflictError.js
|
||||||
|
│ │ ├── UnauthorizedError.js
|
||||||
|
│ │ └── ForbiddenError.js
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ └── errorHandler.js # Global error handler
|
||||||
|
│ └── database/
|
||||||
|
│ └── BaseRepository.js # Base repository class
|
||||||
|
│
|
||||||
|
├── utils/ # Utility functions
|
||||||
|
│ ├── uid.js # Generate 15-char unique IDs (nanoid)
|
||||||
|
│ ├── slug-utils.js # URL slug handling, UID extraction
|
||||||
|
│ ├── timezone-utils.js # Timezone conversions, date calculations
|
||||||
|
│ ├── attachment-utils.js # File handling and validation
|
||||||
|
│ ├── migration-utils.js # Database migration helpers
|
||||||
|
│ ├── request-utils.js # Request utilities
|
||||||
|
│ └── notificationPreferences.js
|
||||||
|
│
|
||||||
|
├── config/ # Configuration
|
||||||
|
│ ├── config.js # Environment-based config
|
||||||
|
│ ├── database.js # Sequelize database config
|
||||||
|
│ └── swagger.js # Swagger API schema (30KB)
|
||||||
|
│
|
||||||
|
├── docs/ # API documentation
|
||||||
|
│ └── swagger/
|
||||||
|
│ └── (swagger doc files)
|
||||||
|
│
|
||||||
|
├── scripts/ # Utility scripts
|
||||||
|
│ └── (database management scripts)
|
||||||
|
│
|
||||||
|
└── tests/ # Backend tests
|
||||||
|
├── unit/ # Unit tests
|
||||||
|
│ ├── models/
|
||||||
|
│ │ ├── task.test.js
|
||||||
|
│ │ ├── project.test.js
|
||||||
|
│ │ ├── user.test.js
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ ├── auth.test.js
|
||||||
|
│ │ └── authorize.test.js
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── permissionsService.test.js
|
||||||
|
│ │ └── applyPerms.test.js
|
||||||
|
│ └── utils/
|
||||||
|
│ ├── timezone-utils.test.js
|
||||||
|
│ ├── slug-utils.test.js
|
||||||
|
│ ├── attachment-utils.test.js
|
||||||
|
│ └── migration-utils.test.js
|
||||||
|
│
|
||||||
|
└── integration/ # Integration tests (47+ test directories)
|
||||||
|
├── tasks/
|
||||||
|
│ ├── tasks.test.js
|
||||||
|
│ ├── subtasks.test.js
|
||||||
|
│ └── recurring.test.js
|
||||||
|
├── projects/
|
||||||
|
├── areas/
|
||||||
|
├── notes/
|
||||||
|
├── tags/
|
||||||
|
├── auth/
|
||||||
|
├── shares/
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/Users/chris/c0deLab/ProjectLand/tududi/frontend/
|
||||||
|
│
|
||||||
|
├── index.tsx # React application entry point
|
||||||
|
│ # - React root initialization
|
||||||
|
│ # - i18n setup
|
||||||
|
│ # - Dark mode initialization
|
||||||
|
│ # - Service worker cleanup
|
||||||
|
│
|
||||||
|
├── App.tsx # Root component (13KB)
|
||||||
|
│ # - Route definitions
|
||||||
|
│ # - User authentication check
|
||||||
|
│ # - Route protection
|
||||||
|
│ # - Layout wrapper
|
||||||
|
│
|
||||||
|
├── Layout.tsx # Main layout wrapper (21KB)
|
||||||
|
│ # - Sidebar integration
|
||||||
|
│ # - Navigation
|
||||||
|
│ # - Modal management
|
||||||
|
│
|
||||||
|
├── components/ # React components (feature-based)
|
||||||
|
│ │
|
||||||
|
│ ├── Task/ # Task-related components
|
||||||
|
│ │ ├── TasksToday.tsx
|
||||||
|
│ │ ├── TaskDetails.tsx
|
||||||
|
│ │ ├── TaskForm.tsx
|
||||||
|
│ │ ├── TaskItem.tsx
|
||||||
|
│ │ ├── TaskList.tsx
|
||||||
|
│ │ ├── TaskFilters.tsx
|
||||||
|
│ │ ├── SubtaskList.tsx
|
||||||
|
│ │ └── ...
|
||||||
|
│ │
|
||||||
|
│ ├── Project/ # Project components
|
||||||
|
│ │ ├── ProjectDetails.tsx
|
||||||
|
│ │ ├── ProjectForm.tsx
|
||||||
|
│ │ ├── ProjectList.tsx
|
||||||
|
│ │ ├── ProjectCard.tsx
|
||||||
|
│ │ └── ...
|
||||||
|
│ │
|
||||||
|
│ ├── Area/ # Area components
|
||||||
|
│ │ ├── AreaDetails.tsx
|
||||||
|
│ │ ├── AreaForm.tsx
|
||||||
|
│ │ └── ...
|
||||||
|
│ │
|
||||||
|
│ ├── Note/ # Note components
|
||||||
|
│ │ ├── NoteDetails.tsx
|
||||||
|
│ │ ├── NoteForm.tsx
|
||||||
|
│ │ └── ...
|
||||||
|
│ │
|
||||||
|
│ ├── Tag/ # Tag components
|
||||||
|
│ ├── Habits/ # Recurring tasks UI
|
||||||
|
│ ├── Inbox/ # Inbox management
|
||||||
|
│ │
|
||||||
|
│ ├── Calendar/ # Calendar view (27KB)
|
||||||
|
│ │ └── Calendar.tsx
|
||||||
|
│ │
|
||||||
|
│ ├── Sidebar.tsx # Left navigation sidebar
|
||||||
|
│ ├── Navbar.tsx # Top navigation bar
|
||||||
|
│ │
|
||||||
|
│ ├── Metrics/ # Productivity metrics
|
||||||
|
│ │ └── ...
|
||||||
|
│ │
|
||||||
|
│ ├── Notifications/ # Notification system
|
||||||
|
│ ├── UniversalSearch/ # Search interface
|
||||||
|
│ │
|
||||||
|
│ ├── Shared/ # Shared UI components (41 items)
|
||||||
|
│ │ ├── Modal components
|
||||||
|
│ │ │ ├── Modal.tsx
|
||||||
|
│ │ │ ├── ConfirmDialog.tsx
|
||||||
|
│ │ │ └── ...
|
||||||
|
│ │ ├── Form inputs
|
||||||
|
│ │ │ ├── Input.tsx
|
||||||
|
│ │ │ ├── Select.tsx
|
||||||
|
│ │ │ ├── DatePicker.tsx
|
||||||
|
│ │ │ └── ...
|
||||||
|
│ │ ├── ToastContext.tsx
|
||||||
|
│ │ ├── LoadingScreen.tsx
|
||||||
|
│ │ ├── Button.tsx
|
||||||
|
│ │ ├── Badge.tsx
|
||||||
|
│ │ └── ...
|
||||||
|
│ │
|
||||||
|
│ ├── Admin/ # Admin panel
|
||||||
|
│ ├── Backup/ # Backup/restore UI
|
||||||
|
│ ├── Profile/ # User profile settings
|
||||||
|
│ │ ├── ProfileSettings.tsx
|
||||||
|
│ │ ├── ApiTokens.tsx
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── Productivity/ # Analytics dashboard
|
||||||
|
│ └── Login/Register # Auth pages
|
||||||
|
│ ├── Login.tsx
|
||||||
|
│ └── Register.tsx
|
||||||
|
│
|
||||||
|
├── store/ # Zustand state management
|
||||||
|
│ └── useStore.ts # Global store (28KB)
|
||||||
|
│ # - Task state & cache
|
||||||
|
│ # - Project state & cache
|
||||||
|
│ # - UI state (modals, filters, selections)
|
||||||
|
│ # - Cache management functions
|
||||||
|
│
|
||||||
|
├── contexts/ # React contexts
|
||||||
|
│ ├── ModalContext.tsx # Modal state management
|
||||||
|
│ ├── SidebarContext.tsx # Sidebar state
|
||||||
|
│ └── TelegramStatusContext.tsx # Telegram integration status
|
||||||
|
│
|
||||||
|
├── hooks/ # Custom React hooks
|
||||||
|
│ ├── useKeyboardShortcuts.ts # Keyboard handling
|
||||||
|
│ ├── useModalManager.ts # Modal management
|
||||||
|
│ ├── usePersistedModal.ts # Modal persistence
|
||||||
|
│ └── useTasksData.ts # Task data fetching
|
||||||
|
│
|
||||||
|
├── utils/ # Frontend utilities (30+ files)
|
||||||
|
│ ├── API Services (API client utilities)
|
||||||
|
│ │ ├── tasksService.ts # Task API client
|
||||||
|
│ │ ├── projectsService.ts # Project API client
|
||||||
|
│ │ ├── notesService.ts
|
||||||
|
│ │ ├── tagsService.ts
|
||||||
|
│ │ ├── areasService.ts
|
||||||
|
│ │ ├── profileService.ts # User profile API
|
||||||
|
│ │ ├── apiKeysService.ts # API token management
|
||||||
|
│ │ ├── searchService.ts # Search API client
|
||||||
|
│ │ ├── sharesService.ts # Project sharing API
|
||||||
|
│ │ ├── backupService.ts # Backup/restore API
|
||||||
|
│ │ ├── inboxService.ts # Inbox API
|
||||||
|
│ │ ├── habitsService.ts # Habits/recurring API
|
||||||
|
│ │ ├── taskEventService.ts # Task history API
|
||||||
|
│ │ ├── taskIntelligenceService.ts # AI-assisted task mgmt
|
||||||
|
│ │ └── attachmentsService.ts # File attachment handling
|
||||||
|
│ │
|
||||||
|
│ ├── Utilities
|
||||||
|
│ │ ├── dateUtils.ts # Date/time helpers
|
||||||
|
│ │ ├── timezoneUtils.ts # Timezone handling
|
||||||
|
│ │ ├── taskSortUtils.ts # Task sorting logic
|
||||||
|
│ │ ├── localeUtils.ts # i18n helpers
|
||||||
|
│ │ ├── keyboardShortcutsService.ts # Shortcut definitions
|
||||||
|
│ │ ├── bannersService.ts # Banner management
|
||||||
|
│ │ ├── urlService.ts # URL parsing
|
||||||
|
│ │ ├── slugUtils.ts # URL slug handling
|
||||||
|
│ │ ├── userUtils.ts # User utilities
|
||||||
|
│ │ ├── fetcher.ts # SWR fetcher configuration
|
||||||
|
│ │ └── featureFlags.ts # Feature flag client
|
||||||
|
│ │
|
||||||
|
│ └── config/
|
||||||
|
│ └── paths.ts # API and path configuration
|
||||||
|
│
|
||||||
|
├── entities/ # TypeScript interfaces/types
|
||||||
|
│ ├── Task.ts # Task type definition
|
||||||
|
│ ├── Project.ts # Project type definition
|
||||||
|
│ ├── Note.ts # Note type definition
|
||||||
|
│ ├── User.ts # User type definition
|
||||||
|
│ ├── Tag.ts # Tag type definition
|
||||||
|
│ ├── Area.ts # Area type definition
|
||||||
|
│ ├── TaskEvent.ts # Task event type
|
||||||
|
│ ├── Attachment.ts # Attachment type
|
||||||
|
│ ├── InboxItem.ts # Inbox item type
|
||||||
|
│ └── Metrics.ts # Metrics type
|
||||||
|
│
|
||||||
|
├── i18n.ts # i18next configuration
|
||||||
|
│ # - Language detection
|
||||||
|
│ # - Resource loading
|
||||||
|
│ # - 24 language support
|
||||||
|
│
|
||||||
|
├── styles/ # Global styles
|
||||||
|
│ ├── globals.css
|
||||||
|
│ ├── markdown.css
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
└── __tests__/ # Frontend tests
|
||||||
|
├── setup.ts # Test configuration
|
||||||
|
└── (component tests)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E2E Tests Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/Users/chris/c0deLab/ProjectLand/tududi/e2e/
|
||||||
|
├── tests/ # Playwright test specs
|
||||||
|
│ ├── login.spec.ts
|
||||||
|
│ ├── tasks.spec.ts
|
||||||
|
│ ├── projects.spec.ts
|
||||||
|
│ ├── subtasks.spec.ts
|
||||||
|
│ ├── recurring-tasks.spec.ts
|
||||||
|
│ └── ...
|
||||||
|
└── bin/
|
||||||
|
└── run-e2e.sh # Test runner script
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Paths Reference
|
||||||
|
|
||||||
|
Quick lookup table for common development tasks:
|
||||||
|
|
||||||
|
| Task | Primary Location | Related Files |
|
||||||
|
|------|------------------|---------------|
|
||||||
|
| **Add backend feature** | `/backend/modules/[feature]/` | routes.js, repository.js, operations/ |
|
||||||
|
| **Create new model** | `/backend/models/[model].js` | Also update `/backend/models/index.js` for associations |
|
||||||
|
| **Database migration** | `/backend/migrations/TIMESTAMP-name.js` | Create with `npm run migration:create` |
|
||||||
|
| **Add React component** | `/frontend/components/[Feature]/ComponentName.tsx` | - |
|
||||||
|
| **Define API routes** | `/backend/modules/[module]/routes.js` | - |
|
||||||
|
| **Business logic** | `/backend/modules/[module]/operations/` | Or service files in module |
|
||||||
|
| **Global frontend state** | `/frontend/store/useStore.ts` | Zustand store |
|
||||||
|
| **API client** | `/frontend/utils/[resource]Service.ts` | - |
|
||||||
|
| **TypeScript types** | `/frontend/entities/[Type].ts` | Interface definitions |
|
||||||
|
| **Backend unit tests** | `/backend/tests/unit/[category]/` | models/, middleware/, services/, utils/ |
|
||||||
|
| **Backend integration tests** | `/backend/tests/integration/[module]/` | - |
|
||||||
|
| **E2E tests** | `/e2e/tests/[feature].spec.ts` | Playwright specs |
|
||||||
|
| **Middleware** | `/backend/middleware/[name].js` | auth.js, authorize.js, etc. |
|
||||||
|
| **Shared utilities** | `/backend/utils/` or `/frontend/utils/` | Depends on context |
|
||||||
|
| **Error classes** | `/backend/shared/errors/` | Custom error types |
|
||||||
|
| **Swagger docs** | `/backend/config/swagger.js` | API schema definitions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Back to Index](../CLAUDE.md)
|
||||||
526
docs/testing.md
Normal file
526
docs/testing.md
Normal file
|
|
@ -0,0 +1,526 @@
|
||||||
|
# Testing Requirements
|
||||||
|
|
||||||
|
[← Back to Index](../CLAUDE.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
/backend/tests/
|
||||||
|
├── unit/ # Unit tests for isolated logic
|
||||||
|
│ ├── models/ # Model tests
|
||||||
|
│ │ ├── task.test.js
|
||||||
|
│ │ ├── project.test.js
|
||||||
|
│ │ ├── user.test.js
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── middleware/ # Middleware tests
|
||||||
|
│ │ ├── auth.test.js
|
||||||
|
│ │ └── authorize.test.js
|
||||||
|
│ ├── services/ # Service tests
|
||||||
|
│ │ ├── permissionsService.test.js
|
||||||
|
│ │ ├── applyPerms.test.js
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── utils/ # Utility tests
|
||||||
|
│ ├── timezone-utils.test.js
|
||||||
|
│ ├── slug-utils.test.js
|
||||||
|
│ ├── attachment-utils.test.js
|
||||||
|
│ └── migration-utils.test.js
|
||||||
|
│
|
||||||
|
└── integration/ # Integration tests for API endpoints
|
||||||
|
├── tasks/
|
||||||
|
│ ├── tasks.test.js
|
||||||
|
│ ├── subtasks.test.js
|
||||||
|
│ └── recurring.test.js
|
||||||
|
├── projects/
|
||||||
|
│ └── projects.test.js
|
||||||
|
├── areas/
|
||||||
|
├── notes/
|
||||||
|
├── tags/
|
||||||
|
├── auth/
|
||||||
|
├── shares/
|
||||||
|
└── ... (47+ test directories)
|
||||||
|
|
||||||
|
/e2e/tests/ # E2E tests (Playwright)
|
||||||
|
├── login.spec.ts
|
||||||
|
├── tasks.spec.ts
|
||||||
|
├── projects.spec.ts
|
||||||
|
├── subtasks.spec.ts
|
||||||
|
└── ...
|
||||||
|
|
||||||
|
/frontend/__tests__/ # Frontend tests
|
||||||
|
├── setup.ts # Test configuration
|
||||||
|
└── components/
|
||||||
|
└── ... (component tests)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Backend Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all backend tests
|
||||||
|
npm test
|
||||||
|
# or
|
||||||
|
npm run backend:test
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
npm test -- backend/tests/unit/models/task.test.js
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
npm run test:coverage
|
||||||
|
|
||||||
|
# Watch mode (re-run on file changes)
|
||||||
|
npm run test:watch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run frontend tests
|
||||||
|
npm run frontend:test
|
||||||
|
|
||||||
|
# Watch mode
|
||||||
|
npm run frontend:test -- --watch
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Headless mode (default)
|
||||||
|
npm run test:ui
|
||||||
|
|
||||||
|
# Headed mode (see browser)
|
||||||
|
npm run test:ui:headed
|
||||||
|
|
||||||
|
# Specific test file
|
||||||
|
npx playwright test e2e/tests/tasks.spec.ts
|
||||||
|
|
||||||
|
# Debug mode
|
||||||
|
npx playwright test --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-Push Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all checks before committing/pushing
|
||||||
|
npm run pre-push
|
||||||
|
|
||||||
|
# This runs:
|
||||||
|
# - ESLint checks
|
||||||
|
# - Prettier formatting
|
||||||
|
# - Backend tests
|
||||||
|
# - Type checking (if applicable)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
### For Bug Fixes
|
||||||
|
|
||||||
|
**MUST include a test** that would have caught the bug.
|
||||||
|
|
||||||
|
**Process:**
|
||||||
|
1. Write failing test that demonstrates the bug
|
||||||
|
2. Fix the bug
|
||||||
|
3. Verify test now passes
|
||||||
|
4. Submit PR with both test and fix
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```javascript
|
||||||
|
// Test for bug: completed tasks showing in Today view
|
||||||
|
it('should not return completed tasks in Today view', async () => {
|
||||||
|
// Arrange - Create completed task
|
||||||
|
await Task.create({
|
||||||
|
name: 'Completed Task',
|
||||||
|
status: 2, // completed
|
||||||
|
due_date: new Date().toISOString().split('T')[0],
|
||||||
|
user_id: user.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act - Get today's tasks
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/api/v1/tasks/today')
|
||||||
|
.set('Cookie', authCookie);
|
||||||
|
|
||||||
|
// Assert - No completed tasks
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const completedTasks = response.body.filter(t => t.status === 2);
|
||||||
|
expect(completedTasks.length).toBe(0);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### For New Features
|
||||||
|
|
||||||
|
**SHOULD include relevant tests** covering:
|
||||||
|
- Happy path (success case)
|
||||||
|
- Common edge cases
|
||||||
|
- Error conditions
|
||||||
|
|
||||||
|
**Not required to test:**
|
||||||
|
- Every possible combination
|
||||||
|
- Framework internals
|
||||||
|
- Third-party library behavior
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Patterns
|
||||||
|
|
||||||
|
### Backend Integration Test
|
||||||
|
|
||||||
|
**Arrange-Act-Assert Pattern:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// /backend/tests/integration/tasks/tasks.test.js
|
||||||
|
const request = require('supertest');
|
||||||
|
const app = require('../../../app');
|
||||||
|
const { Task, User } = require('../../../models');
|
||||||
|
|
||||||
|
describe('Task API', () => {
|
||||||
|
let user;
|
||||||
|
let authCookie;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Setup: Create user and authenticate
|
||||||
|
user = await User.create({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'password123'
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/login')
|
||||||
|
.send({ email: 'test@example.com', password: 'password123' });
|
||||||
|
authCookie = res.headers['set-cookie'];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Cleanup
|
||||||
|
await Task.destroy({ where: {} });
|
||||||
|
await User.destroy({ where: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create task with valid data', async () => {
|
||||||
|
// Arrange
|
||||||
|
const taskData = {
|
||||||
|
name: 'Test Task',
|
||||||
|
priority: 1,
|
||||||
|
due_date: '2026-03-15'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/v1/task')
|
||||||
|
.set('Cookie', authCookie)
|
||||||
|
.send(taskData);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body.name).toBe('Test Task');
|
||||||
|
expect(response.body.priority).toBe(1);
|
||||||
|
|
||||||
|
// Verify in database
|
||||||
|
const task = await Task.findOne({ where: { name: 'Test Task' } });
|
||||||
|
expect(task).not.toBeNull();
|
||||||
|
expect(task.user_id).toBe(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for missing name', async () => {
|
||||||
|
// Arrange
|
||||||
|
const invalidData = { priority: 1 };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/v1/task')
|
||||||
|
.set('Cookie', authCookie)
|
||||||
|
.send(invalidData);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent task', async () => {
|
||||||
|
// Act
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/api/v1/task/99999')
|
||||||
|
.set('Cookie', authCookie);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Unit Test
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// /backend/tests/unit/utils/timezone-utils.test.js
|
||||||
|
const { getTodayBoundsInUTC } = require('../../../utils/timezone-utils');
|
||||||
|
|
||||||
|
describe('timezone-utils', () => {
|
||||||
|
describe('getTodayBoundsInUTC', () => {
|
||||||
|
it('should return UTC bounds for today in given timezone', () => {
|
||||||
|
// Arrange
|
||||||
|
const timezone = 'America/New_York';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { startOfDay, endOfDay } = getTodayBoundsInUTC(timezone);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(startOfDay).toBeInstanceOf(Date);
|
||||||
|
expect(endOfDay).toBeInstanceOf(Date);
|
||||||
|
expect(endOfDay.getTime()).toBeGreaterThan(startOfDay.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle invalid timezone gracefully', () => {
|
||||||
|
// Arrange
|
||||||
|
const invalidTimezone = 'Invalid/Timezone';
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
expect(() => getTodayBoundsInUTC(invalidTimezone)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Component Test
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// /frontend/components/Task/__tests__/TaskItem.test.tsx
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { TaskItem } from '../TaskItem';
|
||||||
|
import { Task } from '../../../entities/Task';
|
||||||
|
|
||||||
|
describe('TaskItem', () => {
|
||||||
|
const mockTask: Task = {
|
||||||
|
id: 1,
|
||||||
|
uid: 'test-uid-123',
|
||||||
|
name: 'Test Task',
|
||||||
|
completed: false,
|
||||||
|
priority: 1,
|
||||||
|
due_date: '2026-03-15'
|
||||||
|
};
|
||||||
|
|
||||||
|
it('renders task name', () => {
|
||||||
|
// Act
|
||||||
|
render(<TaskItem task={mockTask} onUpdate={jest.fn()} />);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(screen.getByText('Test Task')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows priority badge', () => {
|
||||||
|
// Act
|
||||||
|
render(<TaskItem task={mockTask} onUpdate={jest.fn()} />);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(screen.getByText('Medium')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onUpdate when checkbox is clicked', () => {
|
||||||
|
// Arrange
|
||||||
|
const mockOnUpdate = jest.fn();
|
||||||
|
render(<TaskItem task={mockTask} onUpdate={mockOnUpdate} />);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const checkbox = screen.getByRole('checkbox');
|
||||||
|
fireEvent.click(checkbox);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockOnUpdate).toHaveBeenCalledWith({
|
||||||
|
...mockTask,
|
||||||
|
completed: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies completed styling when task is done', () => {
|
||||||
|
// Arrange
|
||||||
|
const completedTask = { ...mockTask, completed: true };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
render(<TaskItem task={completedTask} onUpdate={jest.fn()} />);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
const taskElement = screen.getByText('Test Task').closest('div');
|
||||||
|
expect(taskElement).toHaveClass('line-through');
|
||||||
|
expect(taskElement).toHaveClass('opacity-50');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E Test (Playwright)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// /e2e/tests/tasks.spec.ts
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Task Management', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Login before each test
|
||||||
|
await page.goto('http://localhost:8080/login');
|
||||||
|
await page.fill('input[name="email"]', 'test@example.com');
|
||||||
|
await page.fill('input[name="password"]', 'password123');
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await page.waitForURL('**/tasks');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create new task', async ({ page }) => {
|
||||||
|
// Arrange
|
||||||
|
await page.click('button:has-text("New Task")');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await page.fill('input[name="name"]', 'E2E Test Task');
|
||||||
|
await page.selectOption('select[name="priority"]', '1');
|
||||||
|
await page.fill('input[name="due_date"]', '2026-03-15');
|
||||||
|
await page.click('button:has-text("Save")');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expect(page.locator('text=E2E Test Task')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should complete task', async ({ page }) => {
|
||||||
|
// Arrange - Create a task first
|
||||||
|
await page.click('button:has-text("New Task")');
|
||||||
|
await page.fill('input[name="name"]', 'Task to Complete');
|
||||||
|
await page.click('button:has-text("Save")');
|
||||||
|
|
||||||
|
// Act - Complete the task
|
||||||
|
const taskItem = page.locator('text=Task to Complete').locator('..');
|
||||||
|
await taskItem.locator('input[type="checkbox"]').check();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expect(taskItem).toHaveClass(/line-through/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should filter tasks by priority', async ({ page }) => {
|
||||||
|
// Arrange - Create tasks with different priorities
|
||||||
|
await createTask(page, 'High Priority Task', 2);
|
||||||
|
await createTask(page, 'Low Priority Task', 0);
|
||||||
|
|
||||||
|
// Act - Filter by high priority
|
||||||
|
await page.selectOption('select[name="priority_filter"]', '2');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expect(page.locator('text=High Priority Task')).toBeVisible();
|
||||||
|
await expect(page.locator('text=Low Priority Task')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createTask(page, name: string, priority: number) {
|
||||||
|
await page.click('button:has-text("New Task")');
|
||||||
|
await page.fill('input[name="name"]', name);
|
||||||
|
await page.selectOption('select[name="priority"]', priority.toString());
|
||||||
|
await page.click('button:has-text("Save")');
|
||||||
|
await page.waitForSelector(`text=${name}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Database
|
||||||
|
|
||||||
|
Backend tests use a separate test database:
|
||||||
|
|
||||||
|
- Automatically created in test environment
|
||||||
|
- Migrations run before tests
|
||||||
|
- Database cleared between tests (in `afterEach`)
|
||||||
|
- Configured in `/backend/config/database.js`
|
||||||
|
|
||||||
|
**Example cleanup:**
|
||||||
|
```javascript
|
||||||
|
afterEach(async () => {
|
||||||
|
// Clean up test data
|
||||||
|
await Task.destroy({ where: {} });
|
||||||
|
await Project.destroy({ where: {} });
|
||||||
|
await User.destroy({ where: {} });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mocking
|
||||||
|
|
||||||
|
### Mock External Services
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Mock email service in tests
|
||||||
|
jest.mock('../../../services/emailService', () => ({
|
||||||
|
sendEmail: jest.fn().mockResolvedValue(true)
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should send notification email', async () => {
|
||||||
|
const emailService = require('../../../services/emailService');
|
||||||
|
|
||||||
|
await taskService.create({ name: 'Task', notify: true }, userId);
|
||||||
|
|
||||||
|
expect(emailService.sendEmail).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mock Frontend API Calls
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { rest } from 'msw';
|
||||||
|
import { setupServer } from 'msw/node';
|
||||||
|
|
||||||
|
const server = setupServer(
|
||||||
|
rest.get('/api/v1/tasks', (req, res, ctx) => {
|
||||||
|
return res(ctx.json([
|
||||||
|
{ id: 1, name: 'Mocked Task' }
|
||||||
|
]));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeAll(() => server.listen());
|
||||||
|
afterEach(() => server.resetHandlers());
|
||||||
|
afterAll(() => server.close());
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coverage Goals
|
||||||
|
|
||||||
|
While not strictly enforced, aim for:
|
||||||
|
- **Critical paths:** 80%+ coverage
|
||||||
|
- **Business logic:** 70%+ coverage
|
||||||
|
- **UI components:** 50%+ coverage
|
||||||
|
|
||||||
|
**Run coverage report:**
|
||||||
|
```bash
|
||||||
|
npm run test:coverage
|
||||||
|
|
||||||
|
# Open HTML report
|
||||||
|
open coverage/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Before Submitting PR
|
||||||
|
|
||||||
|
✅ All tests passing:
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
npm run test:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ No linting errors:
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ Code formatted:
|
||||||
|
```bash
|
||||||
|
npm run format:fix
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ Run pre-push checks:
|
||||||
|
```bash
|
||||||
|
npm run pre-push
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Back to Index](../CLAUDE.md)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue