Add comprehensive LLM development documentation (#939)

* Increase coverage

* Add comprehensive LLM development documentation

- Add CLAUDE.md as main documentation index
- Create 8 detailed documentation files in docs/:
  - architecture.md: Tech stack, data models, auth system
  - directory-structure.md: Complete file tree with paths
  - backend-patterns.md: Module architecture and patterns
  - database.md: Models, migrations, and workflows
  - development-workflow.md: Setup and daily development
  - code-conventions.md: Style guide and best practices
  - testing.md: Test organization and patterns
  - common-tasks.md: How-to guides for frequent tasks
- Update .gitignore to allow project-level CLAUDE.md
- 4,285 lines of comprehensive documentation
- Organized for easy navigation with cross-links
- LLM-optimized with absolute paths and code examples

* fixup! Add comprehensive LLM development documentation
This commit is contained in:
Chris 2026-03-14 02:54:59 +02:00 committed by GitHub
parent 25b11086e2
commit 3486541272
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 6086 additions and 2 deletions

4
.gitignore vendored
View file

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

166
CLAUDE.md Normal file
View file

@ -0,0 +1,166 @@
# Tududi - Developer Guide
This documentation is designed for AI assistants and developers working with the tududi codebase. For user-facing documentation, see [README.md](README.md). For contribution guidelines, see [CONTRIBUTING.md](.github/CONTRIBUTING.md).
---
## Quick Start
Tududi is a self-hosted task management system with hierarchical organization (Areas > Projects > Tasks), smart recurring tasks, and multi-channel integration.
**Tech Stack:** React 18 + TypeScript, Express + Sequelize, SQLite
**Get Started:**
```bash
git clone https://github.com/chrisvel/tududi.git
cd tududi
npm install
npm run db:init
npm start # Frontend on :8080, Backend on :3002
```
---
## Documentation Index
### Core Documentation
1. **[Architecture Overview](docs/architecture.md)**
- Tech stack details
- Request flow diagram
- Data model hierarchy
- Authentication methods
2. **[Directory Structure](docs/directory-structure.md)**
- Complete file tree with absolute paths
- Critical paths reference
- Backend and frontend organization
3. **[Backend Patterns](docs/backend-patterns.md)**
- Module architecture pattern
- How to add new modules
- Module communication
- Repository and service patterns
4. **[Database & Migrations](docs/database.md)**
- Key models and relationships
- Migration workflow
- Migration best practices
- Common migration operations
5. **[Development Workflow](docs/development-workflow.md)**
- Initial setup
- Daily development (two-server process)
- Environment variables
- Adding new features (complete walkthrough)
- Database management commands
6. **[Code Conventions](docs/code-conventions.md)**
- Language usage (TypeScript/JavaScript)
- Backend patterns (async/await, repository)
- Frontend patterns (components, state)
- Naming conventions
- API route conventions
7. **[Testing](docs/testing.md)**
- Test organization
- Running tests
- Testing requirements
- Test patterns (Arrange-Act-Assert)
8. **[Common Tasks](docs/common-tasks.md)**
- Add field to model
- Create new backend module
- Add React component
- Update database schema
- Fix a bug (TDD workflow)
- Add translations
---
## Project Overview
### What This Project Does
Tududi is a self-hosted task management system designed around hierarchical organization and smart automation. It prioritizes user flow over rigid structures - a productivity tool that doesn't "fight back."
**Core Philosophy:**
- [Designing a Life Management System That Doesn't Fight Back](https://medium.com/@chrisveleris/designing-a-life-management-system-that-doesnt-fight-back-2fd58773e857)
- [From Task to Table: How I Finally Got to the Korean Burger](https://medium.com/@chrisveleris/from-task-to-table-how-i-finally-got-to-the-korean-burger-01245a14d491)
**Key Capabilities:**
- **Hierarchical Organization:** Areas > Projects > Tasks > Subtasks
- **Smart Recurring Tasks:** Multiple patterns with parent-child tracking
- **Multi-Language Support:** 24 languages via i18next
- **Collaboration:** Project sharing with granular permissions
- **REST API:** Swagger docs + personal API tokens
- **Telegram Integration:** Create tasks via messages, daily digests
- **Tag System:** Flexible tagging across tasks, notes, projects
**Target Users:** Self-hosting individuals and teams managing personal or collaborative productivity
---
## Technology Stack
**Frontend:**
- React 18 + TypeScript 5.6
- Webpack 5 (build) + webpack-dev-server (development)
- Tailwind CSS 3.4 + Heroicons
- Zustand (global state) + SWR (server state)
- React Router 6, i18next (24 languages)
**Backend:**
- Express 4.21 + Sequelize 6.37 (ORM)
- SQLite 5.1 (WAL mode, optimized)
- bcrypt + express-session (auth)
- Swagger (API docs), Multer (uploads)
- node-cron (scheduling), Nodemailer (email)
**Testing:**
- Jest (backend + frontend)
- Playwright (E2E)
- Supertest (API integration tests)
---
## Critical Paths Quick Reference
| Task | Location |
|------|----------|
| Add backend feature | `/backend/modules/[feature]/` |
| Create model | `/backend/models/[model].js` |
| Database migration | `/backend/migrations/` |
| React component | `/frontend/components/[Feature]/` |
| API routes | `/backend/modules/[module]/routes.js` |
| Global state | `/frontend/store/useStore.ts` |
| API client | `/frontend/utils/[resource]Service.ts` |
---
## Related Documentation
| Document | Audience | Purpose |
|----------|----------|---------|
| [README.md](README.md) | Users | Features, Docker setup, quick start |
| [CONTRIBUTING.md](.github/CONTRIBUTING.md) | Contributors | PR workflow, code of conduct |
| [docs.tududi.com](https://docs.tududi.com) | End users | Full user documentation |
| [Swagger API docs](http://localhost:3002/api-docs) | API consumers | API endpoints (after auth) |
| **CLAUDE.md** | Developers, AI | Codebase architecture, patterns |
---
## External Resources
- **Roadmap:** [GitHub Project](https://github.com/users/chrisvel/projects/2)
- **Community:**
- [Discord](https://discord.gg/fkbeJ9CmcH)
- [Reddit](https://www.reddit.com/r/tududi/)
- [Issues](https://github.com/chrisvel/tududi/issues)
- [Discussions](https://github.com/chrisvel/tududi/discussions)
---
**Document Version:** 1.0.0
**Last Updated:** 2026-03-13
**Maintainer:** Update when architecture changes or patterns evolve

View file

@ -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();
});
});
});

View file

@ -0,0 +1,265 @@
const { hasAccess } = require('../../../middleware/authorize');
const permissionsService = require('../../../services/permissionsService');
jest.mock('../../../services/permissionsService');
describe('authorize middleware hasAccess', () => {
let req, res, next;
beforeEach(() => {
req = {
currentUser: { id: 42 },
session: { userId: 42 },
params: { uid: 'abc123' },
};
res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
next = jest.fn();
jest.clearAllMocks();
});
// --- UID resolution ---
it('should resolve uid from a function', async () => {
permissionsService.getAccess.mockResolvedValue('rw');
const mw = hasAccess('ro', 'project', (r) => r.params.uid);
await mw(req, res, next);
expect(permissionsService.getAccess).toHaveBeenCalledWith(
42,
'project',
'abc123'
);
expect(next).toHaveBeenCalled();
});
it('should resolve uid from a static string', async () => {
permissionsService.getAccess.mockResolvedValue('rw');
const mw = hasAccess('ro', 'project', 'static-uid');
await mw(req, res, next);
expect(permissionsService.getAccess).toHaveBeenCalledWith(
42,
'project',
'static-uid'
);
expect(next).toHaveBeenCalled();
});
it('should resolve uid from an async function', async () => {
permissionsService.getAccess.mockResolvedValue('rw');
const mw = hasAccess('ro', 'task', async (r) => r.params.uid);
await mw(req, res, next);
expect(permissionsService.getAccess).toHaveBeenCalledWith(
42,
'task',
'abc123'
);
expect(next).toHaveBeenCalled();
});
it('should return 404 when uid resolves to null', async () => {
const mw = hasAccess('ro', 'project', () => null);
await mw(req, res, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Not found' });
expect(next).not.toHaveBeenCalled();
});
it('should return 404 when uid resolves to undefined', async () => {
const mw = hasAccess('ro', 'project', () => undefined);
await mw(req, res, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(next).not.toHaveBeenCalled();
});
it('should return 404 when uid resolves to empty string', async () => {
const mw = hasAccess('ro', 'project', () => '');
await mw(req, res, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(next).not.toHaveBeenCalled();
});
// --- Permission hierarchy ---
it('should allow access when user has exact required level (ro)', async () => {
permissionsService.getAccess.mockResolvedValue('ro');
const mw = hasAccess('ro', 'project', () => 'uid1');
await mw(req, res, next);
expect(next).toHaveBeenCalled();
});
it('should allow access when user has higher than required (rw > ro)', async () => {
permissionsService.getAccess.mockResolvedValue('rw');
const mw = hasAccess('ro', 'project', () => 'uid1');
await mw(req, res, next);
expect(next).toHaveBeenCalled();
});
it('should allow access when user has admin and rw is required', async () => {
permissionsService.getAccess.mockResolvedValue('admin');
const mw = hasAccess('rw', 'project', () => 'uid1');
await mw(req, res, next);
expect(next).toHaveBeenCalled();
});
it('should deny access when user has ro but rw is required', async () => {
permissionsService.getAccess.mockResolvedValue('ro');
const mw = hasAccess('rw', 'project', () => 'uid1');
await mw(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' });
expect(next).not.toHaveBeenCalled();
});
it('should deny access when user has none', async () => {
permissionsService.getAccess.mockResolvedValue('none');
const mw = hasAccess('ro', 'project', () => 'uid1');
await mw(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' });
expect(next).not.toHaveBeenCalled();
});
// --- User ID extraction ---
it('should prefer currentUser.id over session.userId', async () => {
req.currentUser = { id: 100 };
req.session = { userId: 200 };
permissionsService.getAccess.mockResolvedValue('rw');
const mw = hasAccess('ro', 'project', () => 'uid1');
await mw(req, res, next);
expect(permissionsService.getAccess).toHaveBeenCalledWith(
100,
'project',
'uid1'
);
});
it('should fall back to session.userId when currentUser is missing', async () => {
req.currentUser = null;
req.session = { userId: 200 };
permissionsService.getAccess.mockResolvedValue('rw');
const mw = hasAccess('ro', 'project', () => 'uid1');
await mw(req, res, next);
expect(permissionsService.getAccess).toHaveBeenCalledWith(
200,
'project',
'uid1'
);
});
// --- Options ---
it('should use custom notFoundMessage', async () => {
const mw = hasAccess('ro', 'project', () => null, {
notFoundMessage: 'Project not found',
});
await mw(req, res, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Project not found' });
});
it('should return 404 instead of 403 when forbiddenStatus is 404', async () => {
permissionsService.getAccess.mockResolvedValue('none');
const mw = hasAccess('rw', 'project', () => 'uid1', {
forbiddenStatus: 404,
});
await mw(req, res, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Not found' });
});
it('should use custom notFoundMessage with forbiddenStatus 404', async () => {
permissionsService.getAccess.mockResolvedValue('none');
const mw = hasAccess('rw', 'project', () => 'uid1', {
forbiddenStatus: 404,
notFoundMessage: 'Task not found',
});
await mw(req, res, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Task not found' });
});
// --- Resource types ---
it('should pass correct resource type for tasks', async () => {
permissionsService.getAccess.mockResolvedValue('rw');
const mw = hasAccess('rw', 'task', () => 'task-uid');
await mw(req, res, next);
expect(permissionsService.getAccess).toHaveBeenCalledWith(
42,
'task',
'task-uid'
);
});
it('should pass correct resource type for notes', async () => {
permissionsService.getAccess.mockResolvedValue('rw');
const mw = hasAccess('rw', 'note', () => 'note-uid');
await mw(req, res, next);
expect(permissionsService.getAccess).toHaveBeenCalledWith(
42,
'note',
'note-uid'
);
});
// --- Error handling ---
it('should forward errors from getAccess to next()', async () => {
const error = new Error('Database failure');
permissionsService.getAccess.mockRejectedValue(error);
const mw = hasAccess('ro', 'project', () => 'uid1');
await mw(req, res, next);
expect(next).toHaveBeenCalledWith(error);
expect(res.status).not.toHaveBeenCalled();
});
it('should forward errors from getResourceUid to next()', async () => {
const error = new Error('UID lookup failed');
const mw = hasAccess('ro', 'project', () => {
throw error;
});
await mw(req, res, next);
expect(next).toHaveBeenCalledWith(error);
expect(res.status).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,258 @@
const { applyPerms } = require('../../../services/applyPerms');
const { sequelize, Permission, User } = require('../../../models');
const bcrypt = require('bcrypt');
describe('applyPerms', () => {
let owner, target;
beforeEach(async () => {
await sequelize.query('DELETE FROM permissions');
const hash = await bcrypt.hash('pass', 10);
owner = await User.create({
email: 'owner@test.com',
password_digest: hash,
});
target = await User.create({
email: 'target@test.com',
password_digest: hash,
});
});
it('should create a new permission on upsert when none exists', async () => {
await sequelize.transaction(async (tx) => {
await applyPerms(tx, {
upserts: [
{
userId: target.id,
resourceType: 'project',
resourceUid: 'proj-uid-1',
accessLevel: 'ro',
propagation: 'direct',
grantedByUserId: owner.id,
},
],
deletes: [],
});
});
const perm = await Permission.findOne({
where: {
user_id: target.id,
resource_type: 'project',
resource_uid: 'proj-uid-1',
},
});
expect(perm).not.toBeNull();
expect(perm.access_level).toBe('ro');
expect(perm.propagation).toBe('direct');
expect(perm.granted_by_user_id).toBe(owner.id);
});
it('should upgrade access level on upsert when permission already exists (ro -> rw)', async () => {
// Create existing ro permission
await Permission.create({
user_id: target.id,
resource_type: 'project',
resource_uid: 'proj-uid-2',
access_level: 'ro',
propagation: 'direct',
granted_by_user_id: owner.id,
});
await sequelize.transaction(async (tx) => {
await applyPerms(tx, {
upserts: [
{
userId: target.id,
resourceType: 'project',
resourceUid: 'proj-uid-2',
accessLevel: 'rw',
grantedByUserId: owner.id,
},
],
deletes: [],
});
});
const perm = await Permission.findOne({
where: {
user_id: target.id,
resource_type: 'project',
resource_uid: 'proj-uid-2',
},
});
expect(perm.access_level).toBe('rw');
});
it('should keep rw when upserting ro on existing rw permission', async () => {
await Permission.create({
user_id: target.id,
resource_type: 'project',
resource_uid: 'proj-uid-3',
access_level: 'rw',
propagation: 'direct',
granted_by_user_id: owner.id,
});
await sequelize.transaction(async (tx) => {
await applyPerms(tx, {
upserts: [
{
userId: target.id,
resourceType: 'project',
resourceUid: 'proj-uid-3',
accessLevel: 'ro',
grantedByUserId: owner.id,
},
],
deletes: [],
});
});
const perm = await Permission.findOne({
where: {
user_id: target.id,
resource_type: 'project',
resource_uid: 'proj-uid-3',
},
});
expect(perm.access_level).toBe('rw');
});
it('should delete a permission', async () => {
await Permission.create({
user_id: target.id,
resource_type: 'task',
resource_uid: 'task-uid-1',
access_level: 'rw',
propagation: 'direct',
granted_by_user_id: owner.id,
});
await sequelize.transaction(async (tx) => {
await applyPerms(tx, {
upserts: [],
deletes: [
{
userId: target.id,
resourceType: 'task',
resourceUid: 'task-uid-1',
},
],
});
});
const perm = await Permission.findOne({
where: {
user_id: target.id,
resource_type: 'task',
resource_uid: 'task-uid-1',
},
});
expect(perm).toBeNull();
});
it('should handle empty upserts and deletes', async () => {
await sequelize.transaction(async (tx) => {
await applyPerms(tx, { upserts: [], deletes: [] });
});
// No error thrown
});
it('should handle multiple upserts and deletes in one call', async () => {
await Permission.create({
user_id: target.id,
resource_type: 'note',
resource_uid: 'note-to-delete',
access_level: 'ro',
propagation: 'direct',
granted_by_user_id: owner.id,
});
await sequelize.transaction(async (tx) => {
await applyPerms(tx, {
upserts: [
{
userId: target.id,
resourceType: 'project',
resourceUid: 'new-proj',
accessLevel: 'rw',
propagation: 'direct',
grantedByUserId: owner.id,
},
{
userId: target.id,
resourceType: 'task',
resourceUid: 'new-task',
accessLevel: 'ro',
propagation: 'inherited',
grantedByUserId: owner.id,
},
],
deletes: [
{
userId: target.id,
resourceType: 'note',
resourceUid: 'note-to-delete',
},
],
});
});
const projPerm = await Permission.findOne({
where: { user_id: target.id, resource_uid: 'new-proj' },
});
const taskPerm = await Permission.findOne({
where: { user_id: target.id, resource_uid: 'new-task' },
});
const notePerm = await Permission.findOne({
where: { user_id: target.id, resource_uid: 'note-to-delete' },
});
expect(projPerm).not.toBeNull();
expect(projPerm.access_level).toBe('rw');
expect(taskPerm).not.toBeNull();
expect(taskPerm.access_level).toBe('ro');
expect(taskPerm.propagation).toBe('inherited');
expect(notePerm).toBeNull();
});
it('should set default propagation to direct when not specified', async () => {
await sequelize.transaction(async (tx) => {
await applyPerms(tx, {
upserts: [
{
userId: target.id,
resourceType: 'project',
resourceUid: 'proj-default',
accessLevel: 'ro',
grantedByUserId: owner.id,
// no propagation
},
],
deletes: [],
});
});
const perm = await Permission.findOne({
where: { user_id: target.id, resource_uid: 'proj-default' },
});
expect(perm.propagation).toBe('direct');
});
it('should not fail when deleting a non-existent permission', async () => {
await sequelize.transaction(async (tx) => {
await applyPerms(tx, {
upserts: [],
deletes: [
{
userId: target.id,
resourceType: 'project',
resourceUid: 'does-not-exist',
},
],
});
});
// Should not throw
});
});

View file

@ -0,0 +1,332 @@
const {
getAccess,
getSharedUidsForUser,
ownershipOrPermissionWhere,
ACCESS,
} = require('../../../services/permissionsService');
const {
User,
Project,
Task,
Note,
Permission,
sequelize,
} = require('../../../models');
const bcrypt = require('bcrypt');
describe('permissionsService', () => {
let owner, otherUser, adminUser;
beforeEach(async () => {
await sequelize.query('DELETE FROM permissions');
await sequelize.query('DELETE FROM roles');
const hash = await bcrypt.hash('pass', 10);
// First user created gets admin role automatically via afterCreate hook
adminUser = await User.create({
email: 'admin@test.com',
password_digest: hash,
});
owner = await User.create({
email: 'owner@test.com',
password_digest: hash,
});
otherUser = await User.create({
email: 'other@test.com',
password_digest: hash,
});
});
describe('getAccess', () => {
// --- Projects ---
it('should return rw for project owner', async () => {
const project = await Project.create({
name: 'P1',
user_id: owner.id,
});
const access = await getAccess(owner.id, 'project', project.uid);
expect(access).toBe('rw');
});
it('should return none for non-owner without permission', async () => {
const project = await Project.create({
name: 'P1',
user_id: owner.id,
});
const access = await getAccess(
otherUser.id,
'project',
project.uid
);
expect(access).toBe('none');
});
it('should return admin for admin user', async () => {
const project = await Project.create({
name: 'P1',
user_id: owner.id,
});
const access = await getAccess(
adminUser.uid,
'project',
project.uid
);
expect(access).toBe('admin');
});
it('should return none for non-existent project', async () => {
const access = await getAccess(
owner.id,
'project',
'nonexistent-uid'
);
expect(access).toBe('none');
});
it('should return shared permission level for project', async () => {
const project = await Project.create({
name: 'P1',
user_id: owner.id,
});
await Permission.create({
user_id: otherUser.id,
resource_type: 'project',
resource_uid: project.uid,
access_level: 'ro',
propagation: 'direct',
granted_by_user_id: owner.id,
});
const access = await getAccess(
otherUser.id,
'project',
project.uid
);
expect(access).toBe('ro');
});
// --- Tasks ---
it('should return rw for task owner', async () => {
const task = await Task.create({ name: 'T1', user_id: owner.id });
const access = await getAccess(owner.id, 'task', task.uid);
expect(access).toBe('rw');
});
it('should return none for non-owner task without permission', async () => {
const task = await Task.create({ name: 'T1', user_id: owner.id });
const access = await getAccess(otherUser.id, 'task', task.uid);
expect(access).toBe('none');
});
it('should return none for non-existent task', async () => {
const access = await getAccess(owner.id, 'task', 'no-such-task');
expect(access).toBe('none');
});
it('should inherit task access from parent project permission', async () => {
const project = await Project.create({
name: 'P1',
user_id: owner.id,
});
const task = await Task.create({
name: 'T1',
user_id: owner.id,
project_id: project.id,
});
await Permission.create({
user_id: otherUser.id,
resource_type: 'project',
resource_uid: project.uid,
access_level: 'rw',
propagation: 'direct',
granted_by_user_id: owner.id,
});
const access = await getAccess(otherUser.id, 'task', task.uid);
expect(access).toBe('rw');
});
it('should return shared permission for directly shared task', async () => {
const task = await Task.create({ name: 'T1', user_id: owner.id });
await Permission.create({
user_id: otherUser.id,
resource_type: 'task',
resource_uid: task.uid,
access_level: 'ro',
propagation: 'direct',
granted_by_user_id: owner.id,
});
const access = await getAccess(otherUser.id, 'task', task.uid);
expect(access).toBe('ro');
});
// --- Notes ---
it('should return rw for note owner', async () => {
const note = await Note.create({ title: 'N1', user_id: owner.id });
const access = await getAccess(owner.id, 'note', note.uid);
expect(access).toBe('rw');
});
it('should return none for non-owner note without permission', async () => {
const note = await Note.create({ title: 'N1', user_id: owner.id });
const access = await getAccess(otherUser.id, 'note', note.uid);
expect(access).toBe('none');
});
it('should inherit note access from parent project permission', async () => {
const project = await Project.create({
name: 'P1',
user_id: owner.id,
});
const note = await Note.create({
title: 'N1',
user_id: owner.id,
project_id: project.id,
});
await Permission.create({
user_id: otherUser.id,
resource_type: 'project',
resource_uid: project.uid,
access_level: 'ro',
propagation: 'direct',
granted_by_user_id: owner.id,
});
const access = await getAccess(otherUser.id, 'note', note.uid);
expect(access).toBe('ro');
});
});
describe('getSharedUidsForUser', () => {
it('should return empty array when no permissions exist', async () => {
const uids = await getSharedUidsForUser('project', otherUser.id);
expect(uids).toEqual([]);
});
it('should return shared resource uids', async () => {
const project = await Project.create({
name: 'P1',
user_id: owner.id,
});
await Permission.create({
user_id: otherUser.id,
resource_type: 'project',
resource_uid: project.uid,
access_level: 'ro',
propagation: 'direct',
granted_by_user_id: owner.id,
});
const uids = await getSharedUidsForUser('project', otherUser.id);
expect(uids).toContain(project.uid);
});
it('should deduplicate uids', async () => {
const project = await Project.create({
name: 'P1',
user_id: owner.id,
});
// Create two permissions for the same resource (different propagation)
await Permission.create({
user_id: otherUser.id,
resource_type: 'project',
resource_uid: project.uid,
access_level: 'ro',
propagation: 'direct',
granted_by_user_id: owner.id,
});
const uids = await getSharedUidsForUser('project', otherUser.id);
const uniqueUids = [...new Set(uids)];
expect(uids.length).toBe(uniqueUids.length);
});
});
describe('ownershipOrPermissionWhere', () => {
it('should include user_id condition for owned resources', async () => {
const where = await ownershipOrPermissionWhere('project', owner.id);
expect(where).toBeDefined();
// Should contain an Op.or with user_id condition
const orKey = Object.getOwnPropertySymbols(where)[0];
expect(orKey).toBeDefined();
const conditions = where[orKey];
expect(conditions.some((c) => c.user_id === owner.id)).toBe(true);
});
it('should include shared resource uids when permissions exist', async () => {
const project = await Project.create({
name: 'Shared',
user_id: owner.id,
});
await Permission.create({
user_id: otherUser.id,
resource_type: 'project',
resource_uid: project.uid,
access_level: 'rw',
propagation: 'direct',
granted_by_user_id: owner.id,
});
const where = await ownershipOrPermissionWhere(
'project',
otherUser.id
);
const orKey = Object.getOwnPropertySymbols(where)[0];
const conditions = where[orKey];
// Should have a uid IN condition with the shared project uid
const uidCondition = conditions.find((c) => c.uid);
expect(uidCondition).toBeDefined();
});
it('should use cache when provided', async () => {
const cache = new Map();
const where1 = await ownershipOrPermissionWhere(
'project',
owner.id,
cache
);
const where2 = await ownershipOrPermissionWhere(
'project',
owner.id,
cache
);
expect(where1).toBe(where2); // same reference from cache
});
it('should include tasks from shared projects for task resource type', async () => {
const project = await Project.create({
name: 'P1',
user_id: owner.id,
});
await Task.create({
name: 'T1',
user_id: owner.id,
project_id: project.id,
});
await Permission.create({
user_id: otherUser.id,
resource_type: 'project',
resource_uid: project.uid,
access_level: 'rw',
propagation: 'direct',
granted_by_user_id: owner.id,
});
const where = await ownershipOrPermissionWhere(
'task',
otherUser.id
);
const orKey = Object.getOwnPropertySymbols(where)[0];
const conditions = where[orKey];
// Should have a project_id IN condition
const projectCondition = conditions.find((c) => c.project_id);
expect(projectCondition).toBeDefined();
});
});
describe('ACCESS constants', () => {
it('should export expected access levels', () => {
expect(ACCESS.NONE).toBe('none');
expect(ACCESS.RO).toBe('ro');
expect(ACCESS.RW).toBe('rw');
expect(ACCESS.ADMIN).toBe('admin');
});
});
});

View file

@ -0,0 +1,84 @@
const { isAdmin } = require('../../../services/rolesService');
const { User, Role, sequelize } = require('../../../models');
const bcrypt = require('bcrypt');
describe('rolesService', () => {
beforeEach(async () => {
await sequelize.query('DELETE FROM roles');
});
describe('isAdmin', () => {
it('should return false for null uid', async () => {
expect(await isAdmin(null)).toBe(false);
});
it('should return false for undefined uid', async () => {
expect(await isAdmin(undefined)).toBe(false);
});
it('should return false for empty string uid', async () => {
expect(await isAdmin('')).toBe(false);
});
it('should return false when user does not exist', async () => {
expect(await isAdmin('nonexistent-uid')).toBe(false);
});
it('should return true for first user (auto-admin via afterCreate hook)', async () => {
const hash = await bcrypt.hash('pass', 10);
// First user created when no admin exists becomes admin automatically
const user = await User.create({
email: 'first@example.com',
password_digest: hash,
});
expect(await isAdmin(user.uid)).toBe(true);
});
it('should return false for non-first user (non-admin)', async () => {
const hash = await bcrypt.hash('pass', 10);
// First user becomes admin
await User.create({
email: 'first@example.com',
password_digest: hash,
});
// Second user is not admin
const second = await User.create({
email: 'second@example.com',
password_digest: hash,
});
expect(await isAdmin(second.uid)).toBe(false);
});
it('should return false when user role has is_admin=false', async () => {
const hash = await bcrypt.hash('pass', 10);
const user = await User.create({
email: 'demoted@example.com',
password_digest: hash,
});
// The hook created an admin role; update it to non-admin
await Role.update(
{ is_admin: false },
{ where: { user_id: user.id } }
);
expect(await isAdmin(user.uid)).toBe(false);
});
it('should return true when user role has is_admin=true', async () => {
const hash = await bcrypt.hash('pass', 10);
await User.create({
email: 'first@example.com',
password_digest: hash,
});
const user = await User.create({
email: 'promoted@example.com',
password_digest: hash,
});
// The hook created a non-admin role; update it to admin
await Role.update(
{ is_admin: true },
{ where: { user_id: user.id } }
);
expect(await isAdmin(user.uid)).toBe(true);
});
});
});

View file

@ -0,0 +1,154 @@
const BaseRepository = require('../../../shared/database/BaseRepository');
const { User } = require('../../../models');
const bcrypt = require('bcrypt');
describe('BaseRepository', () => {
let repo;
beforeAll(() => {
repo = new BaseRepository(User);
});
describe('create', () => {
it('should create a record', async () => {
const hash = await bcrypt.hash('pass', 10);
const user = await repo.create({
email: 'base-repo@test.com',
password_digest: hash,
});
expect(user.id).toBeDefined();
expect(user.email).toBe('base-repo@test.com');
});
});
describe('findById', () => {
it('should find a record by primary key', async () => {
const hash = await bcrypt.hash('pass', 10);
const created = await repo.create({
email: 'findbyid@test.com',
password_digest: hash,
});
const found = await repo.findById(created.id);
expect(found).not.toBeNull();
expect(found.email).toBe('findbyid@test.com');
});
it('should return null for non-existent id', async () => {
const found = await repo.findById(999999);
expect(found).toBeNull();
});
});
describe('findOne', () => {
it('should find a record by where clause', async () => {
const hash = await bcrypt.hash('pass', 10);
await repo.create({
email: 'findone@test.com',
password_digest: hash,
});
const found = await repo.findOne({ email: 'findone@test.com' });
expect(found).not.toBeNull();
expect(found.email).toBe('findone@test.com');
});
it('should return null when no match', async () => {
const found = await repo.findOne({ email: 'nonexistent@test.com' });
expect(found).toBeNull();
});
});
describe('findAll', () => {
it('should return all matching records', async () => {
const hash = await bcrypt.hash('pass', 10);
await repo.create({
email: 'findall1@test.com',
password_digest: hash,
});
await repo.create({
email: 'findall2@test.com',
password_digest: hash,
});
const all = await repo.findAll();
expect(all.length).toBeGreaterThanOrEqual(2);
});
it('should filter with where clause', async () => {
const hash = await bcrypt.hash('pass', 10);
await repo.create({
email: 'specific@test.com',
password_digest: hash,
});
const results = await repo.findAll({ email: 'specific@test.com' });
expect(results).toHaveLength(1);
expect(results[0].email).toBe('specific@test.com');
});
});
describe('update', () => {
it('should update instance fields', async () => {
const hash = await bcrypt.hash('pass', 10);
const user = await repo.create({
email: 'update@test.com',
password_digest: hash,
});
await repo.update(user, { email: 'updated@test.com' });
await user.reload();
expect(user.email).toBe('updated@test.com');
});
});
describe('destroy', () => {
it('should delete a record', async () => {
const hash = await bcrypt.hash('pass', 10);
const user = await repo.create({
email: 'destroy@test.com',
password_digest: hash,
});
const id = user.id;
await repo.destroy(user);
const found = await repo.findById(id);
expect(found).toBeNull();
});
});
describe('count', () => {
it('should count all records', async () => {
const hash = await bcrypt.hash('pass', 10);
await repo.create({
email: 'count1@test.com',
password_digest: hash,
});
const c = await repo.count();
expect(c).toBeGreaterThanOrEqual(1);
});
it('should count with where clause', async () => {
const hash = await bcrypt.hash('pass', 10);
await repo.create({
email: 'countfilter@test.com',
password_digest: hash,
});
const c = await repo.count({ email: 'countfilter@test.com' });
expect(c).toBe(1);
});
});
describe('exists', () => {
it('should return true when record exists', async () => {
const hash = await bcrypt.hash('pass', 10);
await repo.create({
email: 'exists@test.com',
password_digest: hash,
});
const result = await repo.exists({ email: 'exists@test.com' });
expect(result).toBe(true);
});
it('should return false when record does not exist', async () => {
const result = await repo.exists({
email: 'doesnotexist@test.com',
});
expect(result).toBe(false);
});
});
});

View file

@ -0,0 +1,166 @@
const errorHandler = require('../../../shared/middleware/errorHandler');
const {
AppError,
NotFoundError,
ValidationError,
ConflictError,
UnauthorizedError,
ForbiddenError,
} = require('../../../shared/errors');
describe('errorHandler middleware', () => {
let req, res, next;
beforeEach(() => {
req = {};
res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
next = jest.fn();
});
// --- AppError subclasses ---
it('should handle NotFoundError with 404', () => {
const err = new NotFoundError('Task not found');
errorHandler(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: 'Task not found',
code: 'NOT_FOUND',
});
});
it('should handle ValidationError with 400', () => {
const err = new ValidationError('Title is required');
errorHandler(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: 'Title is required',
code: 'VALIDATION_ERROR',
});
});
it('should handle ConflictError with 409', () => {
const err = new ConflictError();
errorHandler(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(409);
expect(res.json).toHaveBeenCalledWith({
error: 'Resource already exists',
code: 'CONFLICT',
});
});
it('should handle UnauthorizedError with 401', () => {
const err = new UnauthorizedError();
errorHandler(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Unauthorized',
code: 'UNAUTHORIZED',
});
});
it('should handle ForbiddenError with 403', () => {
const err = new ForbiddenError();
errorHandler(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({
error: 'Forbidden',
code: 'FORBIDDEN',
});
});
it('should handle generic AppError with custom status', () => {
const err = new AppError('Rate limited', 429, 'RATE_LIMITED');
errorHandler(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(429);
expect(res.json).toHaveBeenCalledWith({
error: 'Rate limited',
code: 'RATE_LIMITED',
});
});
// --- Sequelize errors ---
it('should handle SequelizeValidationError with 400', () => {
const err = {
name: 'SequelizeValidationError',
errors: [
{ message: 'email must be unique' },
{ message: 'name cannot be null' },
],
};
errorHandler(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
error: 'email must be unique, name cannot be null',
code: 'VALIDATION_ERROR',
});
});
it('should handle SequelizeUniqueConstraintError with 409', () => {
const err = { name: 'SequelizeUniqueConstraintError' };
errorHandler(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(409);
expect(res.json).toHaveBeenCalledWith({
error: 'A resource with this identifier already exists.',
code: 'CONFLICT',
});
});
// --- Unknown errors ---
it('should handle unknown errors with 500 in non-production', () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'test';
const err = new Error('Something unexpected');
errorHandler(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: 'Something unexpected',
code: 'INTERNAL_ERROR',
});
process.env.NODE_ENV = originalEnv;
});
it('should hide error message in production', () => {
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
const err = new Error('Database credentials leaked');
errorHandler(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: 'Internal server error',
code: 'INTERNAL_ERROR',
});
process.env.NODE_ENV = originalEnv;
});
it('should use statusCode from unknown error if present', () => {
const err = new Error('Bad gateway');
err.statusCode = 502;
errorHandler(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(502);
});
it('should default to 500 when no statusCode on unknown error', () => {
const err = new Error('Oops');
errorHandler(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
});
});

View file

@ -0,0 +1,360 @@
const {
safeAddColumns,
safeCreateTable,
safeAddIndex,
safeRemoveColumn,
safeChangeColumn,
} = require('../../../utils/migration-utils');
describe('migration-utils', () => {
let queryInterface;
beforeEach(() => {
queryInterface = {
showAllTables: jest.fn(),
describeTable: jest.fn(),
addColumn: jest.fn(),
removeColumn: jest.fn(),
createTable: jest.fn(),
showIndex: jest.fn(),
addIndex: jest.fn(),
changeColumn: jest.fn(),
sequelize: {
getDialect: jest.fn().mockReturnValue('postgres'),
query: jest.fn(),
},
};
});
describe('safeAddColumns', () => {
it('should skip when table does not exist', async () => {
queryInterface.showAllTables.mockResolvedValue(['other_table']);
await safeAddColumns(queryInterface, 'missing_table', [
{ name: 'col1', definition: { type: 'TEXT' } },
]);
expect(queryInterface.addColumn).not.toHaveBeenCalled();
});
it('should add column when it does not exist', async () => {
queryInterface.showAllTables.mockResolvedValue(['tasks']);
queryInterface.describeTable.mockResolvedValue({
id: {},
title: {},
});
const definition = { type: 'TEXT' };
await safeAddColumns(queryInterface, 'tasks', [
{ name: 'description', definition },
]);
expect(queryInterface.addColumn).toHaveBeenCalledWith(
'tasks',
'description',
definition
);
});
it('should skip column when it already exists', async () => {
queryInterface.showAllTables.mockResolvedValue(['tasks']);
queryInterface.describeTable.mockResolvedValue({
id: {},
title: {},
});
await safeAddColumns(queryInterface, 'tasks', [
{ name: 'title', definition: { type: 'TEXT' } },
]);
expect(queryInterface.addColumn).not.toHaveBeenCalled();
});
it('should add multiple columns, skipping existing ones', async () => {
queryInterface.showAllTables.mockResolvedValue(['tasks']);
queryInterface.describeTable.mockResolvedValue({
id: {},
title: {},
});
await safeAddColumns(queryInterface, 'tasks', [
{ name: 'title', definition: { type: 'TEXT' } }, // exists
{ name: 'priority', definition: { type: 'INTEGER' } }, // new
{ name: 'due_date', definition: { type: 'DATE' } }, // new
]);
expect(queryInterface.addColumn).toHaveBeenCalledTimes(2);
expect(queryInterface.addColumn).toHaveBeenCalledWith(
'tasks',
'priority',
{ type: 'INTEGER' }
);
expect(queryInterface.addColumn).toHaveBeenCalledWith(
'tasks',
'due_date',
{ type: 'DATE' }
);
});
it('should re-throw errors from addColumn', async () => {
queryInterface.showAllTables.mockResolvedValue(['tasks']);
queryInterface.describeTable.mockResolvedValue({ id: {} });
queryInterface.addColumn.mockRejectedValue(new Error('DB error'));
await expect(
safeAddColumns(queryInterface, 'tasks', [
{ name: 'col', definition: { type: 'TEXT' } },
])
).rejects.toThrow('DB error');
});
});
describe('safeCreateTable', () => {
it('should create table when it does not exist', async () => {
queryInterface.showAllTables.mockResolvedValue([]);
const definition = { id: { type: 'INTEGER', primaryKey: true } };
await safeCreateTable(queryInterface, 'new_table', definition);
expect(queryInterface.createTable).toHaveBeenCalledWith(
'new_table',
definition
);
});
it('should skip when table already exists', async () => {
queryInterface.showAllTables.mockResolvedValue(['existing_table']);
await safeCreateTable(queryInterface, 'existing_table', {});
expect(queryInterface.createTable).not.toHaveBeenCalled();
});
it('should re-throw errors from createTable', async () => {
queryInterface.showAllTables.mockResolvedValue([]);
queryInterface.createTable.mockRejectedValue(
new Error('Create failed')
);
await expect(
safeCreateTable(queryInterface, 'fail_table', {})
).rejects.toThrow('Create failed');
});
});
describe('safeAddIndex', () => {
it('should skip when table does not exist', async () => {
queryInterface.showAllTables.mockResolvedValue([]);
await safeAddIndex(queryInterface, 'missing_table', ['col1']);
expect(queryInterface.addIndex).not.toHaveBeenCalled();
});
it('should add index when it does not exist', async () => {
queryInterface.showAllTables.mockResolvedValue(['tasks']);
queryInterface.showIndex.mockResolvedValue([]);
await safeAddIndex(queryInterface, 'tasks', ['user_id'], {
name: 'idx_user',
});
expect(queryInterface.addIndex).toHaveBeenCalledWith(
'tasks',
['user_id'],
{ name: 'idx_user' }
);
});
it('should skip when a matching index already exists', async () => {
queryInterface.showAllTables.mockResolvedValue(['tasks']);
queryInterface.showIndex.mockResolvedValue([
{ fields: [{ attribute: 'user_id' }] },
]);
await safeAddIndex(queryInterface, 'tasks', ['user_id']);
expect(queryInterface.addIndex).not.toHaveBeenCalled();
});
it('should not throw on addIndex failure (swallows error)', async () => {
queryInterface.showAllTables.mockResolvedValue(['tasks']);
queryInterface.showIndex.mockResolvedValue([]);
queryInterface.addIndex.mockRejectedValue(new Error('Index error'));
// safeAddIndex swallows errors (no throw in the catch)
await expect(
safeAddIndex(queryInterface, 'tasks', ['col'])
).resolves.toBeUndefined();
});
});
describe('safeRemoveColumn', () => {
it('should skip when column does not exist', async () => {
queryInterface.describeTable.mockResolvedValue({
id: {},
title: {},
});
await safeRemoveColumn(queryInterface, 'tasks', 'nonexistent');
expect(queryInterface.removeColumn).not.toHaveBeenCalled();
});
it('should remove column on non-SQLite dialect', async () => {
queryInterface.describeTable.mockResolvedValue({
id: {},
title: {},
old_col: {},
});
queryInterface.sequelize.getDialect.mockReturnValue('postgres');
await safeRemoveColumn(queryInterface, 'tasks', 'old_col');
expect(queryInterface.removeColumn).toHaveBeenCalledWith(
'tasks',
'old_col'
);
});
it('should use table recreation strategy on SQLite', async () => {
queryInterface.describeTable.mockResolvedValue({
id: {
type: 'INTEGER',
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
title: { type: 'TEXT', allowNull: true },
remove_me: { type: 'TEXT', allowNull: true },
});
queryInterface.sequelize.getDialect.mockReturnValue('sqlite');
await safeRemoveColumn(queryInterface, 'tasks', 'remove_me');
// Should have called multiple queries for SQLite recreation
const calls = queryInterface.sequelize.query.mock.calls.map(
(c) => c[0]
);
expect(
calls.some((q) => q.includes('PRAGMA foreign_keys = OFF'))
).toBe(true);
expect(
calls.some((q) => q.includes('CREATE TABLE tasks_new'))
).toBe(true);
expect(calls.some((q) => q.includes('INSERT INTO tasks_new'))).toBe(
true
);
expect(calls.some((q) => q.includes('DROP TABLE tasks'))).toBe(
true
);
expect(
calls.some((q) =>
q.includes('ALTER TABLE tasks_new RENAME TO tasks')
)
).toBe(true);
expect(
calls.some((q) => q.includes('PRAGMA foreign_keys = ON'))
).toBe(true);
// remove_me should not appear in CREATE TABLE
const createCall = calls.find((q) =>
q.includes('CREATE TABLE tasks_new')
);
expect(createCall).not.toContain('remove_me');
});
it('should re-throw errors', async () => {
queryInterface.describeTable.mockRejectedValue(
new Error('Describe failed')
);
await expect(
safeRemoveColumn(queryInterface, 'tasks', 'col')
).rejects.toThrow('Describe failed');
});
});
describe('safeChangeColumn', () => {
it('should skip when table does not exist', async () => {
queryInterface.showAllTables.mockResolvedValue([]);
await safeChangeColumn(queryInterface, 'missing', 'col', {
type: 'TEXT',
});
expect(queryInterface.changeColumn).not.toHaveBeenCalled();
});
it('should skip when column does not exist', async () => {
queryInterface.showAllTables.mockResolvedValue(['tasks']);
queryInterface.describeTable.mockResolvedValue({ id: {} });
await safeChangeColumn(queryInterface, 'tasks', 'nonexistent', {
type: 'TEXT',
});
expect(queryInterface.changeColumn).not.toHaveBeenCalled();
});
it('should change column on non-SQLite dialect', async () => {
queryInterface.showAllTables.mockResolvedValue(['tasks']);
queryInterface.describeTable.mockResolvedValue({
id: {},
title: { type: 'TEXT', allowNull: true },
});
queryInterface.sequelize.getDialect.mockReturnValue('postgres');
const newDef = { type: 'VARCHAR(255)', allowNull: false };
await safeChangeColumn(queryInterface, 'tasks', 'title', newDef);
expect(queryInterface.changeColumn).toHaveBeenCalledWith(
'tasks',
'title',
newDef
);
});
it('should use table recreation on SQLite', async () => {
queryInterface.showAllTables.mockResolvedValue(['tasks']);
queryInterface.describeTable.mockResolvedValue({
id: {
type: 'INTEGER',
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
title: { type: 'TEXT', allowNull: true },
});
queryInterface.showIndex.mockResolvedValue([]);
queryInterface.sequelize.getDialect.mockReturnValue('sqlite');
await safeChangeColumn(queryInterface, 'tasks', 'title', {
type: { toSql: () => 'VARCHAR(255)' },
allowNull: false,
});
const calls = queryInterface.sequelize.query.mock.calls.map(
(c) => c[0]
);
expect(
calls.some((q) => q.includes('CREATE TABLE tasks_new'))
).toBe(true);
const createCall = calls.find((q) =>
q.includes('CREATE TABLE tasks_new')
);
expect(createCall).toContain('VARCHAR(255)');
expect(createCall).toContain('NOT NULL');
});
it('should re-throw errors', async () => {
queryInterface.showAllTables.mockResolvedValue(['tasks']);
queryInterface.describeTable.mockResolvedValue({ id: {}, col: {} });
queryInterface.sequelize.getDialect.mockReturnValue('postgres');
queryInterface.changeColumn.mockRejectedValue(
new Error('Change failed')
);
await expect(
safeChangeColumn(queryInterface, 'tasks', 'col', {
type: 'TEXT',
})
).rejects.toThrow('Change failed');
});
});
});

View file

@ -0,0 +1,29 @@
const { uid } = require('../../../utils/uid');
describe('uid utility', () => {
it('should return a string', () => {
const id = uid();
expect(typeof id).toBe('string');
});
it('should return a 15-character string', () => {
const id = uid();
expect(id).toHaveLength(15);
});
it('should generate different ids on successive calls', () => {
const ids = new Set();
for (let i = 0; i < 100; i++) {
ids.add(uid());
}
expect(ids.size).toBe(100);
});
it('should only contain characters from the allowed alphabet', () => {
// The allowed alphabet is '0123456789abcdefghijkmnpqrstuvwxyz'
// (no 'o' or 'l' to avoid ambiguity)
// Note: in test env nanoid is mocked, so this validates the mock contract
const id = uid();
expect(id).toMatch(/^[0-9a-z]+$/);
});
});

350
docs/architecture.md Normal file
View file

@ -0,0 +1,350 @@
# Architecture Overview
[← Back to Index](../CLAUDE.md)
---
## Technology Stack
### Frontend Stack
- **Framework:** React 18.3.1
- **Language:** TypeScript 5.6.2
- **Build Tool:** Webpack 5 with hot module replacement
- **Styling:** Tailwind CSS 3.4.13 + Heroicons
- **State Management:** Zustand 5.0.3 (global state), SWR 2.2.5 (server state)
- **Routing:** React Router DOM 6.26.2
- **Internationalization:** i18next + react-i18next (24 languages)
- **Charts/Analytics:** Recharts 2.15.4
- **Drag & Drop:** @dnd-kit (sortable tasks)
- **Development:** webpack-dev-server with proxy configuration
### Backend Stack
- **Framework:** Express.js 4.21.2
- **ORM:** Sequelize 6.37.7
- **Database:** SQLite 5.1.7 (WAL mode, memory-mapped I/O for performance)
- **Authentication:** bcrypt + express-session + connect-session-sequelize
- **Security:** Helmet, CORS, express-rate-limit
- **API Documentation:** Swagger (swagger-jsdoc + swagger-ui-express)
- **File Upload:** Multer
- **Email:** Nodemailer 7.0.10
- **Scheduling:** node-cron 4.1.0 (for recurring tasks)
- **Date/Time:** date-fns 4.1.0 + date-fns-tz
### Testing Stack
- **Backend:** Jest + Supertest + supertest-session
- **Frontend:** Jest + React Testing Library
- **E2E:** Playwright
- **Linting:** ESLint 8 + Prettier 3.6.2
---
## Request Flow
### Development Mode
```
┌─────────────────────────────────────────────────────────────┐
│ Development Flow │
├─────────────────────────────────────────────────────────────┤
│ │
│ Browser (http://localhost:8080) │
│ ↓ │
│ ┌─────────────────────┐ │
│ │ Webpack Dev Server │ │
│ │ Port: 8080 │ │
│ │ - Hot reload │ │
│ │ - Proxy /api/* │ │
│ │ - Proxy /locales/*│ │
│ └──────────┬──────────┘ │
│ │ │
│ │ Proxies requests to backend │
│ ↓ │
│ ┌─────────────────────┐ │
│ │ Express Server │ │
│ │ Port: 3002 │ │
│ │ - API endpoints │ │
│ │ - Session auth │ │
│ │ - Rate limiting │ │
│ └──────────┬──────────┘ │
│ │ │
│ ↓ │
│ ┌─────────────────────┐ │
│ │ Sequelize ORM │ │
│ │ - Model layer │ │
│ │ - Relationships │ │
│ │ - Migrations │ │
│ └──────────┬──────────┘ │
│ │ │
│ ↓ │
│ ┌─────────────────────┐ │
│ │ SQLite Database │ │
│ │ (database.sqlite) │ │
│ │ - WAL mode │ │
│ │ - Optimized I/O │ │
│ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Production Mode
```
┌─────────────────────────────────────────────────────────────┐
│ Production Flow │
├─────────────────────────────────────────────────────────────┤
│ │
│ Browser → Express Server (Port 3002) │
│ ↓ │
│ ├─ Serves static files from /dist │
│ │ (compiled React app) │
│ │ │
│ └─ /api routes → Sequelize → SQLite │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## Data Model Hierarchy
```
┌────────────────────────────────────────────────────────────────┐
│ User │
│ (Authentication, Settings, Timezone) │
└───────────────────┬────────────────────────────────────────────┘
│ 1:N (One user has many areas)
┌───────────────────────┐
│ Area │ (Optional organizational layer)
│ (e.g., Work, │
│ Personal, Health) │
└──────────┬────────────┘
│ 1:N (One area has many projects)
┌───────────────────────┐ ┌──────────┐
│ Project │────────────────│ Tag │
│ (Website Redesign, │ N:M │ (urgent,│
│ Marketing Campaign) │ │ design, │
└──────────┬────────────┘ │ bug) │
│ └─────┬────┘
│ 1:N (One project has │
│ many tasks) │ N:M
↓ │
┌───────────────────────┐ │
│ Task │────────────────────┘
│ (Design Homepage, │ N:M (Tasks can
│ Write Blog Post) │ have tags)
└──────────┬────────────┘
│ 1:N (Task can have subtasks)
┌───────────────────────┐
│ Subtask │ (Self-referential)
│ (Create mockup, │
│ Get feedback) │
└───────────────────────┘
Additional Relationships:
Project 1:N Note (One project has many notes)
Note N:M Tag (Notes can have tags)
Task → RecurringCompletion (Tracks completion history)
Task → TaskEvent (Audit log of all changes)
Task → TaskAttachment (File attachments)
Special Task Relationships:
- parent_task_id: Links subtasks to parent task
- recurring_parent_id: Links recurring instances to original pattern
```
---
## Authentication & Authorization
### Dual Authentication Methods
**1. Session-Based (Primary - Web Interface)**
- Express session with Sequelize store
- HttpOnly cookies (30-day expiration)
- Session data stored in database table
- SameSite: 'lax' for CSRF protection
- Implementation: `/backend/middleware/auth.js`
**Flow:**
```
1. User logs in with email/password
2. bcrypt verifies password hash
3. Session created and stored in database
4. Session cookie sent to browser
5. Subsequent requests include cookie
6. Middleware validates session and attaches user
```
**2. Bearer Token (API Access)**
- Personal API tokens for automation/integrations
- Format: `Authorization: Bearer YOUR_TOKEN`
- Token prefix: `tt_` + 64 hex characters
- Stored as bcrypt hash in database
- Optional expiration and abilities (JSON)
- Generated via web UI: Profile > API Tokens
**Flow:**
```
1. User generates token in web interface
2. Token hash stored with prefix (first 12 chars)
3. API requests include: Authorization: Bearer tt_xxx...
4. Middleware validates token hash
5. Updates last_used_at timestamp
6. Attaches user to request
```
### Permission System
**Permission Levels:**
- `NONE` (0) - No access
- `RO` (1) - Read-only access
- `RW` (2) - Read-write access
- `ADMIN` (3) - Full administrative access
**Access Rules:**
1. **Ownership** → Automatic RW access
- User has RW for resources they created
2. **Project Sharing** → Granted via Permission model
- Permission record grants RO/RW/ADMIN to specific user
- resource_type: 'project', 'task', 'note'
- resource_uid: unique identifier
3. **Inheritance**
- Tasks inherit access from parent Project
- Notes inherit access from parent Project
- Subtasks inherit access from parent Task
**Authorization Middleware:**
```javascript
// Location: /backend/middleware/authorize.js
hasAccess(requiredAccess, resourceType, getResourceUid, options)
// Usage in routes:
router.get('/task/:id',
hasAccess('ro', 'task', (req) => req.params.id),
async (req, res) => { ... }
);
```
---
## Core Modules Overview
### Backend Modules (19 total)
Located in `/backend/modules/`, each follows consistent architecture:
| Module | Purpose | Complexity |
|--------|---------|------------|
| **tasks** | Task management, subtasks, recurring | High - most complex module |
| **projects** | Project CRUD and organization | Medium |
| **areas** | Area categorization | Low |
| **notes** | Note-taking system | Medium |
| **tags** | Tagging system | Low |
| **users** | User management | Medium |
| **auth** | Authentication (login/register) | Medium |
| **shares** | Project sharing & permissions | High |
| **telegram** | Telegram bot integration | Medium |
| **inbox** | Quick capture inbox | Low |
| **habits** | Habit tracking | Medium |
| **notifications** | In-app notifications | Medium |
| **search** | Universal search | Medium |
| **views** | Saved custom views | Low |
| **admin** | Admin operations | Low |
| **backup** | Backup/restore functionality | High |
| **feature-flags** | Feature flag management | Low |
| **quotes** | Daily quotes | Low |
| **url** | URL handling | Low |
---
## Database Schema Highlights
**20+ Sequelize Models** in `/backend/models/`
**Core Tables:**
- **Users** - Authentication, settings, preferences
- **Areas** - Organizational categories
- **Projects** - Project grouping
- **Tasks** - Main task entity (11 indexes for performance)
- **Notes** - Project notes
- **Tags** - Flexible tagging
- **Permissions** - Sharing and access control
- **ApiTokens** - API authentication
- **RecurringCompletions** - Recurring task history
- **TaskEvents** - Audit log
- **TaskAttachments** - File uploads
- **InboxItems** - Quick capture
- **Notifications** - User notifications
- **Roles** - User role system
- **Views** - Saved custom views
- **Backups** - Backup records
- **Settings** - Application config
- **Actions** - Audit trail
**Junction Tables (Many-to-Many):**
- tasks_tags
- notes_tags
- projects_tags
**Special Fields in Tasks:**
- `recurrence_type`: daily, weekly, monthly, monthly_weekday, monthly_last_day
- `recurrence_interval`: Custom intervals (every 2 weeks, etc.)
- `parent_task_id`: Links subtasks to parent (self-referential)
- `recurring_parent_id`: Links recurring instances to original pattern
**Performance Optimizations:**
- WAL (Write-Ahead Logging) mode
- PRAGMA synchronous=NORMAL
- 64MB cache size
- 256MB memory-mapped I/O
- Memory-based temp storage
- 11 indexes on Tasks table for slow I/O systems
---
## Frontend Architecture
**Component Organization:**
- Feature-based structure in `/frontend/components/`
- Shared components in `/frontend/components/Shared/`
- Each feature has dedicated directory (Task/, Project/, Area/, etc.)
**State Management:**
- **Global State:** Zustand store (`/frontend/store/useStore.ts`)
- Task cache
- Project cache
- UI state (modals, filters, selections)
- **Server State:** SWR for data fetching
- Automatic revalidation
- Optimistic updates
- Cache management
- **Local State:** React useState for component-specific data
**Key Frontend Patterns:**
- Functional components with hooks (no class components)
- TypeScript interfaces in `/frontend/entities/`
- API services in `/frontend/utils/[resource]Service.ts`
- Custom hooks in `/frontend/hooks/`
- Context providers in `/frontend/contexts/`
---
[← Back to Index](../CLAUDE.md)

649
docs/backend-patterns.md Normal file
View file

@ -0,0 +1,649 @@
# Backend Module Architecture
[← Back to Index](../CLAUDE.md)
---
## Standard Module Structure
All feature modules in `/backend/modules/` follow a consistent architecture pattern. This ensures code is organized, maintainable, and easy to navigate.
### Typical Module Directory
```
/backend/modules/[module-name]/
├── routes.js # Express router with endpoint definitions
├── repository.js # Data access layer (Sequelize queries)
├── operations/ # Business logic operations (optional)
│ ├── create.js
│ ├── update.js
│ ├── delete.js
│ └── ...
├── queries/ # Complex query builders (optional)
├── core/ # Core utilities (optional)
│ ├── serializers.js # Format data for API responses
│ ├── parsers.js # Parse request data
│ └── builders.js # Build database objects
├── middleware/ # Route-specific middleware (optional)
│ └── access.js # Access control
└── utils/ # Module-specific utilities
└── validation.js # Input validation
```
**Note:** Not all modules have all directories. Simpler modules may only have `routes.js` and basic logic.
---
## Example: Tasks Module (Complex)
The tasks module (`/backend/modules/tasks/`) is the most comprehensive example, showing the full potential of the module pattern:
```
/backend/modules/tasks/
├── routes.js # Main routes: GET /tasks, POST /task, etc.
├── repository.js # Data access: findTaskById, findTasksForUser
├── recurringTaskService.js # Recurring task pattern logic (8.5KB)
├── taskEventService.js # Task activity logging
├── taskScheduler.js # Cron-based task scheduling with node-cron
├── operations/ # Business logic operations
│ ├── list.js # List operations and filtering
│ ├── completion.js # Task completion/status changes
│ ├── recurring.js # Recurrence pattern handling
│ ├── subtasks.js # Subtask CRUD operations
│ ├── tags.js # Tag assignment to tasks
│ ├── grouping.js # Task grouping logic
│ ├── sorting.js # Sort order logic
│ └── parent-child.js # Parent-child relationship handling
├── queries/
│ ├── query-builders.js # filterTasksByParams, buildWhereClause
│ ├── metrics-queries.js # Task metrics and analytics queries
│ └── metrics-computation.js # Metric calculations and aggregations
├── core/
│ ├── serializers.js # serializeTask, serializeTasks
│ ├── builders.js # buildTaskAttributes for create/update
│ ├── parsers.js # parseTaskInput from requests
│ └── comparators.js # Detect task changes for audit log
├── middleware/
│ └── access.js # Task-specific access control
└── utils/
├── constants.js # Task-specific constants (status codes, etc.)
├── validation.js # Task input validation rules
└── logging.js # Change tracking helpers
```
---
## Example: Projects Module (Simpler)
The projects module (`/backend/modules/projects/`) follows the same pattern but with less complexity:
```
/backend/modules/projects/
├── routes.js # Project CRUD endpoints
├── repository.js # Project data access (findAll, findById, create, etc.)
└── utils/
└── validation.js # Project validation (name required, etc.)
```
This shows that modules scale based on complexity - simple features don't require the full directory structure.
---
## Module Pattern Details
### routes.js - Express Router
**Purpose:** Define HTTP endpoints for the module
**Pattern:**
```javascript
// /backend/modules/[module]/routes.js
const express = require('express');
const router = express.Router();
const repository = require('./repository');
const { hasAccess } = require('../../middleware/authorize');
// List all resources (collection)
router.get('/[resources]', async (req, res, next) => {
try {
const items = await repository.findAll(req.currentUser.id, req.query);
res.json(items);
} catch (error) {
next(error); // Pass to global error handler
}
});
// Create new resource (singular)
router.post('/[resource]', async (req, res, next) => {
try {
const item = await repository.create(req.body, req.currentUser.id);
res.status(201).json(item);
} catch (error) {
next(error);
}
});
// Get single resource (with authorization)
router.get('/[resource]/:id',
hasAccess('ro', '[resource]', (req) => req.params.id),
async (req, res, next) => {
try {
const item = await repository.findById(req.params.id, req.currentUser.id);
if (!item) {
return res.status(404).json({ error: 'Not found' });
}
res.json(item);
} catch (error) {
next(error);
}
}
);
// Update resource (with authorization)
router.put('/[resource]/:id',
hasAccess('rw', '[resource]', (req) => req.params.id),
async (req, res, next) => {
try {
const updated = await repository.update(req.params.id, req.body, req.currentUser.id);
res.json(updated);
} catch (error) {
next(error);
}
}
);
// Delete resource
router.delete('/[resource]/:id',
hasAccess('rw', '[resource]', (req) => req.params.id),
async (req, res, next) => {
try {
await repository.destroy(req.params.id, req.currentUser.id);
res.status(204).send();
} catch (error) {
next(error);
}
}
);
module.exports = router;
```
**Key Conventions:**
- Plural for collections: `GET /tasks`
- Singular for single resource: `GET /task/:id`, `POST /task`
- Always use try/catch
- Pass errors to `next(error)` for global error handler
- Use `hasAccess()` middleware for authorization
- Return proper HTTP status codes (200, 201, 204, 404, etc.)
---
### repository.js - Data Access Layer
**Purpose:** Abstract database queries from routes
**Pattern:**
```javascript
// /backend/modules/[module]/repository.js
const { Model } = require('../../models');
async function findAll(userId, filters = {}) {
return await Model.findAll({
where: {
user_id: userId,
...buildWhereClause(filters)
},
include: [...],
order: [['created_at', 'DESC']]
});
}
async function findById(id, userId) {
return await Model.findOne({
where: { id, user_id: userId },
include: [...]
});
}
async function create(data, userId) {
return await Model.create({
...data,
user_id: userId
});
}
async function update(id, data, userId) {
const instance = await findById(id, userId);
if (!instance) {
throw new NotFoundError('Resource not found');
}
return await instance.update(data);
}
async function destroy(id, userId) {
const instance = await findById(id, userId);
if (!instance) {
throw new NotFoundError('Resource not found');
}
await instance.destroy();
}
module.exports = {
findAll,
findById,
create,
update,
destroy
};
```
**Why Repository Pattern:**
- Separates data access from business logic
- Makes testing easier (can mock repository)
- Centralizes query logic
- Prevents Model usage directly in routes
---
### core/serializers.js - Response Formatting
**Purpose:** Transform database objects into API-friendly format
**Pattern:**
```javascript
// /backend/modules/[module]/core/serializers.js
function serializeItem(item) {
if (!item) return null;
return {
id: item.id,
uid: item.uid,
name: item.name,
description: item.description,
created_at: item.created_at,
updated_at: item.updated_at,
// Include associations if loaded
tags: item.Tags ? item.Tags.map(serializeTag) : undefined,
user: item.User ? serializeUser(item.User) : undefined
};
}
function serializeItems(items) {
return items.map(serializeItem);
}
module.exports = {
serializeItem,
serializeItems
};
```
**Usage in routes:**
```javascript
const { serializeItem, serializeItems } = require('./core/serializers');
router.get('/items', async (req, res) => {
const items = await repository.findAll(req.currentUser.id);
res.json(serializeItems(items)); // Transform before sending
});
```
---
### core/builders.js - Object Construction
**Purpose:** Build objects for database creation/update from request data
**Pattern:**
```javascript
// /backend/modules/[module]/core/builders.js
function buildItemAttributes(data, userId) {
const attributes = {
user_id: userId,
name: data.name?.trim(),
description: data.description?.trim() || null
};
// Optional fields
if (data.due_date) {
attributes.due_date = parseDate(data.due_date);
}
if (data.priority !== undefined) {
attributes.priority = parseInt(data.priority, 10);
}
return attributes;
}
module.exports = { buildItemAttributes };
```
**Usage:**
```javascript
const { buildItemAttributes } = require('./core/builders');
router.post('/item', async (req, res) => {
const attributes = buildItemAttributes(req.body, req.currentUser.id);
const item = await repository.create(attributes);
res.status(201).json(serializeItem(item));
});
```
---
### operations/ - Business Logic
**Purpose:** Complex operations that don't fit in simple CRUD
**Example: operations/completion.js (from tasks module)**
```javascript
// /backend/modules/tasks/operations/completion.js
async function completeTask(taskId, userId, completionData) {
// Get task with associations
const task = await repository.findById(taskId, userId);
if (task.recurrence_type) {
// Handle recurring task completion
await recurringTaskService.handleCompletion(task, completionData);
} else {
// Simple completion
task.status = 2; // completed
task.completed_at = new Date();
await task.save();
}
// Log event
await taskEventService.logEvent(taskId, 'completed', userId);
// Update related subtasks
if (completionData.completeSubtasks) {
await completeSubtasks(taskId);
}
return task;
}
module.exports = { completeTask };
```
---
## Module Communication
### Accessing Other Modules
Modules can import other module repositories and services:
```javascript
// In /backend/modules/projects/routes.js
// Import task repository from tasks module
const taskRepository = require('../tasks/repository');
// Import shared service
const permissionsService = require('../../services/permissionsService');
router.get('/project/:id/tasks', async (req, res) => {
// Use task repository
const tasks = await taskRepository.findTasksForProject(req.params.id);
res.json(tasks);
});
```
### Avoid Circular Dependencies
**Bad:**
```javascript
// Module A imports Module B
const moduleB = require('../moduleB');
// Module B imports Module A
const moduleA = require('../moduleA'); // CIRCULAR!
```
**Good:**
```javascript
// Extract shared logic to /backend/services/
// Both modules import from services
const sharedService = require('../../services/sharedService');
```
---
## How to Add a New Module
**Example: Creating a "labels" module**
### Step 1: Create Directory Structure
```bash
mkdir -p /Users/chris/c0deLab/ProjectLand/tududi/backend/modules/labels
mkdir -p /Users/chris/c0deLab/ProjectLand/tududi/backend/modules/labels/utils
```
### Step 2: Create routes.js
```javascript
// /backend/modules/labels/routes.js
const express = require('express');
const router = express.Router();
const repository = require('./repository');
router.get('/labels', async (req, res, next) => {
try {
const labels = await repository.findAll(req.currentUser.id);
res.json(labels);
} catch (error) {
next(error);
}
});
router.post('/label', async (req, res, next) => {
try {
const label = await repository.create(req.body, req.currentUser.id);
res.status(201).json(label);
} catch (error) {
next(error);
}
});
router.put('/label/:id', async (req, res, next) => {
try {
const label = await repository.update(req.params.id, req.body, req.currentUser.id);
res.json(label);
} catch (error) {
next(error);
}
});
router.delete('/label/:id', async (req, res, next) => {
try {
await repository.destroy(req.params.id, req.currentUser.id);
res.status(204).send();
} catch (error) {
next(error);
}
});
module.exports = router;
```
### Step 3: Create repository.js
```javascript
// /backend/modules/labels/repository.js
const { Label } = require('../../models');
async function findAll(userId) {
return await Label.findAll({
where: { user_id: userId },
order: [['name', 'ASC']]
});
}
async function findById(id, userId) {
return await Label.findOne({
where: { id, user_id: userId }
});
}
async function create(data, userId) {
return await Label.create({
name: data.name,
color: data.color || '#gray',
user_id: userId
});
}
async function update(id, data, userId) {
const label = await findById(id, userId);
if (!label) {
throw new Error('Label not found');
}
return await label.update({
name: data.name,
color: data.color
});
}
async function destroy(id, userId) {
const label = await findById(id, userId);
if (!label) {
throw new Error('Label not found');
}
await label.destroy();
}
module.exports = {
findAll,
findById,
create,
update,
destroy
};
```
### Step 4: Create Model
See [Database documentation](database.md) for creating models and migrations.
### Step 5: Register Routes in app.js
Edit `/backend/app.js`:
```javascript
// Add with other module registrations (around line 50-70)
// Labels module
app.use('/api/v1', require('./modules/labels/routes'));
app.use('/api', require('./modules/labels/routes')); // Backward compatibility
```
### Step 6: Add Swagger Documentation
Edit `/backend/config/swagger.js` to add Label schema:
```javascript
components: {
schemas: {
// ... existing schemas ...
Label: {
type: 'object',
properties: {
id: { type: 'integer' },
uid: { type: 'string' },
name: { type: 'string' },
color: { type: 'string' },
created_at: { type: 'string', format: 'date-time' },
updated_at: { type: 'string', format: 'date-time' }
}
}
}
}
```
### Step 7: Write Tests
Create `/backend/tests/integration/labels/labels.test.js`:
```javascript
const request = require('supertest');
const app = require('../../../app');
const { Label, User } = require('../../../models');
describe('Labels API', () => {
let user, authCookie;
beforeEach(async () => {
user = await User.create({
email: 'test@example.com',
password: 'password123'
});
const res = await request(app)
.post('/api/login')
.send({ email: 'test@example.com', password: 'password123' });
authCookie = res.headers['set-cookie'];
});
afterEach(async () => {
await Label.destroy({ where: {} });
await User.destroy({ where: {} });
});
it('should create label', async () => {
const response = await request(app)
.post('/api/v1/label')
.set('Cookie', authCookie)
.send({ name: 'Important', color: '#red' });
expect(response.status).toBe(201);
expect(response.body.name).toBe('Important');
});
it('should list user labels', async () => {
await Label.create({ name: 'Label 1', user_id: user.id });
await Label.create({ name: 'Label 2', user_id: user.id });
const response = await request(app)
.get('/api/v1/labels')
.set('Cookie', authCookie);
expect(response.status).toBe(200);
expect(response.body.length).toBe(2);
});
});
```
### Step 8: Create Frontend Integration (Optional)
Create `/frontend/utils/labelsService.ts` and components as needed.
---
## Module Checklist
When adding a new module, ensure:
- [ ] Directory created in `/backend/modules/[name]/`
- [ ] `routes.js` with all necessary endpoints
- [ ] `repository.js` with data access methods
- [ ] Model created (if new database table needed)
- [ ] Migration created and run
- [ ] Routes registered in `/backend/app.js`
- [ ] Swagger schema added
- [ ] Integration tests written
- [ ] Documentation updated (if public feature)
---
[← Back to Index](../CLAUDE.md)

509
docs/code-conventions.md Normal file
View file

@ -0,0 +1,509 @@
# Code Conventions & Patterns
[← Back to Index](../CLAUDE.md)
---
## Language Usage
- **Frontend:** TypeScript (`.tsx`, `.ts` files)
- **Backend:** JavaScript with optional JSDoc types (`.js` files)
- **tsconfig.json:** Frontend only, `"strict": false`
---
## Backend Patterns
### 1. Async/Await (No Callbacks)
```javascript
// ✅ Good - Use async/await
async function getTasks(userId) {
const tasks = await Task.findAll({ where: { user_id: userId } });
return tasks;
}
// ❌ Bad - No callbacks
function getTasks(userId, callback) {
Task.findAll({ where: { user_id: userId } })
.then(tasks => callback(null, tasks))
.catch(err => callback(err));
}
```
### 2. Repository Pattern
```javascript
// ✅ Good - Use repository
// In routes.js
const repository = require('./repository');
const task = await repository.findTaskById(req.params.id, req.currentUser.id);
// ❌ Bad - Don't access Model directly in routes
const { Task } = require('../../models');
const task = await Task.findByPk(req.params.id);
```
**Why:** Repository pattern separates data access from business logic, making testing easier and centralizing query logic.
### 3. Error Handling
```javascript
// In routes.js
router.get('/task/:id', async (req, res, next) => {
try {
const task = await repository.findTaskById(req.params.id, req.currentUser.id);
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
res.json(task);
} catch (error) {
next(error); // Pass to global error handler
}
});
```
**Always:**
- Use try/catch in route handlers
- Pass errors to `next(error)` for global error handler
- Return proper HTTP status codes
- Use custom error classes from `/backend/shared/errors/`
### 4. Service Pattern
```javascript
// Create singleton service or class
class TaskService {
async create(data, userId) {
// Validation
if (!data.name) {
throw new ValidationError('Task name is required');
}
// Business logic
const task = await Task.create({ ...data, user_id: userId });
// Additional operations
await taskEventService.logEvent(task.id, 'created');
return task;
}
}
module.exports = new TaskService(); // Export singleton
```
---
## Frontend Patterns
### 1. Functional Components with Hooks
```typescript
// ✅ Good - Functional component
import React, { useState, useEffect } from 'react';
interface TaskItemProps {
task: Task;
onUpdate: (task: Task) => void;
}
export const TaskItem: React.FC<TaskItemProps> = ({ task, onUpdate }) => {
const [editing, setEditing] = useState(false);
useEffect(() => {
// Side effects here
}, [task]);
return <div>{task.name}</div>;
};
// ❌ Bad - Class components (legacy pattern)
class TaskItem extends React.Component { ... }
```
### 2. State Management
```typescript
// Global state - Zustand
import { useStore } from '../store/useStore';
const TaskList = () => {
const tasks = useStore(state => state.tasks);
const setTasks = useStore(state => state.setTasks);
// ...
};
// Server state - SWR
import useSWR from 'swr';
import { getTasks } from '../utils/tasksService';
const TaskList = () => {
const { data: tasks, error, mutate } = useSWR('/api/v1/tasks', getTasks);
// ...
};
// Local component state - useState
const [isOpen, setIsOpen] = useState(false);
```
### 3. Styling - Tailwind CSS
```typescript
// ✅ Good - Tailwind utility classes
<div className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-lg shadow">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
{task.name}
</h2>
</div>
// Conditional classes
<div className={`p-4 ${task.completed ? 'opacity-50 line-through' : ''}`}>
{task.name}
</div>
// Complex conditionals - use clsx or classnames
import clsx from 'clsx';
<div className={clsx(
'p-4 rounded-lg',
task.completed && 'opacity-50 line-through',
task.priority === 2 && 'border-l-4 border-red-500'
)}>
```
---
## Naming Conventions
| Type | Convention | Example |
|------|------------|---------|
| **Files** | kebab-case | `recurring-task-service.js`, `task-item.tsx` |
| **React Components** | PascalCase | `TaskItem.tsx`, `ProjectForm.tsx` |
| **Functions** | camelCase | `findTaskById`, `createTask`, `handleSubmit` |
| **Classes** | PascalCase | `TaskService`, `BaseRepository` |
| **Constants** | UPPER_SNAKE_CASE | `API_VERSION`, `MAX_FILE_SIZE` |
| **Variables** | camelCase | `userId`, `taskList`, `isCompleted` |
| **Database Tables** | PascalCase | `Tasks`, `Projects`, `Users` |
| **Database Columns** | snake_case | `user_id`, `due_date`, `created_at` |
| **Interfaces (TS)** | PascalCase | `TaskProps`, `User`, `ApiResponse` |
| **Type Aliases (TS)** | PascalCase | `TaskStatus`, `Priority` |
---
## API Route Conventions
```javascript
// Singular for single resource operations
POST /api/v1/task // Create new task
GET /api/v1/task/:id // Get task by ID
PUT /api/v1/task/:id // Update task
DELETE /api/v1/task/:id // Delete task
// Plural for collection operations
GET /api/v1/tasks // List all tasks
GET /api/v1/tasks/today // Filtered list
GET /api/v1/tasks/upcoming // Another filtered list
// UID support (alternative to numeric ID)
GET /api/v1/task/uid/:uid // Get by UID
// Nested resources
GET /api/v1/project/:id/tasks // Tasks for a project
POST /api/v1/task/:id/tags // Add tags to task
```
---
## HTTP Status Codes
Use appropriate status codes:
```javascript
// Success
200 OK // Successful GET, PUT
201 Created // Successful POST
204 No Content // Successful DELETE
// Client errors
400 Bad Request // Invalid input
401 Unauthorized // Not authenticated
403 Forbidden // Not authorized
404 Not Found // Resource doesn't exist
409 Conflict // Duplicate resource
// Server errors
500 Internal Server Error // Unexpected error
```
**Example:**
```javascript
// Success cases
res.json(task); // 200 (default)
res.status(201).json(task); // 201 for create
res.status(204).send(); // 204 for delete
// Error cases
res.status(400).json({ error: 'Invalid input' });
res.status(404).json({ error: 'Not found' });
```
---
## Commit Message Style
Based on git log history:
```bash
# Use imperative mood
git commit -m "Fix sidebar toggle causing unnecessary reload"
git commit -m "Add priority level field to tasks"
git commit -m "Update database migration for recurring tasks"
# Reference issues (if applicable)
git commit -m "Fix subtask completion not persisting (#920)"
# Prefix releases
git commit -m "release: v0.89.0"
# Multi-line for complex changes
git commit -m "Add estimated time feature
- Add database migration for estimated_time field
- Update Task model and serializers
- Update Swagger documentation
- Add UI field in TaskForm component
- Add validation for positive values
- Add integration tests"
```
**Guidelines:**
- Start with a verb in imperative mood (Add, Fix, Update, Remove)
- Keep first line under 72 characters
- Reference issue numbers with (#123)
- Use body for detailed explanations (if needed)
---
## Code Documentation
### JSDoc for Backend Functions
```javascript
/**
* Find all tasks for a user with optional filtering
*
* @param {number} userId - The user ID
* @param {Object} filters - Optional filters
* @param {string} [filters.status] - Filter by status
* @param {string} [filters.priority] - Filter by priority
* @returns {Promise<Task[]>} Array of tasks
*/
async function findAllTasks(userId, filters = {}) {
// ...
}
```
### TypeScript Interfaces
```typescript
/**
* Task entity representing a single task
*/
export interface Task {
/** Unique numeric ID */
id: number;
/** Unique string identifier */
uid: string;
/** Task name/title */
name: string;
/** Optional due date */
due_date: string | null;
/** Priority: 0 (low), 1 (medium), 2 (high) */
priority: 0 | 1 | 2;
/** Current status */
status: TaskStatus;
}
```
---
## File Organization
### Backend Module Files
```
/backend/modules/[module]/
├── routes.js # Keep routes simple, delegate to operations
├── repository.js # Only database queries
├── operations/ # Complex business logic
├── core/
│ ├── serializers.js # Only formatting
│ ├── builders.js # Only object construction
│ └── parsers.js # Only parsing
└── utils/
└── validation.js # Only validation
```
**Principle:** Single Responsibility - each file has one clear purpose
### Frontend Component Files
```
/frontend/components/Task/
├── TaskItem.tsx # Single task display
├── TaskList.tsx # List of tasks
├── TaskForm.tsx # Task creation/editing form
├── TaskFilters.tsx # Filter controls
└── SubtaskList.tsx # Subtask-specific component
```
**Principle:** Feature-based organization, components stay focused
---
## Testing Conventions
### Test File Naming
```
// Backend
/backend/tests/unit/services/taskService.test.js
/backend/tests/integration/tasks/tasks.test.js
// Frontend
/frontend/components/Task/__tests__/TaskItem.test.tsx
```
### Test Structure
```javascript
describe('Feature or Component', () => {
// Setup
beforeEach(() => {
// Arrange common setup
});
// Cleanup
afterEach(() => {
// Clean up
});
it('should do specific thing', () => {
// Arrange
const input = 'test';
// Act
const result = functionUnderTest(input);
// Assert
expect(result).toBe('expected');
});
});
```
---
## Security Best Practices
### Input Validation
```javascript
// ✅ Always validate user input
if (!data.name || typeof data.name !== 'string') {
throw new ValidationError('Name is required');
}
if (data.priority !== undefined) {
const priority = parseInt(data.priority, 10);
if (isNaN(priority) || priority < 0 || priority > 2) {
throw new ValidationError('Invalid priority');
}
}
```
### SQL Injection Prevention
```javascript
// ✅ Good - Sequelize handles parameterization
const tasks = await Task.findAll({
where: { user_id: userId, name: { [Op.like]: `%${searchTerm}%` } }
});
// ❌ Bad - Raw queries (if absolutely necessary, use replacements)
await sequelize.query(
'SELECT * FROM Tasks WHERE user_id = :userId',
{
replacements: { userId },
type: QueryTypes.SELECT
}
);
```
### Password Handling
```javascript
// ✅ Always hash passwords with bcrypt
const bcrypt = require('bcrypt');
const hashedPassword = await bcrypt.hash(password, 10);
// ❌ Never store plain text passwords
user.password = password; // NEVER DO THIS
```
### XSS Prevention
```typescript
// ✅ React automatically escapes content
<div>{task.name}</div>
// ⚠️ Only use dangerouslySetInnerHTML for trusted, sanitized content
import DOMPurify from 'dompurify';
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
```
---
## Performance Best Practices
### Database Queries
```javascript
// ✅ Good - Use includes for associations
const tasks = await Task.findAll({
where: { user_id: userId },
include: [Project, Tag] // Eager loading
});
// ❌ Bad - N+1 queries
const tasks = await Task.findAll({ where: { user_id: userId } });
for (const task of tasks) {
task.project = await Project.findByPk(task.project_id); // N+1!
}
```
### React Rendering
```typescript
// ✅ Good - Memoize expensive computations
const sortedTasks = useMemo(
() => tasks.sort((a, b) => a.priority - b.priority),
[tasks]
);
// ✅ Good - Prevent unnecessary re-renders
const TaskItem = React.memo(({ task }) => {
return <div>{task.name}</div>;
});
```
---
[← Back to Index](../CLAUDE.md)

540
docs/common-tasks.md Normal file
View file

@ -0,0 +1,540 @@
# Common Tasks Reference
[← Back to Index](../CLAUDE.md)
Quick reference for frequently performed development tasks with complete file lists.
---
## Task 1: Add New Field to Existing Model
**Example: Add `estimated_time` field to Task**
### Files to Modify
1. **Create migration**
```bash
npm run migration:create -- --name add-estimated-time-to-tasks
```
Edit `/backend/migrations/YYYYMMDDHHMMSS-add-estimated-time-to-tasks.js`
2. **Update model**
- `/backend/models/task.js` - Add field definition
3. **Update serializer**
- `/backend/modules/tasks/core/serializers.js` - Add to `serializeTask()`
4. **Update builder**
- `/backend/modules/tasks/core/builders.js` - Add to `buildTaskAttributes()`
5. **Add validation (if needed)**
- `/backend/modules/tasks/routes.js` - Add validation logic
6. **Update API docs**
- `/backend/config/swagger.js` - Update Task schema
7. **Update frontend types**
- `/frontend/entities/Task.ts` - Add to interface (if exists)
8. **Update UI**
- `/frontend/components/Task/TaskForm.tsx` - Add input field
- `/frontend/components/Task/TaskItem.tsx` - Display field (if needed)
9. **Add tests**
- `/backend/tests/integration/tasks/tasks.test.js` - Test CRUD with new field
### Commands
```bash
npm run migration:create -- --name add-estimated-time-to-tasks
# Edit migration file
npm run migration:run
npm run backend:test
npm run lint:fix
npm run format:fix
```
---
## Task 2: Create New Backend Module
**Example: Create "labels" module**
### Steps
1. **Create directory structure**
```bash
mkdir -p /Users/chris/c0deLab/ProjectLand/tududi/backend/modules/labels
```
2. **Create files**
- `/backend/modules/labels/routes.js` - Express routes
- `/backend/modules/labels/repository.js` - Data access
3. **Create model**
- `/backend/models/label.js` - Sequelize model
- Update `/backend/models/index.js` - Add associations
4. **Create migration**
```bash
npm run migration:create -- --name create-labels-table
```
- Edit `/backend/migrations/YYYYMMDDHHMMSS-create-labels-table.js`
5. **Register routes**
- Edit `/backend/app.js`:
```javascript
app.use('/api/v1', require('./modules/labels/routes'));
app.use('/api', require('./modules/labels/routes'));
```
6. **Add Swagger docs**
- Edit `/backend/config/swagger.js` - Add Label schema
7. **Write tests**
- Create `/backend/tests/integration/labels/labels.test.js`
8. **Frontend integration (optional)**
- `/frontend/entities/Label.ts` - TypeScript interface
- `/frontend/utils/labelsService.ts` - API client
- `/frontend/components/Label/` - Components
### Commands
```bash
npm run migration:create -- --name create-labels-table
npm run migration:run
npm run backend:test
```
---
## Task 3: Add New React Component
**Example: Create `TaskPriorityBadge` component**
### Files to Create
1. **Component file**
- `/frontend/components/Task/TaskPriorityBadge.tsx`
2. **Test file (optional)**
- `/frontend/components/Task/__tests__/TaskPriorityBadge.test.tsx`
3. **Import in parent component**
- `/frontend/components/Task/TaskItem.tsx` - Import and use
### Example Component
```typescript
// TaskPriorityBadge.tsx
import React from 'react';
interface TaskPriorityBadgeProps {
priority: number;
}
export const TaskPriorityBadge: React.FC<TaskPriorityBadgeProps> = ({ priority }) => {
const configs = {
0: { label: 'Low', className: 'bg-gray-200 text-gray-800' },
1: { label: 'Medium', className: 'bg-yellow-200 text-yellow-800' },
2: { label: 'High', className: 'bg-red-200 text-red-800' }
};
const config = configs[priority] || configs[0];
return (
<span className={`px-2 py-1 rounded text-xs font-semibold ${config.className}`}>
{config.label}
</span>
);
};
```
### Commands
```bash
npm run frontend:test
npm run lint:fix
```
---
## Task 4: Update Database Schema
**Example: Add index to Tasks table**
### Steps
1. **Create migration**
```bash
npm run migration:create -- --name add-index-to-tasks-due-date
```
2. **Edit migration file**
`/backend/migrations/YYYYMMDDHHMMSS-add-index-to-tasks-due-date.js`
```javascript
async up(queryInterface, Sequelize) {
await queryInterface.addIndex('Tasks', ['due_date'], {
name: 'tasks_due_date_index'
});
}
async down(queryInterface, Sequelize) {
await queryInterface.removeIndex('Tasks', 'tasks_due_date_index');
}
```
3. **Test migration**
```bash
npm run migration:run
npm run migration:undo # Test rollback
npm run migration:run # Re-apply
```
4. **Update model (optional)**
- Index definition can be in migration only, or also in model
### Commands
```bash
npm run migration:create -- --name add-index-to-tasks-due-date
npm run migration:run
npm run backend:test
```
---
## Task 5: Fix a Bug (TDD Workflow)
**Example: Fix bug where completed tasks show in Today view**
### Steps
1. **Write failing test first**
Edit `/backend/tests/integration/tasks/tasks.test.js`:
```javascript
it('should not return completed tasks in Today view', async () => {
// Arrange - Create completed task
await Task.create({
name: 'Completed Task',
status: 2,
due_date: new Date().toISOString().split('T')[0],
user_id: user.id
});
// Act
const response = await request(app)
.get('/api/v1/tasks/today')
.set('Cookie', authCookie);
// Assert
expect(response.status).toBe(200);
const completedTasks = response.body.filter(t => t.status === 2);
expect(completedTasks.length).toBe(0);
});
```
2. **Run test - verify it fails**
```bash
npm run backend:test
```
3. **Fix the bug**
Edit `/backend/modules/tasks/queries/query-builders.js` or `/backend/modules/tasks/operations/list.js`:
```javascript
// Add status filter to Today query
where.status = { [Op.ne]: 2 }; // Exclude completed
```
4. **Run test - verify it passes**
```bash
npm run backend:test
```
5. **Run all checks**
```bash
npm run pre-push
```
6. **Commit**
```bash
git add .
git commit -m "Fix completed tasks appearing in Today view (#123)
- Add status filter to Today query
- Add test to prevent regression"
```
---
## Task 6: Add Translation Key
### Backend Translations
Backend typically doesn't use translations (API responses in English).
### Frontend Translations
1. **Add key to English source**
Edit `/public/locales/en/translation.json`:
```json
{
"task": {
"estimatedTime": "Estimated Time",
"estimatedTimePlaceholder": "Enter time in minutes",
"estimatedTimeUnit": "minutes"
}
}
```
2. **Use in component**
```typescript
import { useTranslation } from 'react-i18next';
const TaskForm = () => {
const { t } = useTranslation();
return (
<div>
<label>{t('task.estimatedTime')}</label>
<input
type="number"
placeholder={t('task.estimatedTimePlaceholder')}
/>
<span>{t('task.estimatedTimeUnit')}</span>
</div>
);
};
```
3. **Sync translations (if tool available)**
```bash
npm run translations:sync
npm run translations:check
```
---
## Task 7: Add API Endpoint Documentation
### Update Swagger Schema
Edit `/backend/config/swagger.js`:
```javascript
// Update existing schema
components: {
schemas: {
Task: {
type: 'object',
properties: {
id: { type: 'integer' },
uid: { type: 'string' },
name: { type: 'string' },
estimated_time: {
type: 'integer',
description: 'Estimated time in minutes',
nullable: true,
example: 30
}
}
}
}
}
```
### Document New Endpoint with JSDoc
In `/backend/modules/tasks/routes.js`:
```javascript
/**
* @swagger
* /api/v1/task/{id}/estimate:
* put:
* summary: Update task estimated time
* tags: [Tasks]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* estimated_time:
* type: integer
* description: Time in minutes
* responses:
* 200:
* description: Task updated successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Task'
* 404:
* description: Task not found
*/
router.put('/task/:id/estimate', async (req, res, next) => {
// Implementation
});
```
### View Swagger Docs
1. Start server: `npm run backend:dev`
2. Login to app
3. Visit: http://localhost:3002/api-docs
---
## Task 8: Add Subtask to Task
**Example: Add subtasks to a task**
This is already implemented in tududi, but here's the pattern:
### Files Involved
1. **Migration**
- Already exists: `parent_task_id` field in Tasks table
2. **Model**
- `/backend/models/task.js` - Self-referential association
- `/backend/models/index.js` - Define relationship:
```javascript
Task.hasMany(Task, { as: 'Subtasks', foreignKey: 'parent_task_id' });
Task.belongsTo(Task, { as: 'ParentTask', foreignKey: 'parent_task_id' });
```
3. **Backend operations**
- `/backend/modules/tasks/operations/subtasks.js` - Subtask CRUD
4. **Frontend**
- `/frontend/components/Task/SubtaskList.tsx` - Display subtasks
- `/frontend/components/Task/TaskDetails.tsx` - Show subtasks
---
## Task 9: Add Permission/Sharing to Resource
**Example: Add sharing to a new resource type**
### Steps
1. **Ensure Permission model supports resource type**
Check `/backend/models/permission.js`:
```javascript
resource_type: {
type: DataTypes.STRING,
allowNull: false,
// Should support your resource type
}
```
2. **Use `hasAccess` middleware in routes**
```javascript
const { hasAccess } = require('../../middleware/authorize');
router.get('/myresource/:id',
hasAccess('ro', 'myresource', (req) => req.params.id),
async (req, res, next) => {
// Handler
}
);
```
3. **Implement sharing endpoints**
See `/backend/modules/shares/` for reference
4. **Frontend sharing UI**
See `/frontend/components/Project/ProjectSharing.tsx` for reference
---
## Task 10: Add Recurring Pattern to Task
**Example: Add new recurrence type**
### Files to Check/Modify
1. **Task model**
- `/backend/models/task.js` - Recurrence fields
2. **Recurring task service**
- `/backend/modules/tasks/recurringTaskService.js` - Add new pattern logic
3. **Task scheduler**
- `/backend/modules/tasks/taskScheduler.js` - Ensure cron job handles new pattern
4. **Frontend**
- `/frontend/components/Task/TaskForm.tsx` - Add UI for new pattern
---
## Task 11: Export Data (Backup)
**Example: Create database backup**
Already implemented in tududi:
```bash
# Via API (after login)
POST /api/v1/backup
# Returns JSON file with all user data
```
See `/backend/modules/backup/` for implementation details.
---
## Quick Command Reference
```bash
# Database
npm run db:init # Initialize database
npm run db:reset # Reset database
npm run migration:create -- --name # Create migration
npm run migration:run # Run migrations
npm run migration:undo # Rollback migration
# Development
npm start # Run both frontend + backend
npm run backend:dev # Backend only
npm run frontend:dev # Frontend only
# Testing
npm test # Backend tests
npm run frontend:test # Frontend tests
npm run test:ui # E2E tests
npm run test:coverage # Coverage report
# Code Quality
npm run lint # Check linting
npm run lint:fix # Fix linting
npm run format:fix # Format code
npm run pre-push # All checks
# Build
npm run build # Production build
```
---
[← Back to Index](../CLAUDE.md)

508
docs/database.md Normal file
View file

@ -0,0 +1,508 @@
# Database & Migrations
[← Back to Index](../CLAUDE.md)
---
## Key Models
All Sequelize models are defined in `/backend/models/` and associations are configured in `/backend/models/index.js`.
### Core Models
| Model | File | Purpose | Key Fields |
|-------|------|---------|------------|
| **User** | `user.js` | User accounts | email, password (bcrypt), settings, preferences, timezone |
| **Task** | `task.js` | Tasks with recurrence | name, due_date, priority, status, recurrence_type, parent_task_id |
| **Project** | `project.js` | Project grouping | name, area_id, user_id |
| **Area** | `area.js` | Area categorization | name, user_id |
| **Note** | `note.js` | Notes | text, project_id, user_id |
| **Tag** | `tag.js` | Tags | name, color, user_id |
| **Permission** | `permission.js` | Sharing/permissions | user_id, resource_type, resource_uid, access_level |
| **ApiToken** | `apiToken.js` | API tokens | user_id, token_hash, expires_at |
| **RecurringCompletion** | `recurringCompletion.js` | Recurring task history | task_id, completed_at, due_date |
| **TaskEvent** | `taskEvent.js` | Task audit log | task_id, user_id, action, changes |
| **TaskAttachment** | `taskAttachment.js` | File attachments | task_id, filename, path |
| **InboxItem** | `inboxItem.js` | Inbox entries | name, user_id |
| **Notification** | `notification.js` | User notifications | user_id, type, read, linked_resource |
| **Role** | `role.js` | User roles | name, is_admin |
| **View** | `view.js` | Saved views | name, filters, user_id |
| **Backup** | `backup.js` | Backup records | user_id, filename, created_at |
---
## Model Relationships
Defined in `/backend/models/index.js`:
```javascript
// User relationships (one user has many resources)
User.hasMany(Task, { foreignKey: 'user_id' });
User.hasMany(Project, { foreignKey: 'user_id' });
User.hasMany(Area, { foreignKey: 'user_id' });
User.hasMany(Note, { foreignKey: 'user_id' });
// Hierarchical relationships
// Area > Project
Area.hasMany(Project, { foreignKey: 'area_id' });
Project.belongsTo(Area, { foreignKey: 'area_id' });
// Project > Task
Project.hasMany(Task, { foreignKey: 'project_id' });
Task.belongsTo(Project, { foreignKey: 'project_id' });
// Project > Note
Project.hasMany(Note, { foreignKey: 'project_id' });
Note.belongsTo(Project, { foreignKey: 'project_id' });
// Self-referential (subtasks and recurring tasks)
Task.hasMany(Task, { as: 'Subtasks', foreignKey: 'parent_task_id' });
Task.belongsTo(Task, { as: 'ParentTask', foreignKey: 'parent_task_id' });
// Many-to-many relationships (tags)
Task.belongsToMany(Tag, { through: 'tasks_tags' });
Tag.belongsToMany(Task, { through: 'tasks_tags' });
Project.belongsToMany(Tag, { through: 'projects_tags' });
Tag.belongsToMany(Project, { through: 'projects_tags' });
Note.belongsToMany(Tag, { through: 'notes_tags' });
Tag.belongsToMany(Note, { through: 'notes_tags' });
```
---
## Migration Workflow
### Creating a New Migration
```bash
npm run migration:create -- --name add-field-to-tasks
```
This creates: `/backend/migrations/YYYYMMDDHHMMSS-add-field-to-tasks.js`
The timestamp ensures migrations run in chronological order.
### Migration File Template
```javascript
// backend/migrations/20260313120000-add-priority-level-to-tasks.js
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
// Changes to apply
await queryInterface.addColumn('Tasks', 'priority_level', {
type: Sequelize.INTEGER,
allowNull: true,
defaultValue: null
});
},
async down(queryInterface, Sequelize) {
// How to reverse the changes
await queryInterface.removeColumn('Tasks', 'priority_level');
}
};
```
### Running Migrations
```bash
# Apply all pending migrations
npm run migration:run
# Rollback last migration
npm run migration:undo
# Check migration status
npm run db:status
# Rollback all migrations (CAREFUL!)
npm run migration:undo:all
```
---
## Migration Best Practices
### 1. Always Implement `down` Method
Make migrations reversible:
```javascript
// ✅ Good - Reversible
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn('Tasks', 'priority_level');
}
// ❌ Bad - Not reversible
async down(queryInterface, Sequelize) {
// Not implemented
}
```
### 2. Test Both Directions
```bash
# Test up
npm run migration:run
# Test down (rollback)
npm run migration:undo
# Re-apply to verify
npm run migration:run
```
### 3. Never Modify Released Migrations
Once a migration is released (in main branch or deployed), create a new one instead:
```bash
# ❌ Bad - Modifying existing migration
# Edit: migrations/20260101-create-tasks.js
# ✅ Good - Create new migration
npm run migration:create -- --name modify-tasks-table
```
### 4. Update Corresponding Model
After creating migration, update the Sequelize model:
```javascript
// Migration adds field
await queryInterface.addColumn('Tasks', 'estimated_time', {
type: Sequelize.INTEGER
});
// Update /backend/models/task.js
Task.init({
// ... existing fields ...
estimated_time: {
type: DataTypes.INTEGER,
allowNull: true
}
});
```
### 5. Use Transactions for Data Migrations
```javascript
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
// Multiple operations in transaction
await queryInterface.addColumn('Tasks', 'new_field', {...}, { transaction });
// Data migration
await queryInterface.sequelize.query(
'UPDATE Tasks SET new_field = old_field WHERE old_field IS NOT NULL',
{ transaction }
);
await queryInterface.removeColumn('Tasks', 'old_field', { transaction });
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
```
### 6. Include Migrations in PRs
When schema changes are needed, always include the migration file in your PR.
---
## Common Migration Operations
### Add Column
```javascript
await queryInterface.addColumn('TableName', 'column_name', {
type: Sequelize.STRING,
allowNull: false,
defaultValue: 'default'
});
```
### Remove Column
```javascript
await queryInterface.removeColumn('TableName', 'column_name');
```
### Change Column Type
```javascript
await queryInterface.changeColumn('TableName', 'column_name', {
type: Sequelize.TEXT, // Changed from STRING
allowNull: true
});
```
### Rename Column
```javascript
await queryInterface.renameColumn('TableName', 'old_name', 'new_name');
```
### Add Index
```javascript
await queryInterface.addIndex('TableName', ['column_name'], {
name: 'index_name',
unique: false
});
```
### Remove Index
```javascript
await queryInterface.removeIndex('TableName', 'index_name');
```
### Create Table
```javascript
await queryInterface.createTable('NewTable', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true
},
uid: {
type: Sequelize.STRING(15),
allowNull: false,
unique: true
},
name: {
type: Sequelize.STRING,
allowNull: false
},
user_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'Users',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
created_at: {
type: Sequelize.DATE,
allowNull: false
},
updated_at: {
type: Sequelize.DATE,
allowNull: false
}
});
```
### Drop Table
```javascript
await queryInterface.dropTable('TableName');
```
### Add Foreign Key
```javascript
await queryInterface.addConstraint('Tasks', {
fields: ['project_id'],
type: 'foreign key',
name: 'tasks_project_fk',
references: {
table: 'Projects',
field: 'id'
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE'
});
```
---
## Database Configuration
### Location
`/backend/config/database.js`
### SQLite Performance Optimizations
```javascript
// Applied in models/index.js
await sequelize.query('PRAGMA journal_mode=WAL'); // Write-Ahead Logging
await sequelize.query('PRAGMA synchronous=NORMAL'); // Faster with single user
await sequelize.query('PRAGMA busy_timeout=5000'); // 5s timeout
await sequelize.query('PRAGMA cache_size=-64000'); // 64MB cache
await sequelize.query('PRAGMA mmap_size=268435456'); // 256MB memory-mapped I/O
await sequelize.query('PRAGMA temp_store=MEMORY'); // Memory-based temp storage
```
**Benefits:**
- WAL mode: Better concurrency for writes
- Larger cache: Fewer disk reads
- Memory-mapped I/O: Faster random access
- Memory temp storage: Faster temp operations
### Database File Location
- **Development:** `/backend/database.sqlite`
- **Docker:** Mounted volume at `/app/backend/db/`
- **Test:** Separate test database (auto-created)
---
## Database Management Commands
```bash
# Initialize database (create + migrate)
npm run db:init
# Reset database (WIPES ALL DATA!)
npm run db:reset
# Seed development data
npm run db:seed
# Check migration status
npm run db:status
# Create new migration
npm run migration:create -- --name description
# Run pending migrations
npm run migration:run
# Rollback last migration
npm run migration:undo
# Rollback all migrations (CAREFUL!)
npm run migration:undo:all
```
---
## Model Definition Example
```javascript
// /backend/models/task.js
'use strict';
const { Model, DataTypes } = require('sequelize');
module.exports = (sequelize) => {
class Task extends Model {
static associate(models) {
// Defined in models/index.js
}
}
Task.init({
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
uid: {
type: DataTypes.STRING(15),
allowNull: false,
unique: true
},
name: {
type: DataTypes.STRING,
allowNull: false
},
due_date: {
type: DataTypes.DATEONLY,
allowNull: true
},
priority: {
type: DataTypes.INTEGER,
defaultValue: 0,
validate: {
min: 0,
max: 2
}
},
status: {
type: DataTypes.INTEGER,
defaultValue: 0
},
// Recurrence fields
recurrence_type: {
type: DataTypes.STRING,
allowNull: true
},
recurrence_interval: {
type: DataTypes.INTEGER,
allowNull: true
},
// Foreign keys
user_id: {
type: DataTypes.INTEGER,
allowNull: false
},
project_id: {
type: DataTypes.INTEGER,
allowNull: true
},
parent_task_id: {
type: DataTypes.INTEGER,
allowNull: true
}
}, {
sequelize,
modelName: 'Task',
tableName: 'Tasks',
underscored: true,
timestamps: true
});
return Task;
};
```
---
## Special Task Model Fields
The Task model has special fields for recurring tasks:
```javascript
// Recurrence pattern
recurrence_type: 'daily' | 'weekly' | 'monthly' | 'monthly_weekday' | 'monthly_last_day'
recurrence_interval: 1, 2, 3, ... (every N days/weeks/months)
recurrence_end_date: Optional end date for series
// Relationships
parent_task_id: Links subtasks to parent task (self-referential)
recurring_parent_id: Links recurring instances to original pattern
```
**Task Status Values:**
- 0: Not started
- 1: In progress
- 2: Done/Completed
- 3: Archived
- 4: Waiting
- 5: Cancelled
- 6: Planned
**Priority Values:**
- 0: Low
- 1: Medium
- 2: High
---
[← Back to Index](../CLAUDE.md)

View file

@ -0,0 +1,580 @@
# Development Workflow
[← Back to Index](../CLAUDE.md)
---
## Initial Setup
### Prerequisites
- **Node.js** v22+ (recommended - check package.json engines field)
- **npm** (comes with Node.js)
- **Git**
### Clone and Install
```bash
# Clone repository
git clone https://github.com/chrisvel/tududi.git
cd tududi
# Install all dependencies
# This installs both frontend and backend dependencies (monorepo setup)
npm install
```
### Initialize Database
```bash
# Create database and run all migrations
npm run db:init
# This command:
# 1. Creates /backend/database.sqlite
# 2. Runs all migrations from /backend/migrations/
# 3. Sets up tables and relationships
```
### Create Test User (Optional)
```bash
npm run user:create
# Interactive prompts:
# - Email address
# - Password
# - Timezone (defaults to system timezone)
```
---
## Daily Development
Tududi runs two separate processes during development:
### Two-Server Development
**Terminal 1 - Backend (Express):**
```bash
npm run backend:dev
# Details:
# - Runs: nodemon backend/app.js
# - Server: http://localhost:3002
# - Auto-reloads: Yes (on file changes)
# - API endpoints: /api/v1/*
# - Swagger docs: /api-docs (after login)
```
**Terminal 2 - Frontend (Webpack Dev Server):**
```bash
npm run frontend:dev
# Details:
# - Runs: webpack serve --mode development
# - Server: http://localhost:8080
# - Hot reload: Yes (React Fast Refresh)
# - Proxies /api/* to backend:3002
# - Proxies /locales/* to backend:3002
```
**Or run both simultaneously:**
```bash
npm start
# Runs both backend:dev and frontend:dev in parallel
# Uses 'concurrently' package
# Logs from both processes interleaved
```
### Accessing the Application
Open http://localhost:8080 in your browser.
**Login with:**
- Email: The email you set during db:init or user:create
- Password: The password you set
---
## Environment Variables
Create `/backend/.env` file (not tracked in git):
```bash
# Required
TUDUDI_SESSION_SECRET=your-random-secret-here-use-openssl-rand-hex-64
TUDUDI_USER_EMAIL=admin@example.com
TUDUDI_USER_PASSWORD=your-secure-password
# Optional - Server config
NODE_ENV=development
DB_FILE=database.sqlite
FRONTEND_URL=http://localhost:8080
BACKEND_URL=http://localhost:3002
PORT=3002
HOST=0.0.0.0
# Optional - Email
ENABLE_EMAIL=false
EMAIL_SMTP_HOST=smtp.example.com
EMAIL_SMTP_PORT=587
EMAIL_SMTP_SECURE=false
EMAIL_SMTP_USERNAME=user
EMAIL_SMTP_PASSWORD=pass
EMAIL_FROM_ADDRESS=noreply@example.com
EMAIL_FROM_NAME=Tududi
# Optional - Integrations
DISABLE_TELEGRAM=false
GOOGLE_CLIENT_ID=your-google-oauth-client-id
GOOGLE_CLIENT_SECRET=your-google-oauth-secret
GOOGLE_REDIRECT_URI=http://localhost:8080/auth/google/callback
# Optional - Features
DISABLE_SCHEDULER=false
SWAGGER_ENABLED=true
RATE_LIMITING_ENABLED=true
# Optional - Proxy (if behind reverse proxy)
TUDUDI_TRUST_PROXY=false
TUDUDI_ALLOWED_ORIGINS=http://localhost:8080
# Optional - Registration
REGISTRATION_TOKEN_EXPIRY_HOURS=24
```
**Generate secure session secret:**
```bash
openssl rand -hex 64
```
---
## Adding a New Feature (Complete Example)
**Example: Add "estimated_time" field to tasks**
This walkthrough shows all files to touch when adding a new field to an existing model.
### Step 1: Create Database Migration
```bash
npm run migration:create -- --name add-estimated-time-to-tasks
```
Edit the created file `/backend/migrations/YYYYMMDDHHMMSS-add-estimated-time-to-tasks.js`:
```javascript
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('Tasks', 'estimated_time', {
type: Sequelize.INTEGER, // minutes
allowNull: true,
defaultValue: null
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn('Tasks', 'estimated_time');
}
};
```
**Run migration:**
```bash
npm run migration:run
```
### Step 2: Update Sequelize Model
Edit `/backend/models/task.js`:
```javascript
Task.init({
// ... existing fields ...
estimated_time: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'Estimated time in minutes'
},
// ... rest of fields ...
}, {
sequelize,
modelName: 'Task',
tableName: 'Tasks'
});
```
### Step 3: Update Serializer (API Response)
Edit `/backend/modules/tasks/core/serializers.js`:
```javascript
function serializeTask(task) {
return {
// ... existing fields ...
estimated_time: task.estimated_time,
// ... rest of fields ...
};
}
```
### Step 4: Update Builder (API Input)
Edit `/backend/modules/tasks/core/builders.js`:
```javascript
function buildTaskAttributes(data, userId) {
const attributes = {
// ... existing fields ...
estimated_time: data.estimated_time ? parseInt(data.estimated_time, 10) : null,
// ... rest of fields ...
};
return attributes;
}
```
### Step 5: Add Validation (Optional)
If validation needed, edit `/backend/modules/tasks/routes.js`:
```javascript
router.put('/task/:id', async (req, res, next) => {
try {
// Validate estimated_time
if (req.body.estimated_time !== undefined) {
const time = parseInt(req.body.estimated_time, 10);
if (isNaN(time) || time < 0) {
return res.status(400).json({
error: 'Estimated time must be a positive number'
});
}
}
// ... rest of route handler ...
} catch (error) {
next(error);
}
});
```
### Step 6: Update Swagger Documentation
Edit `/backend/config/swagger.js`:
```javascript
// Find Task schema
components: {
schemas: {
Task: {
type: 'object',
properties: {
// ... existing properties ...
estimated_time: {
type: 'integer',
description: 'Estimated time in minutes',
nullable: true,
example: 30
}
}
}
}
}
```
### Step 7: Update Frontend TypeScript Interface
If TypeScript interface exists, edit `/frontend/entities/Task.ts`:
```typescript
export interface Task {
// ... existing fields ...
estimated_time: number | null;
// ... rest of fields ...
}
```
### Step 8: Update Frontend Component
Edit `/frontend/components/Task/TaskForm.tsx`:
```typescript
// Add input field
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Estimated Time (minutes)
</label>
<input
type="number"
min="0"
value={task.estimated_time || ''}
onChange={(e) => updateTask({
...task,
estimated_time: e.target.value ? parseInt(e.target.value) : null
})}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
/>
</div>
```
### Step 9: Write Tests
Add tests in `/backend/tests/integration/tasks/tasks.test.js`:
```javascript
it('should create task with estimated_time', async () => {
const response = await request(app)
.post('/api/v1/task')
.set('Cookie', authCookie)
.send({
name: 'Task with estimate',
estimated_time: 60
});
expect(response.status).toBe(201);
expect(response.body.estimated_time).toBe(60);
});
it('should reject negative estimated_time', async () => {
const response = await request(app)
.post('/api/v1/task')
.set('Cookie', authCookie)
.send({
name: 'Invalid task',
estimated_time: -10
});
expect(response.status).toBe(400);
});
```
### Step 10: Run Tests and Checks
```bash
# Run backend tests
npm run backend:test
# Run linting
npm run lint:fix
# Format code
npm run format:fix
# Run all pre-push checks
npm run pre-push
```
### Step 11: Commit Changes
```bash
git add .
git commit -m "Add estimated_time field to tasks
- Add database migration for estimated_time
- Update Task model and serializers
- Update Swagger documentation
- Add validation for positive values
- Add UI field in TaskForm component
- Add integration tests"
```
---
## Database Management
```bash
# Reset database (WIPES ALL DATA!)
npm run db:reset
# Seed development data
npm run db:seed
# Check migration status
npm run db:status
# Create new migration
npm run migration:create -- --name description
# Run pending migrations
npm run migration:run
# Rollback last migration
npm run migration:undo
```
---
## Code Quality
```bash
# Check linting
npm run lint
# Auto-fix linting issues
npm run lint:fix
# Format code with Prettier
npm run format:fix
# Run all pre-push checks
# (lint + format + tests)
npm run pre-push
```
---
## Testing
```bash
# Backend tests
npm test
# or
npm run backend:test
# Frontend tests
npm run frontend:test
# E2E tests
npm run test:ui # Headless
npm run test:ui:headed # With browser visible
# Coverage report
npm run test:coverage
# Watch mode (during development)
npm run test:watch
```
---
## Branch Strategy
From CONTRIBUTING.md conventions:
```bash
# Feature branches
git checkout -b feature/description
# Bug fix branches
git checkout -b fix/description
# Refactoring branches
git checkout -b refactor/description
# Documentation branches
git checkout -b docs/description
# Test branches
git checkout -b test/description
```
### Example Workflow
```bash
# Create feature branch from main
git checkout main
git pull origin main
git checkout -b feature/estimated-time
# Make changes, run tests
npm run pre-push
# Commit changes
git add .
git commit -m "Add estimated time feature"
# Before PR: rebase on main
git checkout main
git pull origin main
git checkout feature/estimated-time
git rebase main
# Push and create PR
git push origin feature/estimated-time
```
---
## Build for Production
```bash
# Build frontend
npm run build
# Builds to: /dist/
# - Minified JavaScript
# - Optimized CSS
# - Hashed filenames for cache busting
```
### Docker Production
```bash
# Build Docker image
docker build -t tududi:latest .
# Run container
docker run \
-e TUDUDI_USER_EMAIL=admin@example.com \
-e TUDUDI_USER_PASSWORD=secure-password \
-e TUDUDI_SESSION_SECRET=$(openssl rand -hex 64) \
-v ~/tududi_db:/app/backend/db \
-v ~/tududi_uploads:/app/backend/uploads \
-p 3002:3002 \
-d tududi:latest
```
---
## Troubleshooting
### Port Already in Use
```bash
# Find process using port 3002
lsof -ti:3002
# Kill process
kill -9 $(lsof -ti:3002)
# Or use different port
PORT=3003 npm run backend:dev
```
### Database Locked
```bash
# Stop all servers
# Delete database file
rm backend/database.sqlite
# Reinitialize
npm run db:init
```
### Module Not Found
```bash
# Clean install
rm -rf node_modules package-lock.json
npm install
```
### Webpack Build Errors
```bash
# Clear webpack cache
rm -rf node_modules/.cache
# Rebuild
npm run frontend:dev
```
---
[← Back to Index](../CLAUDE.md)

457
docs/directory-structure.md Normal file
View file

@ -0,0 +1,457 @@
# Directory Structure
[← Back to Index](../CLAUDE.md)
---
## Project Root
```
/Users/chris/c0deLab/ProjectLand/tududi/
├── README.md # User-facing documentation
├── CLAUDE.md # This developer guide (index)
├── LICENSE # MIT License
├── package.json # Root scripts and dependencies (monorepo)
├── package-lock.json # Dependency lock file
├── Configuration Files
├── webpack.config.js # Frontend build configuration
├── tsconfig.json # TypeScript config (frontend only)
├── jest.config.js # Jest config for frontend tests
├── babel.config.js # Babel transpilation for Jest + Webpack
├── eslint.config.mjs # ESLint flat config
├── .prettierrc.json # Prettier code formatting
├── tailwind.config.js # Tailwind CSS customization
├── .sequelizerc # Sequelize CLI configuration
├── postcss.config.js # PostCSS config for Tailwind
├── Docker & Deployment
├── Dockerfile # Production Docker image (multi-stage)
├── docker-compose.yml # Development Docker setup
├── .dockerignore # Docker build exclusions
├── Git & GitHub
├── .gitignore
├── .github/
│ ├── CONTRIBUTING.md # Contribution guidelines
│ └── workflows/ # GitHub Actions (if any)
├── Source Code
├── backend/ # Express backend → See Backend Structure
├── frontend/ # React frontend → See Frontend Structure
├── public/ # Static assets (fonts, locales, images)
├── dist/ # Production build output
├── e2e/ # Playwright E2E tests
├── scripts/ # Build and utility scripts
├── docs/ # Documentation (this directory)
└── Other
├── screenshots/ # App screenshots for README
├── uploads/ # User file uploads (not in git)
├── test-results/ # Playwright test results
└── node_modules/ # Dependencies
```
---
## Backend Structure
```
/Users/chris/c0deLab/ProjectLand/tududi/backend/
├── app.js # Main Express application entry point
│ # - Middleware setup (Helmet, CORS, compression)
│ # - Session management
│ # - Rate limiting
│ # - Module registration
│ # - Swagger integration
│ # - SPA fallback routing
├── modules/ # Feature modules (modular architecture)
│ │
│ ├── tasks/ # Task management (MOST COMPLEX MODULE)
│ │ ├── routes.js # Express routes
│ │ ├── repository.js # Data access layer
│ │ ├── recurringTaskService.js
│ │ ├── taskEventService.js
│ │ ├── taskScheduler.js # Cron-based scheduling
│ │ ├── operations/ # Business logic operations
│ │ │ ├── list.js # List operations
│ │ │ ├── completion.js # Status changes
│ │ │ ├── recurring.js # Recurrence handling
│ │ │ ├── subtasks.js # Subtask CRUD
│ │ │ ├── tags.js # Tag assignment
│ │ │ ├── grouping.js # Grouping logic
│ │ │ ├── sorting.js # Sort orders
│ │ │ └── parent-child.js # Hierarchy ops
│ │ ├── queries/ # Query builders
│ │ │ ├── query-builders.js
│ │ │ ├── metrics-queries.js
│ │ │ └── metrics-computation.js
│ │ ├── core/ # Core utilities
│ │ │ ├── serializers.js # Format API responses
│ │ │ ├── parsers.js # Parse request data
│ │ │ ├── builders.js # Build database objects
│ │ │ └── comparators.js # Detect changes
│ │ ├── middleware/
│ │ │ └── access.js # Access control
│ │ └── utils/
│ │ ├── constants.js
│ │ ├── validation.js
│ │ └── logging.js
│ │
│ ├── projects/ # Project management
│ │ ├── routes.js
│ │ ├── repository.js
│ │ └── utils/
│ │ └── validation.js
│ │
│ ├── areas/ # Area organization
│ ├── notes/ # Notes management
│ ├── tags/ # Tag system
│ ├── users/ # User management
│ ├── auth/ # Authentication (login/register)
│ ├── shares/ # Project sharing & permissions
│ ├── telegram/ # Telegram bot integration
│ ├── inbox/ # Inbox items
│ ├── habits/ # Habit tracking
│ ├── notifications/ # Notification system
│ ├── search/ # Universal search
│ ├── views/ # Saved views
│ ├── admin/ # Admin functions
│ ├── backup/ # Backup/restore (33KB, complex)
│ ├── feature-flags/ # Feature flag management
│ ├── quotes/ # Daily quotes
│ └── url/ # URL handling
├── models/ # Sequelize model definitions
│ ├── index.js # Model initialization & associations
│ ├── task.js # Task model (recurrence fields)
│ ├── project.js # Project model
│ ├── area.js # Area model
│ ├── note.js # Note model
│ ├── tag.js # Tag model
│ ├── user.js # User model (bcrypt password, settings)
│ ├── permission.js # Permission/sharing model
│ ├── apiToken.js # API token model
│ ├── recurringCompletion.js
│ ├── taskEvent.js # Task audit log
│ ├── taskAttachment.js
│ ├── inboxItem.js
│ ├── notification.js
│ ├── role.js
│ ├── view.js
│ ├── backup.js
│ ├── setting.js
│ └── action.js
├── migrations/ # Database migrations (64+ files)
│ ├── 20240101120000-initial-schema.js
│ ├── 20240115140000-add-recurring-tasks.js
│ └── ... (timestamped migration files)
├── seeders/ # Database seed data
│ └── (seed files if any)
├── middleware/ # Global middleware
│ ├── auth.js # Authentication (session + Bearer token)
│ ├── authorize.js # Authorization (permission checking)
│ ├── rateLimiter.js # Rate limiting config (5 different limiters)
│ ├── queryLogger.js # Development query logging
│ └── permissionCache.js
├── services/ # Cross-cutting services
│ ├── permissionsService.js # Main permissions service
│ ├── backupService.js # Backup/restore operations
│ ├── emailService.js # Email notifications
│ ├── logService.js # Error logging
│ ├── applyPerms.js # Apply permissions
│ └── permissionsCalculators.js # Permission calculations
├── shared/ # Shared utilities
│ ├── errors/ # Custom error classes
│ │ ├── AppError.js
│ │ ├── NotFoundError.js
│ │ ├── ValidationError.js
│ │ ├── ConflictError.js
│ │ ├── UnauthorizedError.js
│ │ └── ForbiddenError.js
│ ├── middleware/
│ │ └── errorHandler.js # Global error handler
│ └── database/
│ └── BaseRepository.js # Base repository class
├── utils/ # Utility functions
│ ├── uid.js # Generate 15-char unique IDs (nanoid)
│ ├── slug-utils.js # URL slug handling, UID extraction
│ ├── timezone-utils.js # Timezone conversions, date calculations
│ ├── attachment-utils.js # File handling and validation
│ ├── migration-utils.js # Database migration helpers
│ ├── request-utils.js # Request utilities
│ └── notificationPreferences.js
├── config/ # Configuration
│ ├── config.js # Environment-based config
│ ├── database.js # Sequelize database config
│ └── swagger.js # Swagger API schema (30KB)
├── docs/ # API documentation
│ └── swagger/
│ └── (swagger doc files)
├── scripts/ # Utility scripts
│ └── (database management scripts)
└── tests/ # Backend tests
├── unit/ # Unit tests
│ ├── models/
│ │ ├── task.test.js
│ │ ├── project.test.js
│ │ ├── user.test.js
│ │ └── ...
│ ├── middleware/
│ │ ├── auth.test.js
│ │ └── authorize.test.js
│ ├── services/
│ │ ├── permissionsService.test.js
│ │ └── applyPerms.test.js
│ └── utils/
│ ├── timezone-utils.test.js
│ ├── slug-utils.test.js
│ ├── attachment-utils.test.js
│ └── migration-utils.test.js
└── integration/ # Integration tests (47+ test directories)
├── tasks/
│ ├── tasks.test.js
│ ├── subtasks.test.js
│ └── recurring.test.js
├── projects/
├── areas/
├── notes/
├── tags/
├── auth/
├── shares/
└── ...
```
---
## Frontend Structure
```
/Users/chris/c0deLab/ProjectLand/tududi/frontend/
├── index.tsx # React application entry point
│ # - React root initialization
│ # - i18n setup
│ # - Dark mode initialization
│ # - Service worker cleanup
├── App.tsx # Root component (13KB)
│ # - Route definitions
│ # - User authentication check
│ # - Route protection
│ # - Layout wrapper
├── Layout.tsx # Main layout wrapper (21KB)
│ # - Sidebar integration
│ # - Navigation
│ # - Modal management
├── components/ # React components (feature-based)
│ │
│ ├── Task/ # Task-related components
│ │ ├── TasksToday.tsx
│ │ ├── TaskDetails.tsx
│ │ ├── TaskForm.tsx
│ │ ├── TaskItem.tsx
│ │ ├── TaskList.tsx
│ │ ├── TaskFilters.tsx
│ │ ├── SubtaskList.tsx
│ │ └── ...
│ │
│ ├── Project/ # Project components
│ │ ├── ProjectDetails.tsx
│ │ ├── ProjectForm.tsx
│ │ ├── ProjectList.tsx
│ │ ├── ProjectCard.tsx
│ │ └── ...
│ │
│ ├── Area/ # Area components
│ │ ├── AreaDetails.tsx
│ │ ├── AreaForm.tsx
│ │ └── ...
│ │
│ ├── Note/ # Note components
│ │ ├── NoteDetails.tsx
│ │ ├── NoteForm.tsx
│ │ └── ...
│ │
│ ├── Tag/ # Tag components
│ ├── Habits/ # Recurring tasks UI
│ ├── Inbox/ # Inbox management
│ │
│ ├── Calendar/ # Calendar view (27KB)
│ │ └── Calendar.tsx
│ │
│ ├── Sidebar.tsx # Left navigation sidebar
│ ├── Navbar.tsx # Top navigation bar
│ │
│ ├── Metrics/ # Productivity metrics
│ │ └── ...
│ │
│ ├── Notifications/ # Notification system
│ ├── UniversalSearch/ # Search interface
│ │
│ ├── Shared/ # Shared UI components (41 items)
│ │ ├── Modal components
│ │ │ ├── Modal.tsx
│ │ │ ├── ConfirmDialog.tsx
│ │ │ └── ...
│ │ ├── Form inputs
│ │ │ ├── Input.tsx
│ │ │ ├── Select.tsx
│ │ │ ├── DatePicker.tsx
│ │ │ └── ...
│ │ ├── ToastContext.tsx
│ │ ├── LoadingScreen.tsx
│ │ ├── Button.tsx
│ │ ├── Badge.tsx
│ │ └── ...
│ │
│ ├── Admin/ # Admin panel
│ ├── Backup/ # Backup/restore UI
│ ├── Profile/ # User profile settings
│ │ ├── ProfileSettings.tsx
│ │ ├── ApiTokens.tsx
│ │ └── ...
│ ├── Productivity/ # Analytics dashboard
│ └── Login/Register # Auth pages
│ ├── Login.tsx
│ └── Register.tsx
├── store/ # Zustand state management
│ └── useStore.ts # Global store (28KB)
│ # - Task state & cache
│ # - Project state & cache
│ # - UI state (modals, filters, selections)
│ # - Cache management functions
├── contexts/ # React contexts
│ ├── ModalContext.tsx # Modal state management
│ ├── SidebarContext.tsx # Sidebar state
│ └── TelegramStatusContext.tsx # Telegram integration status
├── hooks/ # Custom React hooks
│ ├── useKeyboardShortcuts.ts # Keyboard handling
│ ├── useModalManager.ts # Modal management
│ ├── usePersistedModal.ts # Modal persistence
│ └── useTasksData.ts # Task data fetching
├── utils/ # Frontend utilities (30+ files)
│ ├── API Services (API client utilities)
│ │ ├── tasksService.ts # Task API client
│ │ ├── projectsService.ts # Project API client
│ │ ├── notesService.ts
│ │ ├── tagsService.ts
│ │ ├── areasService.ts
│ │ ├── profileService.ts # User profile API
│ │ ├── apiKeysService.ts # API token management
│ │ ├── searchService.ts # Search API client
│ │ ├── sharesService.ts # Project sharing API
│ │ ├── backupService.ts # Backup/restore API
│ │ ├── inboxService.ts # Inbox API
│ │ ├── habitsService.ts # Habits/recurring API
│ │ ├── taskEventService.ts # Task history API
│ │ ├── taskIntelligenceService.ts # AI-assisted task mgmt
│ │ └── attachmentsService.ts # File attachment handling
│ │
│ ├── Utilities
│ │ ├── dateUtils.ts # Date/time helpers
│ │ ├── timezoneUtils.ts # Timezone handling
│ │ ├── taskSortUtils.ts # Task sorting logic
│ │ ├── localeUtils.ts # i18n helpers
│ │ ├── keyboardShortcutsService.ts # Shortcut definitions
│ │ ├── bannersService.ts # Banner management
│ │ ├── urlService.ts # URL parsing
│ │ ├── slugUtils.ts # URL slug handling
│ │ ├── userUtils.ts # User utilities
│ │ ├── fetcher.ts # SWR fetcher configuration
│ │ └── featureFlags.ts # Feature flag client
│ │
│ └── config/
│ └── paths.ts # API and path configuration
├── entities/ # TypeScript interfaces/types
│ ├── Task.ts # Task type definition
│ ├── Project.ts # Project type definition
│ ├── Note.ts # Note type definition
│ ├── User.ts # User type definition
│ ├── Tag.ts # Tag type definition
│ ├── Area.ts # Area type definition
│ ├── TaskEvent.ts # Task event type
│ ├── Attachment.ts # Attachment type
│ ├── InboxItem.ts # Inbox item type
│ └── Metrics.ts # Metrics type
├── i18n.ts # i18next configuration
│ # - Language detection
│ # - Resource loading
│ # - 24 language support
├── styles/ # Global styles
│ ├── globals.css
│ ├── markdown.css
│ └── ...
└── __tests__/ # Frontend tests
├── setup.ts # Test configuration
└── (component tests)
```
---
## E2E Tests Structure
```
/Users/chris/c0deLab/ProjectLand/tududi/e2e/
├── tests/ # Playwright test specs
│ ├── login.spec.ts
│ ├── tasks.spec.ts
│ ├── projects.spec.ts
│ ├── subtasks.spec.ts
│ ├── recurring-tasks.spec.ts
│ └── ...
└── bin/
└── run-e2e.sh # Test runner script
```
---
## Critical Paths Reference
Quick lookup table for common development tasks:
| Task | Primary Location | Related Files |
|------|------------------|---------------|
| **Add backend feature** | `/backend/modules/[feature]/` | routes.js, repository.js, operations/ |
| **Create new model** | `/backend/models/[model].js` | Also update `/backend/models/index.js` for associations |
| **Database migration** | `/backend/migrations/TIMESTAMP-name.js` | Create with `npm run migration:create` |
| **Add React component** | `/frontend/components/[Feature]/ComponentName.tsx` | - |
| **Define API routes** | `/backend/modules/[module]/routes.js` | - |
| **Business logic** | `/backend/modules/[module]/operations/` | Or service files in module |
| **Global frontend state** | `/frontend/store/useStore.ts` | Zustand store |
| **API client** | `/frontend/utils/[resource]Service.ts` | - |
| **TypeScript types** | `/frontend/entities/[Type].ts` | Interface definitions |
| **Backend unit tests** | `/backend/tests/unit/[category]/` | models/, middleware/, services/, utils/ |
| **Backend integration tests** | `/backend/tests/integration/[module]/` | - |
| **E2E tests** | `/e2e/tests/[feature].spec.ts` | Playwright specs |
| **Middleware** | `/backend/middleware/[name].js` | auth.js, authorize.js, etc. |
| **Shared utilities** | `/backend/utils/` or `/frontend/utils/` | Depends on context |
| **Error classes** | `/backend/shared/errors/` | Custom error types |
| **Swagger docs** | `/backend/config/swagger.js` | API schema definitions |
---
[← Back to Index](../CLAUDE.md)

526
docs/testing.md Normal file
View file

@ -0,0 +1,526 @@
# Testing Requirements
[← Back to Index](../CLAUDE.md)
---
## Test Organization
```
/backend/tests/
├── unit/ # Unit tests for isolated logic
│ ├── models/ # Model tests
│ │ ├── task.test.js
│ │ ├── project.test.js
│ │ ├── user.test.js
│ │ └── ...
│ ├── middleware/ # Middleware tests
│ │ ├── auth.test.js
│ │ └── authorize.test.js
│ ├── services/ # Service tests
│ │ ├── permissionsService.test.js
│ │ ├── applyPerms.test.js
│ │ └── ...
│ └── utils/ # Utility tests
│ ├── timezone-utils.test.js
│ ├── slug-utils.test.js
│ ├── attachment-utils.test.js
│ └── migration-utils.test.js
└── integration/ # Integration tests for API endpoints
├── tasks/
│ ├── tasks.test.js
│ ├── subtasks.test.js
│ └── recurring.test.js
├── projects/
│ └── projects.test.js
├── areas/
├── notes/
├── tags/
├── auth/
├── shares/
└── ... (47+ test directories)
/e2e/tests/ # E2E tests (Playwright)
├── login.spec.ts
├── tasks.spec.ts
├── projects.spec.ts
├── subtasks.spec.ts
└── ...
/frontend/__tests__/ # Frontend tests
├── setup.ts # Test configuration
└── components/
└── ... (component tests)
```
---
## Running Tests
### Backend Tests
```bash
# Run all backend tests
npm test
# or
npm run backend:test
# Run specific test file
npm test -- backend/tests/unit/models/task.test.js
# Run with coverage
npm run test:coverage
# Watch mode (re-run on file changes)
npm run test:watch
```
### Frontend Tests
```bash
# Run frontend tests
npm run frontend:test
# Watch mode
npm run frontend:test -- --watch
```
### E2E Tests
```bash
# Headless mode (default)
npm run test:ui
# Headed mode (see browser)
npm run test:ui:headed
# Specific test file
npx playwright test e2e/tests/tasks.spec.ts
# Debug mode
npx playwright test --debug
```
### Pre-Push Checks
```bash
# Run all checks before committing/pushing
npm run pre-push
# This runs:
# - ESLint checks
# - Prettier formatting
# - Backend tests
# - Type checking (if applicable)
```
---
## Testing Requirements
### For Bug Fixes
**MUST include a test** that would have caught the bug.
**Process:**
1. Write failing test that demonstrates the bug
2. Fix the bug
3. Verify test now passes
4. Submit PR with both test and fix
**Example:**
```javascript
// Test for bug: completed tasks showing in Today view
it('should not return completed tasks in Today view', async () => {
// Arrange - Create completed task
await Task.create({
name: 'Completed Task',
status: 2, // completed
due_date: new Date().toISOString().split('T')[0],
user_id: user.id
});
// Act - Get today's tasks
const response = await request(app)
.get('/api/v1/tasks/today')
.set('Cookie', authCookie);
// Assert - No completed tasks
expect(response.status).toBe(200);
const completedTasks = response.body.filter(t => t.status === 2);
expect(completedTasks.length).toBe(0);
});
```
### For New Features
**SHOULD include relevant tests** covering:
- Happy path (success case)
- Common edge cases
- Error conditions
**Not required to test:**
- Every possible combination
- Framework internals
- Third-party library behavior
---
## Test Patterns
### Backend Integration Test
**Arrange-Act-Assert Pattern:**
```javascript
// /backend/tests/integration/tasks/tasks.test.js
const request = require('supertest');
const app = require('../../../app');
const { Task, User } = require('../../../models');
describe('Task API', () => {
let user;
let authCookie;
beforeEach(async () => {
// Setup: Create user and authenticate
user = await User.create({
email: 'test@example.com',
password: 'password123'
});
const res = await request(app)
.post('/api/login')
.send({ email: 'test@example.com', password: 'password123' });
authCookie = res.headers['set-cookie'];
});
afterEach(async () => {
// Cleanup
await Task.destroy({ where: {} });
await User.destroy({ where: {} });
});
it('should create task with valid data', async () => {
// Arrange
const taskData = {
name: 'Test Task',
priority: 1,
due_date: '2026-03-15'
};
// Act
const response = await request(app)
.post('/api/v1/task')
.set('Cookie', authCookie)
.send(taskData);
// Assert
expect(response.status).toBe(201);
expect(response.body.name).toBe('Test Task');
expect(response.body.priority).toBe(1);
// Verify in database
const task = await Task.findOne({ where: { name: 'Test Task' } });
expect(task).not.toBeNull();
expect(task.user_id).toBe(user.id);
});
it('should return 400 for missing name', async () => {
// Arrange
const invalidData = { priority: 1 };
// Act
const response = await request(app)
.post('/api/v1/task')
.set('Cookie', authCookie)
.send(invalidData);
// Assert
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
});
it('should return 404 for non-existent task', async () => {
// Act
const response = await request(app)
.get('/api/v1/task/99999')
.set('Cookie', authCookie);
// Assert
expect(response.status).toBe(404);
});
});
```
### Backend Unit Test
```javascript
// /backend/tests/unit/utils/timezone-utils.test.js
const { getTodayBoundsInUTC } = require('../../../utils/timezone-utils');
describe('timezone-utils', () => {
describe('getTodayBoundsInUTC', () => {
it('should return UTC bounds for today in given timezone', () => {
// Arrange
const timezone = 'America/New_York';
// Act
const { startOfDay, endOfDay } = getTodayBoundsInUTC(timezone);
// Assert
expect(startOfDay).toBeInstanceOf(Date);
expect(endOfDay).toBeInstanceOf(Date);
expect(endOfDay.getTime()).toBeGreaterThan(startOfDay.getTime());
});
it('should handle invalid timezone gracefully', () => {
// Arrange
const invalidTimezone = 'Invalid/Timezone';
// Act & Assert
expect(() => getTodayBoundsInUTC(invalidTimezone)).not.toThrow();
});
});
});
```
### Frontend Component Test
```typescript
// /frontend/components/Task/__tests__/TaskItem.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { TaskItem } from '../TaskItem';
import { Task } from '../../../entities/Task';
describe('TaskItem', () => {
const mockTask: Task = {
id: 1,
uid: 'test-uid-123',
name: 'Test Task',
completed: false,
priority: 1,
due_date: '2026-03-15'
};
it('renders task name', () => {
// Act
render(<TaskItem task={mockTask} onUpdate={jest.fn()} />);
// Assert
expect(screen.getByText('Test Task')).toBeInTheDocument();
});
it('shows priority badge', () => {
// Act
render(<TaskItem task={mockTask} onUpdate={jest.fn()} />);
// Assert
expect(screen.getByText('Medium')).toBeInTheDocument();
});
it('calls onUpdate when checkbox is clicked', () => {
// Arrange
const mockOnUpdate = jest.fn();
render(<TaskItem task={mockTask} onUpdate={mockOnUpdate} />);
// Act
const checkbox = screen.getByRole('checkbox');
fireEvent.click(checkbox);
// Assert
expect(mockOnUpdate).toHaveBeenCalledWith({
...mockTask,
completed: true
});
});
it('applies completed styling when task is done', () => {
// Arrange
const completedTask = { ...mockTask, completed: true };
// Act
render(<TaskItem task={completedTask} onUpdate={jest.fn()} />);
// Assert
const taskElement = screen.getByText('Test Task').closest('div');
expect(taskElement).toHaveClass('line-through');
expect(taskElement).toHaveClass('opacity-50');
});
});
```
### E2E Test (Playwright)
```typescript
// /e2e/tests/tasks.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Task Management', () => {
test.beforeEach(async ({ page }) => {
// Login before each test
await page.goto('http://localhost:8080/login');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await page.waitForURL('**/tasks');
});
test('should create new task', async ({ page }) => {
// Arrange
await page.click('button:has-text("New Task")');
// Act
await page.fill('input[name="name"]', 'E2E Test Task');
await page.selectOption('select[name="priority"]', '1');
await page.fill('input[name="due_date"]', '2026-03-15');
await page.click('button:has-text("Save")');
// Assert
await expect(page.locator('text=E2E Test Task')).toBeVisible();
});
test('should complete task', async ({ page }) => {
// Arrange - Create a task first
await page.click('button:has-text("New Task")');
await page.fill('input[name="name"]', 'Task to Complete');
await page.click('button:has-text("Save")');
// Act - Complete the task
const taskItem = page.locator('text=Task to Complete').locator('..');
await taskItem.locator('input[type="checkbox"]').check();
// Assert
await expect(taskItem).toHaveClass(/line-through/);
});
test('should filter tasks by priority', async ({ page }) => {
// Arrange - Create tasks with different priorities
await createTask(page, 'High Priority Task', 2);
await createTask(page, 'Low Priority Task', 0);
// Act - Filter by high priority
await page.selectOption('select[name="priority_filter"]', '2');
// Assert
await expect(page.locator('text=High Priority Task')).toBeVisible();
await expect(page.locator('text=Low Priority Task')).not.toBeVisible();
});
});
async function createTask(page, name: string, priority: number) {
await page.click('button:has-text("New Task")');
await page.fill('input[name="name"]', name);
await page.selectOption('select[name="priority"]', priority.toString());
await page.click('button:has-text("Save")');
await page.waitForSelector(`text=${name}`);
}
```
---
## Test Database
Backend tests use a separate test database:
- Automatically created in test environment
- Migrations run before tests
- Database cleared between tests (in `afterEach`)
- Configured in `/backend/config/database.js`
**Example cleanup:**
```javascript
afterEach(async () => {
// Clean up test data
await Task.destroy({ where: {} });
await Project.destroy({ where: {} });
await User.destroy({ where: {} });
});
```
---
## Mocking
### Mock External Services
```javascript
// Mock email service in tests
jest.mock('../../../services/emailService', () => ({
sendEmail: jest.fn().mockResolvedValue(true)
}));
it('should send notification email', async () => {
const emailService = require('../../../services/emailService');
await taskService.create({ name: 'Task', notify: true }, userId);
expect(emailService.sendEmail).toHaveBeenCalled();
});
```
### Mock Frontend API Calls
```typescript
import { rest } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
rest.get('/api/v1/tasks', (req, res, ctx) => {
return res(ctx.json([
{ id: 1, name: 'Mocked Task' }
]));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
```
---
## Coverage Goals
While not strictly enforced, aim for:
- **Critical paths:** 80%+ coverage
- **Business logic:** 70%+ coverage
- **UI components:** 50%+ coverage
**Run coverage report:**
```bash
npm run test:coverage
# Open HTML report
open coverage/index.html
```
---
## Before Submitting PR
✅ All tests passing:
```bash
npm test
npm run test:ui
```
✅ No linting errors:
```bash
npm run lint
```
✅ Code formatted:
```bash
npm run format:fix
```
✅ Run pre-push checks:
```bash
npm run pre-push
```
---
[← Back to Index](../CLAUDE.md)