diff --git a/.gitignore b/.gitignore index 50aa4c9..c807574 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,8 @@ certs/ .DS_Store .cursor AGENTS.md -CLAUDE.md -CLAUDE.local.md +# CLAUDE.md - project documentation (committed) +CLAUDE.local.md # User-specific customizations (ignored) .byebug_history node_modules diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6257982 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/backend/tests/unit/middleware/auth.test.js b/backend/tests/unit/middleware/auth.test.js index 98d75fb..adff5a9 100644 --- a/backend/tests/unit/middleware/auth.test.js +++ b/backend/tests/unit/middleware/auth.test.js @@ -1,5 +1,6 @@ const { requireAuth } = require('../../../middleware/auth'); const { User } = require('../../../models'); +const { createApiToken } = require('../../../modules/users/apiTokenService'); describe('Auth Middleware', () => { let req, res, next; @@ -135,4 +136,154 @@ describe('Auth Middleware', () => { User.findByPk = originalFindByPk; 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(); + }); + }); }); diff --git a/backend/tests/unit/middleware/authorize.test.js b/backend/tests/unit/middleware/authorize.test.js new file mode 100644 index 0000000..048a74a --- /dev/null +++ b/backend/tests/unit/middleware/authorize.test.js @@ -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(); + }); +}); diff --git a/backend/tests/unit/services/applyPerms.test.js b/backend/tests/unit/services/applyPerms.test.js new file mode 100644 index 0000000..6661993 --- /dev/null +++ b/backend/tests/unit/services/applyPerms.test.js @@ -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 + }); +}); diff --git a/backend/tests/unit/services/permissionsService.test.js b/backend/tests/unit/services/permissionsService.test.js new file mode 100644 index 0000000..d18ba6a --- /dev/null +++ b/backend/tests/unit/services/permissionsService.test.js @@ -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'); + }); + }); +}); diff --git a/backend/tests/unit/services/rolesService.test.js b/backend/tests/unit/services/rolesService.test.js new file mode 100644 index 0000000..d951fd2 --- /dev/null +++ b/backend/tests/unit/services/rolesService.test.js @@ -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); + }); + }); +}); diff --git a/backend/tests/unit/shared/BaseRepository.test.js b/backend/tests/unit/shared/BaseRepository.test.js new file mode 100644 index 0000000..b6591a2 --- /dev/null +++ b/backend/tests/unit/shared/BaseRepository.test.js @@ -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); + }); + }); +}); diff --git a/backend/tests/unit/shared/errorHandler.test.js b/backend/tests/unit/shared/errorHandler.test.js new file mode 100644 index 0000000..2434e1b --- /dev/null +++ b/backend/tests/unit/shared/errorHandler.test.js @@ -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); + }); +}); diff --git a/backend/tests/unit/utils/migration-utils.test.js b/backend/tests/unit/utils/migration-utils.test.js new file mode 100644 index 0000000..1741b76 --- /dev/null +++ b/backend/tests/unit/utils/migration-utils.test.js @@ -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'); + }); + }); +}); diff --git a/backend/tests/unit/utils/uid.test.js b/backend/tests/unit/utils/uid.test.js new file mode 100644 index 0000000..fef4519 --- /dev/null +++ b/backend/tests/unit/utils/uid.test.js @@ -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]+$/); + }); +}); diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..c7a4d9e --- /dev/null +++ b/docs/architecture.md @@ -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) diff --git a/docs/backend-patterns.md b/docs/backend-patterns.md new file mode 100644 index 0000000..9f00414 --- /dev/null +++ b/docs/backend-patterns.md @@ -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) diff --git a/docs/code-conventions.md b/docs/code-conventions.md new file mode 100644 index 0000000..ac5f7d6 --- /dev/null +++ b/docs/code-conventions.md @@ -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 = ({ task, onUpdate }) => { + const [editing, setEditing] = useState(false); + + useEffect(() => { + // Side effects here + }, [task]); + + return
{task.name}
; +}; + +// ❌ 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 +
+

+ {task.name} +

+
+ +// Conditional classes +
+ {task.name} +
+ +// Complex conditionals - use clsx or classnames +import clsx from 'clsx'; + +
+``` + +--- + +## 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} 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 +
{task.name}
+ +// ⚠️ Only use dangerouslySetInnerHTML for trusted, sanitized content +import DOMPurify from 'dompurify'; +
+``` + +--- + +## 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
{task.name}
; +}); +``` + +--- + +[← Back to Index](../CLAUDE.md) diff --git a/docs/common-tasks.md b/docs/common-tasks.md new file mode 100644 index 0000000..7312092 --- /dev/null +++ b/docs/common-tasks.md @@ -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 = ({ 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 ( + + {config.label} + + ); +}; +``` + +### 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 ( +
+ + + {t('task.estimatedTimeUnit')} +
+ ); + }; + ``` + +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) diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..2e5c91f --- /dev/null +++ b/docs/database.md @@ -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) diff --git a/docs/development-workflow.md b/docs/development-workflow.md new file mode 100644 index 0000000..0ae4f4c --- /dev/null +++ b/docs/development-workflow.md @@ -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 +
+ + 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" + /> +
+``` + +### 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) diff --git a/docs/directory-structure.md b/docs/directory-structure.md new file mode 100644 index 0000000..5cec27a --- /dev/null +++ b/docs/directory-structure.md @@ -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) diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..58fd751 --- /dev/null +++ b/docs/testing.md @@ -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(); + + // Assert + expect(screen.getByText('Test Task')).toBeInTheDocument(); + }); + + it('shows priority badge', () => { + // Act + render(); + + // Assert + expect(screen.getByText('Medium')).toBeInTheDocument(); + }); + + it('calls onUpdate when checkbox is clicked', () => { + // Arrange + const mockOnUpdate = jest.fn(); + render(); + + // 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(); + + // 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)