mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
fix(todo-backend): implement recurrence handling and fix N+1 query
- Implement recurring task handling using rrule library - createNextOccurrence() creates next task instance when completing recurring task - calculateNextStartDate() maintains offset between start/due dates - Copies labels, subtasks (reset), and metadata to new occurrence - Respects recurrenceEndDate limit - Fix N+1 query problem for task labels - Replace individual loadTaskLabels() calls with batch loadTaskLabelsBatch() - Reduces database queries from O(2n) to O(2) for task lists - Uses Maps for O(1) lookups when combining tasks with labels - Add Jest test coverage (25 tests) - CRUD operations, task status changes, recurrence - Special queries (inbox, today, completed) - Batch loading efficiency verification 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
59324cae1c
commit
d8f1bbbbce
5 changed files with 1123 additions and 248 deletions
16
apps/todo/apps/backend/jest.config.js
Normal file
16
apps/todo/apps/backend/jest.config.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
rootDir: 'src',
|
||||
testRegex: '.*\\.spec\\.ts$',
|
||||
transform: {
|
||||
'^.+\\.(t|j)s$': 'ts-jest',
|
||||
},
|
||||
collectCoverageFrom: ['**/*.(t|j)s'],
|
||||
coverageDirectory: '../coverage',
|
||||
testEnvironment: 'node',
|
||||
moduleNameMapper: {
|
||||
'^@todo/shared$': '<rootDir>/../../packages/shared/src',
|
||||
'^@manacore/shared-nestjs-auth$': '<rootDir>/../../../../../packages/shared-nestjs-auth/src',
|
||||
},
|
||||
};
|
||||
|
|
@ -9,33 +9,41 @@
|
|||
"start": "nest start",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "tsx src/db/seed.ts",
|
||||
"db:generate": "drizzle-kit generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@todo/shared": "workspace:*",
|
||||
"@manacore/shared-nestjs-auth": "workspace:*",
|
||||
"@nestjs/common": "^10.4.9",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.9",
|
||||
"@nestjs/platform-express": "^10.4.9",
|
||||
"@nestjs/schedule": "^4.1.2",
|
||||
"@todo/shared": "workspace:*",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"postgres": "^3.4.5",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rrule": "^2.8.1",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@nestjs/testing": "^11.1.9",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.15.21",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"jest": "^30.2.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
|
|
|||
480
apps/todo/apps/backend/src/task/__tests__/task.service.spec.ts
Normal file
480
apps/todo/apps/backend/src/task/__tests__/task.service.spec.ts
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { TaskService } from '../task.service';
|
||||
import { ProjectService } from '../../project/project.service';
|
||||
import { DATABASE_CONNECTION } from '../../db/database.module';
|
||||
|
||||
// Mock database
|
||||
const mockDb = {
|
||||
query: {
|
||||
tasks: {
|
||||
findMany: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
taskLabels: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
labels: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock ProjectService
|
||||
const mockProjectService = {
|
||||
findByIdOrThrow: jest.fn(),
|
||||
};
|
||||
|
||||
describe('TaskService', () => {
|
||||
let service: TaskService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
TaskService,
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useValue: mockDb,
|
||||
},
|
||||
{
|
||||
provide: ProjectService,
|
||||
useValue: mockProjectService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<TaskService>(TaskService);
|
||||
|
||||
// Reset all mocks before each test
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return all tasks for a user', async () => {
|
||||
const userId = 'user-123';
|
||||
const mockTasks = [
|
||||
{ id: 'task-1', title: 'Task 1', userId },
|
||||
{ id: 'task-2', title: 'Task 2', userId },
|
||||
];
|
||||
|
||||
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.findAll(userId);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].labels).toEqual([]);
|
||||
expect(result[1].labels).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter by projectId when provided', async () => {
|
||||
const userId = 'user-123';
|
||||
const projectId = 'project-1';
|
||||
|
||||
mockDb.query.tasks.findMany.mockResolvedValue([]);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.findAll(userId, { projectId });
|
||||
|
||||
expect(mockDb.query.tasks.findMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should filter by priority when provided', async () => {
|
||||
const userId = 'user-123';
|
||||
|
||||
mockDb.query.tasks.findMany.mockResolvedValue([]);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.findAll(userId, { priority: 'high' });
|
||||
|
||||
expect(mockDb.query.tasks.findMany).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return a task when found', async () => {
|
||||
const userId = 'user-123';
|
||||
const taskId = 'task-1';
|
||||
const mockTask = { id: taskId, title: 'Test Task', userId };
|
||||
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(mockTask);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.findById(taskId, userId);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.id).toBe(taskId);
|
||||
expect(result?.labels).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return null when task not found', async () => {
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(null);
|
||||
|
||||
const result = await service.findById('non-existent', 'user-123');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByIdOrThrow', () => {
|
||||
it('should return a task when found', async () => {
|
||||
const userId = 'user-123';
|
||||
const taskId = 'task-1';
|
||||
const mockTask = { id: taskId, title: 'Test Task', userId };
|
||||
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(mockTask);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.findByIdOrThrow(taskId, userId);
|
||||
|
||||
expect(result.id).toBe(taskId);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when task not found', async () => {
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findByIdOrThrow('non-existent', 'user-123')).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a task with basic fields', async () => {
|
||||
const userId = 'user-123';
|
||||
const dto = { title: 'New Task' };
|
||||
const createdTask = { id: 'task-new', title: 'New Task', userId, order: 0 };
|
||||
|
||||
mockDb.query.tasks.findMany.mockResolvedValue([]);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
mockDb.returning.mockResolvedValue([createdTask]);
|
||||
|
||||
const result = await service.create(userId, dto);
|
||||
|
||||
expect(result.title).toBe('New Task');
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should verify project belongs to user when projectId is provided', async () => {
|
||||
const userId = 'user-123';
|
||||
const projectId = 'project-1';
|
||||
const dto = { title: 'New Task', projectId };
|
||||
const createdTask = { id: 'task-new', title: 'New Task', userId, projectId, order: 0 };
|
||||
|
||||
mockProjectService.findByIdOrThrow.mockResolvedValue({ id: projectId, userId });
|
||||
mockDb.query.tasks.findMany.mockResolvedValue([]);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
mockDb.returning.mockResolvedValue([createdTask]);
|
||||
|
||||
await service.create(userId, dto);
|
||||
|
||||
expect(mockProjectService.findByIdOrThrow).toHaveBeenCalledWith(projectId, userId);
|
||||
});
|
||||
|
||||
it('should calculate order based on existing tasks', async () => {
|
||||
const userId = 'user-123';
|
||||
const dto = { title: 'New Task' };
|
||||
const existingTasks = [
|
||||
{ id: 'task-1', order: 0 },
|
||||
{ id: 'task-2', order: 1 },
|
||||
{ id: 'task-3', order: 2 },
|
||||
];
|
||||
const createdTask = { id: 'task-new', title: 'New Task', userId, order: 3 };
|
||||
|
||||
mockDb.query.tasks.findMany.mockResolvedValue(existingTasks);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
mockDb.returning.mockResolvedValue([createdTask]);
|
||||
|
||||
const result = await service.create(userId, dto);
|
||||
|
||||
expect(result.order).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update a task', async () => {
|
||||
const userId = 'user-123';
|
||||
const taskId = 'task-1';
|
||||
const dto = { title: 'Updated Title' };
|
||||
const existingTask = { id: taskId, title: 'Original', userId };
|
||||
const updatedTask = { id: taskId, title: 'Updated Title', userId };
|
||||
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
mockDb.returning.mockResolvedValue([updatedTask]);
|
||||
|
||||
const result = await service.update(taskId, userId, dto);
|
||||
|
||||
expect(result.title).toBe('Updated Title');
|
||||
});
|
||||
|
||||
it('should throw when task does not exist', async () => {
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update('non-existent', 'user-123', { title: 'Test' })).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete a task', async () => {
|
||||
const userId = 'user-123';
|
||||
const taskId = 'task-1';
|
||||
const existingTask = { id: taskId, userId };
|
||||
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.delete(taskId, userId);
|
||||
|
||||
expect(mockDb.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw when task does not exist', async () => {
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.delete('non-existent', 'user-123')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complete', () => {
|
||||
it('should mark a task as completed', async () => {
|
||||
const userId = 'user-123';
|
||||
const taskId = 'task-1';
|
||||
const existingTask = { id: taskId, title: 'Test', userId, recurrenceRule: null };
|
||||
const completedTask = { ...existingTask, isCompleted: true, status: 'completed' };
|
||||
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
mockDb.returning.mockResolvedValue([completedTask]);
|
||||
|
||||
const result = await service.complete(taskId, userId);
|
||||
|
||||
expect(result.isCompleted).toBe(true);
|
||||
expect(result.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('should create next occurrence for recurring task', async () => {
|
||||
const userId = 'user-123';
|
||||
const taskId = 'task-1';
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const existingTask = {
|
||||
id: taskId,
|
||||
title: 'Daily Task',
|
||||
userId,
|
||||
recurrenceRule: 'FREQ=DAILY',
|
||||
dueDate: new Date(),
|
||||
labels: [],
|
||||
};
|
||||
|
||||
const completedTask = {
|
||||
...existingTask,
|
||||
isCompleted: true,
|
||||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
lastOccurrence: new Date(),
|
||||
};
|
||||
|
||||
const newTask = {
|
||||
id: 'task-new',
|
||||
title: 'Daily Task',
|
||||
userId,
|
||||
recurrenceRule: 'FREQ=DAILY',
|
||||
dueDate: tomorrow,
|
||||
isCompleted: false,
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
// First call for findByIdOrThrow
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
|
||||
// For completing the task
|
||||
mockDb.returning
|
||||
.mockResolvedValueOnce([newTask]) // For creating new occurrence
|
||||
.mockResolvedValueOnce([completedTask]); // For completing original
|
||||
|
||||
const result = await service.complete(taskId, userId);
|
||||
|
||||
expect(result.isCompleted).toBe(true);
|
||||
// Verify that a new task was created
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uncomplete', () => {
|
||||
it('should mark a task as not completed', async () => {
|
||||
const userId = 'user-123';
|
||||
const taskId = 'task-1';
|
||||
const existingTask = { id: taskId, title: 'Test', userId, isCompleted: true };
|
||||
const uncompletedTask = { ...existingTask, isCompleted: false, status: 'pending' };
|
||||
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
mockDb.returning.mockResolvedValue([uncompletedTask]);
|
||||
|
||||
const result = await service.uncomplete(taskId, userId);
|
||||
|
||||
expect(result.isCompleted).toBe(false);
|
||||
expect(result.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('move', () => {
|
||||
it('should move a task to a different project', async () => {
|
||||
const userId = 'user-123';
|
||||
const taskId = 'task-1';
|
||||
const newProjectId = 'project-2';
|
||||
const existingTask = { id: taskId, title: 'Test', userId, projectId: 'project-1' };
|
||||
const movedTask = { ...existingTask, projectId: newProjectId };
|
||||
|
||||
mockProjectService.findByIdOrThrow.mockResolvedValue({ id: newProjectId, userId });
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
|
||||
mockDb.query.tasks.findMany.mockResolvedValue([]);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
mockDb.returning.mockResolvedValue([movedTask]);
|
||||
|
||||
const result = await service.move(taskId, userId, newProjectId);
|
||||
|
||||
expect(result.projectId).toBe(newProjectId);
|
||||
expect(mockProjectService.findByIdOrThrow).toHaveBeenCalledWith(newProjectId, userId);
|
||||
});
|
||||
|
||||
it('should move a task to inbox (null project)', async () => {
|
||||
const userId = 'user-123';
|
||||
const taskId = 'task-1';
|
||||
const existingTask = { id: taskId, title: 'Test', userId, projectId: 'project-1' };
|
||||
const movedTask = { ...existingTask, projectId: null };
|
||||
|
||||
mockDb.query.tasks.findFirst.mockResolvedValue(existingTask);
|
||||
mockDb.query.tasks.findMany.mockResolvedValue([]);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
mockDb.returning.mockResolvedValue([movedTask]);
|
||||
|
||||
const result = await service.move(taskId, userId, null);
|
||||
|
||||
expect(result.projectId).toBeNull();
|
||||
expect(mockProjectService.findByIdOrThrow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInboxTasks', () => {
|
||||
it('should return incomplete tasks', async () => {
|
||||
const userId = 'user-123';
|
||||
const mockTasks = [
|
||||
{ id: 'task-1', title: 'Task 1', userId, isCompleted: false },
|
||||
{ id: 'task-2', title: 'Task 2', userId, isCompleted: false },
|
||||
];
|
||||
|
||||
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getInboxTasks(userId);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((t) => t.isCompleted === false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTodayTasks', () => {
|
||||
it('should return tasks due today', async () => {
|
||||
const userId = 'user-123';
|
||||
const today = new Date();
|
||||
const mockTasks = [{ id: 'task-1', title: 'Today Task', userId, dueDate: today }];
|
||||
|
||||
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getTodayTasks(userId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCompletedTasks', () => {
|
||||
it('should return completed tasks with default limit', async () => {
|
||||
const userId = 'user-123';
|
||||
const mockTasks = Array(50)
|
||||
.fill(null)
|
||||
.map((_, i) => ({
|
||||
id: `task-${i}`,
|
||||
title: `Task ${i}`,
|
||||
userId,
|
||||
isCompleted: true,
|
||||
}));
|
||||
|
||||
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getCompletedTasks(userId);
|
||||
|
||||
expect(result).toHaveLength(50);
|
||||
});
|
||||
|
||||
it('should respect custom limit', async () => {
|
||||
const userId = 'user-123';
|
||||
const mockTasks = Array(10)
|
||||
.fill(null)
|
||||
.map((_, i) => ({
|
||||
id: `task-${i}`,
|
||||
title: `Task ${i}`,
|
||||
userId,
|
||||
isCompleted: true,
|
||||
}));
|
||||
|
||||
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getCompletedTasks(userId, 10);
|
||||
|
||||
expect(result).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadTaskLabelsBatch', () => {
|
||||
it('should batch load labels for multiple tasks', async () => {
|
||||
const userId = 'user-123';
|
||||
const mockTasks = [
|
||||
{ id: 'task-1', title: 'Task 1', userId },
|
||||
{ id: 'task-2', title: 'Task 2', userId },
|
||||
];
|
||||
|
||||
const mockTaskLabels = [
|
||||
{ taskId: 'task-1', labelId: 'label-1' },
|
||||
{ taskId: 'task-1', labelId: 'label-2' },
|
||||
{ taskId: 'task-2', labelId: 'label-1' },
|
||||
];
|
||||
|
||||
const mockLabels = [
|
||||
{ id: 'label-1', name: 'Important', color: '#ff0000' },
|
||||
{ id: 'label-2', name: 'Work', color: '#0000ff' },
|
||||
];
|
||||
|
||||
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
|
||||
mockDb.query.taskLabels.findMany.mockResolvedValue(mockTaskLabels);
|
||||
mockDb.query.labels.findMany.mockResolvedValue(mockLabels);
|
||||
|
||||
const result = await service.findAll(userId);
|
||||
|
||||
expect(result[0].labels).toHaveLength(2);
|
||||
expect(result[1].labels).toHaveLength(1);
|
||||
// Should only make 2 queries for labels (taskLabels + labels), not N+1
|
||||
expect(mockDb.query.taskLabels.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(mockDb.query.labels.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL } from 'drizzle-orm';
|
||||
import { RRule, RRuleSet, rrulestr } from 'rrule';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { tasks, taskLabels, labels, type Task, type NewTask, type Subtask } from '../db/schema';
|
||||
import { ProjectService } from '../project/project.service';
|
||||
import { CreateTaskDto, UpdateTaskDto, QueryTasksDto } from './dto';
|
||||
|
||||
// Extended Task type that includes labels (populated after loading from DB)
|
||||
type TaskWithLabels = Task & { labels: (typeof labels.$inferSelect)[] };
|
||||
|
||||
@Injectable()
|
||||
export class TaskService {
|
||||
constructor(
|
||||
|
|
@ -13,7 +17,7 @@ export class TaskService {
|
|||
private projectService: ProjectService
|
||||
) {}
|
||||
|
||||
async findAll(userId: string, query: QueryTasksDto = {}): Promise<Task[]> {
|
||||
async findAll(userId: string, query: QueryTasksDto = {}): Promise<TaskWithLabels[]> {
|
||||
const conditions: SQL[] = [eq(tasks.userId, userId)];
|
||||
|
||||
if (query.projectId) {
|
||||
|
|
@ -73,11 +77,11 @@ export class TaskService {
|
|||
offset: query.offset,
|
||||
});
|
||||
|
||||
// Load labels for each task
|
||||
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
|
||||
// Batch load labels for all tasks (2 queries instead of N+1)
|
||||
return this.loadTaskLabelsBatch(result);
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<Task | null> {
|
||||
async findById(id: string, userId: string): Promise<TaskWithLabels | null> {
|
||||
const result = await this.db.query.tasks.findFirst({
|
||||
where: and(eq(tasks.id, id), eq(tasks.userId, userId)),
|
||||
});
|
||||
|
|
@ -86,7 +90,7 @@ export class TaskService {
|
|||
return this.loadTaskLabels(result);
|
||||
}
|
||||
|
||||
async findByIdOrThrow(id: string, userId: string): Promise<Task> {
|
||||
async findByIdOrThrow(id: string, userId: string): Promise<TaskWithLabels> {
|
||||
const task = await this.findById(id, userId);
|
||||
if (!task) {
|
||||
throw new NotFoundException(`Task with id ${id} not found`);
|
||||
|
|
@ -94,7 +98,7 @@ export class TaskService {
|
|||
return task;
|
||||
}
|
||||
|
||||
async create(userId: string, dto: CreateTaskDto): Promise<Task> {
|
||||
async create(userId: string, dto: CreateTaskDto): Promise<TaskWithLabels> {
|
||||
// Verify project belongs to user if provided
|
||||
if (dto.projectId) {
|
||||
await this.projectService.findByIdOrThrow(dto.projectId, userId);
|
||||
|
|
@ -139,7 +143,7 @@ export class TaskService {
|
|||
return this.loadTaskLabels(created);
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, dto: UpdateTaskDto): Promise<Task> {
|
||||
async update(id: string, userId: string, dto: UpdateTaskDto): Promise<TaskWithLabels> {
|
||||
await this.findByIdOrThrow(id, userId);
|
||||
|
||||
// Verify project belongs to user if changing project
|
||||
|
|
@ -185,13 +189,28 @@ export class TaskService {
|
|||
await this.db.delete(tasks).where(and(eq(tasks.id, id), eq(tasks.userId, userId)));
|
||||
}
|
||||
|
||||
async complete(id: string, userId: string): Promise<Task> {
|
||||
async complete(id: string, userId: string): Promise<TaskWithLabels> {
|
||||
const task = await this.findByIdOrThrow(id, userId);
|
||||
|
||||
// If task has recurrence, create next occurrence instead of completing
|
||||
if (task.recurrenceRule) {
|
||||
// TODO: Implement recurrence handling
|
||||
// For now, just mark as complete
|
||||
const nextOccurrence = await this.createNextOccurrence(task, userId);
|
||||
if (nextOccurrence) {
|
||||
// Mark current task as completed and update lastOccurrence
|
||||
const [completed] = await this.db
|
||||
.update(tasks)
|
||||
.set({
|
||||
isCompleted: true,
|
||||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
lastOccurrence: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(tasks.id, id), eq(tasks.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return this.loadTaskLabels(completed);
|
||||
}
|
||||
}
|
||||
|
||||
return this.update(id, userId, {
|
||||
|
|
@ -200,14 +219,113 @@ export class TaskService {
|
|||
});
|
||||
}
|
||||
|
||||
async uncomplete(id: string, userId: string): Promise<Task> {
|
||||
/**
|
||||
* Creates the next occurrence of a recurring task based on its RRULE.
|
||||
* Returns the newly created task, or null if no more occurrences should be created.
|
||||
*/
|
||||
private async createNextOccurrence(
|
||||
task: TaskWithLabels,
|
||||
userId: string
|
||||
): Promise<TaskWithLabels | null> {
|
||||
if (!task.recurrenceRule) return null;
|
||||
|
||||
try {
|
||||
// Parse the RRULE string
|
||||
const rule = rrulestr(task.recurrenceRule);
|
||||
const now = new Date();
|
||||
|
||||
// Get the next occurrence after now
|
||||
const nextDate = rule.after(now, false);
|
||||
|
||||
// Check if we've exceeded the recurrence end date
|
||||
if (task.recurrenceEndDate) {
|
||||
const endDate = new Date(task.recurrenceEndDate);
|
||||
if (!nextDate || nextDate > endDate) {
|
||||
return null; // No more occurrences
|
||||
}
|
||||
}
|
||||
|
||||
if (!nextDate) {
|
||||
return null; // No more occurrences according to RRULE
|
||||
}
|
||||
|
||||
// Reset subtasks (mark all as incomplete)
|
||||
const resetSubtasks: Subtask[] | undefined = task.subtasks?.map((s) => ({
|
||||
...s,
|
||||
isCompleted: false,
|
||||
completedAt: null,
|
||||
}));
|
||||
|
||||
// Create new task for the next occurrence
|
||||
const newTask: NewTask = {
|
||||
userId,
|
||||
projectId: task.projectId,
|
||||
parentTaskId: task.parentTaskId,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
dueDate: nextDate,
|
||||
dueTime: task.dueTime,
|
||||
startDate: task.startDate
|
||||
? this.calculateNextStartDate(task.startDate, task.dueDate, nextDate)
|
||||
: null,
|
||||
priority: task.priority ?? 'medium',
|
||||
status: 'pending',
|
||||
isCompleted: false,
|
||||
recurrenceRule: task.recurrenceRule,
|
||||
recurrenceEndDate: task.recurrenceEndDate,
|
||||
subtasks: resetSubtasks,
|
||||
metadata: task.metadata,
|
||||
order: task.order,
|
||||
columnId: task.columnId,
|
||||
columnOrder: task.columnOrder,
|
||||
};
|
||||
|
||||
const [created] = await this.db.insert(tasks).values(newTask).returning();
|
||||
|
||||
// Copy labels from original task
|
||||
if (task.labels && task.labels.length > 0) {
|
||||
await this.db.insert(taskLabels).values(
|
||||
task.labels.map((label) => ({
|
||||
taskId: created.id,
|
||||
labelId: label.id,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return this.loadTaskLabels(created);
|
||||
} catch (error) {
|
||||
// If RRULE parsing fails, log and return null
|
||||
console.error('Failed to parse recurrence rule:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the new start date based on the offset between original start and due dates.
|
||||
*/
|
||||
private calculateNextStartDate(
|
||||
originalStartDate: Date | string | null,
|
||||
originalDueDate: Date | string | null,
|
||||
nextDueDate: Date
|
||||
): Date | null {
|
||||
if (!originalStartDate || !originalDueDate) return null;
|
||||
|
||||
const start = new Date(originalStartDate);
|
||||
const due = new Date(originalDueDate);
|
||||
const diffMs = due.getTime() - start.getTime();
|
||||
|
||||
// New start date maintains the same offset from the new due date
|
||||
return new Date(nextDueDate.getTime() - diffMs);
|
||||
}
|
||||
|
||||
async uncomplete(id: string, userId: string): Promise<TaskWithLabels> {
|
||||
return this.update(id, userId, {
|
||||
isCompleted: false,
|
||||
status: 'pending',
|
||||
});
|
||||
}
|
||||
|
||||
async move(id: string, userId: string, projectId: string | null): Promise<Task> {
|
||||
async move(id: string, userId: string, projectId: string | null): Promise<TaskWithLabels> {
|
||||
// Verify new project if provided
|
||||
if (projectId) {
|
||||
await this.projectService.findByIdOrThrow(projectId, userId);
|
||||
|
|
@ -247,11 +365,11 @@ export class TaskService {
|
|||
}
|
||||
}
|
||||
|
||||
async getInboxTasks(userId: string): Promise<Task[]> {
|
||||
async getInboxTasks(userId: string): Promise<TaskWithLabels[]> {
|
||||
return this.findAll(userId, { isCompleted: false });
|
||||
}
|
||||
|
||||
async getTodayTasks(userId: string): Promise<Task[]> {
|
||||
async getTodayTasks(userId: string): Promise<TaskWithLabels[]> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
|
|
@ -270,10 +388,10 @@ export class TaskService {
|
|||
orderBy: [asc(tasks.dueDate), asc(tasks.order)],
|
||||
});
|
||||
|
||||
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
|
||||
return this.loadTaskLabelsBatch(result);
|
||||
}
|
||||
|
||||
async getUpcomingTasks(userId: string, days: number = 7): Promise<Task[]> {
|
||||
async getUpcomingTasks(userId: string, days: number = 7): Promise<TaskWithLabels[]> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const endDate = new Date(today);
|
||||
|
|
@ -289,20 +407,24 @@ export class TaskService {
|
|||
orderBy: [asc(tasks.dueDate), asc(tasks.order)],
|
||||
});
|
||||
|
||||
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
|
||||
return this.loadTaskLabelsBatch(result);
|
||||
}
|
||||
|
||||
async getCompletedTasks(userId: string, limit: number = 50): Promise<Task[]> {
|
||||
async getCompletedTasks(userId: string, limit: number = 50): Promise<TaskWithLabels[]> {
|
||||
const result = await this.db.query.tasks.findMany({
|
||||
where: and(eq(tasks.userId, userId), eq(tasks.isCompleted, true)),
|
||||
orderBy: [desc(tasks.completedAt)],
|
||||
limit,
|
||||
});
|
||||
|
||||
return Promise.all(result.map((task) => this.loadTaskLabels(task)));
|
||||
return this.loadTaskLabelsBatch(result);
|
||||
}
|
||||
|
||||
async reorder(userId: string, taskIds: string[], projectId?: string | null): Promise<Task[]> {
|
||||
async reorder(
|
||||
userId: string,
|
||||
taskIds: string[],
|
||||
projectId?: string | null
|
||||
): Promise<TaskWithLabels[]> {
|
||||
// Update order for each task
|
||||
const updates = taskIds.map((id, index) =>
|
||||
this.db
|
||||
|
|
@ -316,22 +438,66 @@ export class TaskService {
|
|||
return this.findAll(userId, { projectId: projectId ?? undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads labels for a single task (used for single task operations).
|
||||
* For multiple tasks, use loadTaskLabelsBatch instead.
|
||||
*/
|
||||
private async loadTaskLabels(
|
||||
task: Task
|
||||
): Promise<Task & { labels: (typeof labels.$inferSelect)[] }> {
|
||||
const taskLabelRows = await this.db.query.taskLabels.findMany({
|
||||
where: eq(taskLabels.taskId, task.id),
|
||||
});
|
||||
const [result] = await this.loadTaskLabelsBatch([task]);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (taskLabelRows.length === 0) {
|
||||
return { ...task, labels: [] };
|
||||
/**
|
||||
* Batch loads labels for multiple tasks in just 2 queries (instead of N+1).
|
||||
* This significantly improves performance when loading task lists.
|
||||
*/
|
||||
private async loadTaskLabelsBatch(
|
||||
taskList: Task[]
|
||||
): Promise<(Task & { labels: (typeof labels.$inferSelect)[] })[]> {
|
||||
if (taskList.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const labelIds = taskLabelRows.map((tl) => tl.labelId);
|
||||
const taskLabelsData = await this.db.query.labels.findMany({
|
||||
where: or(...labelIds.map((id) => eq(labels.id, id))),
|
||||
const taskIds = taskList.map((t) => t.id);
|
||||
|
||||
// Single query to get all task-label relationships
|
||||
const allTaskLabels = await this.db.query.taskLabels.findMany({
|
||||
where: or(...taskIds.map((id) => eq(taskLabels.taskId, id))),
|
||||
});
|
||||
|
||||
return { ...task, labels: taskLabelsData };
|
||||
if (allTaskLabels.length === 0) {
|
||||
// No labels for any task - return tasks with empty labels array
|
||||
return taskList.map((task) => ({ ...task, labels: [] }));
|
||||
}
|
||||
|
||||
// Get unique label IDs
|
||||
const uniqueLabelIds = [...new Set(allTaskLabels.map((tl) => tl.labelId))];
|
||||
|
||||
// Single query to get all labels
|
||||
const allLabels = await this.db.query.labels.findMany({
|
||||
where: or(...uniqueLabelIds.map((id) => eq(labels.id, id))),
|
||||
});
|
||||
|
||||
// Create a map of labelId -> label for fast lookup
|
||||
const labelMap = new Map(allLabels.map((l) => [l.id, l]));
|
||||
|
||||
// Create a map of taskId -> labelIds for fast lookup
|
||||
const taskLabelMap = new Map<string, string[]>();
|
||||
for (const tl of allTaskLabels) {
|
||||
const existing = taskLabelMap.get(tl.taskId) || [];
|
||||
existing.push(tl.labelId);
|
||||
taskLabelMap.set(tl.taskId, existing);
|
||||
}
|
||||
|
||||
// Combine tasks with their labels
|
||||
return taskList.map((task) => {
|
||||
const labelIds = taskLabelMap.get(task.id) || [];
|
||||
const taskLabelsData = labelIds
|
||||
.map((id) => labelMap.get(id))
|
||||
.filter((l): l is typeof labels.$inferSelect => l !== undefined);
|
||||
return { ...task, labels: taskLabelsData };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
641
pnpm-lock.yaml
generated
641
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue