diff --git a/apps/todo/apps/backend/src/task/__tests__/task.service.spec.ts b/apps/todo/apps/backend/src/task/__tests__/task.service.spec.ts index 3b5ca9a5b..b5d9756fd 100644 --- a/apps/todo/apps/backend/src/task/__tests__/task.service.spec.ts +++ b/apps/todo/apps/backend/src/task/__tests__/task.service.spec.ts @@ -5,6 +5,9 @@ import { ProjectService } from '../../project/project.service'; import { DATABASE_CONNECTION } from '../../db/database.module'; // Mock database +const mockSelectFrom = jest.fn().mockReturnThis(); +const mockSelectWhere = jest.fn(); + const mockDb = { query: { tasks: { @@ -18,6 +21,10 @@ const mockDb = { findMany: jest.fn(), }, }, + select: jest.fn().mockReturnValue({ + from: mockSelectFrom, + where: mockSelectWhere, + }), insert: jest.fn().mockReturnThis(), update: jest.fn().mockReturnThis(), delete: jest.fn().mockReturnThis(), @@ -406,7 +413,7 @@ describe('TaskService', () => { }); describe('getCompletedTasks', () => { - it('should return completed tasks with default limit', async () => { + it('should return completed tasks with pagination info', async () => { const userId = 'user-123'; const mockTasks = Array(50) .fill(null) @@ -419,13 +426,16 @@ describe('TaskService', () => { mockDb.query.tasks.findMany.mockResolvedValue(mockTasks); mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockSelectWhere.mockResolvedValue([{ count: 75 }]); const result = await service.getCompletedTasks(userId); - expect(result).toHaveLength(50); + expect(result.tasks).toHaveLength(50); + expect(result.total).toBe(75); + expect(result.hasMore).toBe(true); }); - it('should respect custom limit', async () => { + it('should respect custom limit and offset', async () => { const userId = 'user-123'; const mockTasks = Array(10) .fill(null) @@ -438,10 +448,35 @@ describe('TaskService', () => { mockDb.query.tasks.findMany.mockResolvedValue(mockTasks); mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockSelectWhere.mockResolvedValue([{ count: 25 }]); - const result = await service.getCompletedTasks(userId, 10); + const result = await service.getCompletedTasks(userId, 10, 10); - expect(result).toHaveLength(10); + expect(result.tasks).toHaveLength(10); + expect(result.total).toBe(25); + expect(result.hasMore).toBe(true); // offset 10 + limit 10 = 20 < 25 + }); + + it('should enforce max limit of 100', async () => { + const userId = 'user-123'; + const mockTasks = Array(100) + .fill(null) + .map((_, i) => ({ + id: `task-${i}`, + title: `Task ${i}`, + userId, + isCompleted: true, + })); + + mockDb.query.tasks.findMany.mockResolvedValue(mockTasks); + mockDb.query.taskLabels.findMany.mockResolvedValue([]); + mockSelectWhere.mockResolvedValue([{ count: 200 }]); + + // Request 500 tasks, should be capped at 100 + const result = await service.getCompletedTasks(userId, 500, 0); + + expect(result.tasks).toHaveLength(100); + expect(result.hasMore).toBe(true); }); }); diff --git a/apps/todo/apps/backend/src/task/dto/create-task.dto.ts b/apps/todo/apps/backend/src/task/dto/create-task.dto.ts index f266923d4..2cebd0bdd 100644 --- a/apps/todo/apps/backend/src/task/dto/create-task.dto.ts +++ b/apps/todo/apps/backend/src/task/dto/create-task.dto.ts @@ -4,13 +4,16 @@ import { IsUUID, IsEnum, IsArray, - IsObject, MaxLength, MinLength, IsDateString, IsNotEmpty, + ValidateNested, } from 'class-validator'; -import type { TaskPriority, Subtask, TaskMetadata } from '../../db/schema/tasks.schema'; +import { Type } from 'class-transformer'; +import type { TaskPriority } from '../../db/schema/tasks.schema'; +import { CreateSubtaskDto } from './subtask.dto'; +import { TaskMetadataDto } from './metadata.dto'; export class CreateTaskDto { @IsString() @@ -58,7 +61,9 @@ export class CreateTaskDto { @IsOptional() @IsArray() - subtasks?: Omit[]; + @ValidateNested({ each: true }) + @Type(() => CreateSubtaskDto) + subtasks?: CreateSubtaskDto[]; @IsOptional() @IsArray() @@ -66,6 +71,7 @@ export class CreateTaskDto { labelIds?: string[]; @IsOptional() - @IsObject() - metadata?: TaskMetadata; + @ValidateNested() + @Type(() => TaskMetadataDto) + metadata?: TaskMetadataDto; } diff --git a/apps/todo/apps/backend/src/task/dto/metadata.dto.ts b/apps/todo/apps/backend/src/task/dto/metadata.dto.ts new file mode 100644 index 000000000..f6ba27ba8 --- /dev/null +++ b/apps/todo/apps/backend/src/task/dto/metadata.dto.ts @@ -0,0 +1,58 @@ +import { + IsString, + IsOptional, + IsNumber, + IsArray, + IsUUID, + IsEnum, + Min, + Max, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class EffectiveDurationDto { + @IsNumber() + @Min(1, { message: 'Dauer muss mindestens 1 sein' }) + @Max(9999, { message: 'Dauer darf maximal 9999 sein' }) + value: number; + + @IsEnum(['minutes', 'hours', 'days'], { message: 'Ungültige Zeiteinheit' }) + unit: 'minutes' | 'hours' | 'days'; +} + +export class TaskMetadataDto { + @IsOptional() + @IsString() + @MaxLength(10000, { message: 'Notizen dürfen maximal 10000 Zeichen haben' }) + notes?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @MaxLength(500, { each: true }) + attachments?: string[]; + + @IsOptional() + @IsUUID() + linkedCalendarEventId?: string | null; + + @IsOptional() + @IsNumber() + @IsEnum([1, 2, 3, 5, 8, 13, 21], { + message: 'Storypoints müssen Fibonacci-Zahlen sein (1,2,3,5,8,13,21)', + }) + storyPoints?: number | null; + + @IsOptional() + @ValidateNested() + @Type(() => EffectiveDurationDto) + effectiveDuration?: EffectiveDurationDto | null; + + @IsOptional() + @IsNumber() + @Min(1, { message: 'Spaß-Faktor muss mindestens 1 sein' }) + @Max(10, { message: 'Spaß-Faktor darf maximal 10 sein' }) + funRating?: number | null; +} diff --git a/apps/todo/apps/backend/src/task/dto/subtask.dto.ts b/apps/todo/apps/backend/src/task/dto/subtask.dto.ts new file mode 100644 index 000000000..1cdce1a78 --- /dev/null +++ b/apps/todo/apps/backend/src/task/dto/subtask.dto.ts @@ -0,0 +1,48 @@ +import { + IsString, + IsBoolean, + IsOptional, + IsNumber, + MinLength, + MaxLength, + Min, + IsDateString, +} from 'class-validator'; + +export class SubtaskDto { + @IsOptional() + @IsString() + id?: string; + + @IsString() + @MinLength(1, { message: 'Subtask-Titel darf nicht leer sein' }) + @MaxLength(500, { message: 'Subtask-Titel darf maximal 500 Zeichen haben' }) + title: string; + + @IsBoolean() + isCompleted: boolean; + + @IsOptional() + @IsDateString() + completedAt?: string | null; + + @IsNumber() + @Min(0) + order: number; +} + +export class CreateSubtaskDto { + @IsString() + @MinLength(1, { message: 'Subtask-Titel darf nicht leer sein' }) + @MaxLength(500, { message: 'Subtask-Titel darf maximal 500 Zeichen haben' }) + title: string; + + @IsOptional() + @IsBoolean() + isCompleted?: boolean; + + @IsOptional() + @IsNumber() + @Min(0) + order?: number; +} diff --git a/apps/todo/apps/backend/src/task/task.controller.ts b/apps/todo/apps/backend/src/task/task.controller.ts index 579923aa8..01060596a 100644 --- a/apps/todo/apps/backend/src/task/task.controller.ts +++ b/apps/todo/apps/backend/src/task/task.controller.ts @@ -33,9 +33,13 @@ export class TaskController { } @Get('completed') - async getCompleted(@CurrentUser() user: CurrentUserData, @Query('limit') limit?: number) { - const tasks = await this.taskService.getCompletedTasks(user.userId, limit ?? 50); - return { tasks }; + async getCompleted( + @CurrentUser() user: CurrentUserData, + @Query('limit') limit?: number, + @Query('offset') offset?: number + ) { + const result = await this.taskService.getCompletedTasks(user.userId, limit ?? 50, offset ?? 0); + return result; } @Get(':id') diff --git a/apps/todo/apps/backend/src/task/task.service.ts b/apps/todo/apps/backend/src/task/task.service.ts index cbb420d1e..747990a56 100644 --- a/apps/todo/apps/backend/src/task/task.service.ts +++ b/apps/todo/apps/backend/src/task/task.service.ts @@ -1,5 +1,5 @@ import { Injectable, Inject, NotFoundException } from '@nestjs/common'; -import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL } from 'drizzle-orm'; +import { eq, and, or, gte, lte, ilike, asc, desc, isNull, SQL, sql } from 'drizzle-orm'; import { RRule, RRuleSet, rrulestr } from 'rrule'; import { DATABASE_CONNECTION } from '../db/database.module'; import { type Database } from '../db/connection'; @@ -452,14 +452,35 @@ export class TaskService { return this.loadTaskLabelsBatch(result); } - async getCompletedTasks(userId: string, limit: number = 50): Promise { - const result = await this.db.query.tasks.findMany({ - where: and(eq(tasks.userId, userId), eq(tasks.isCompleted, true)), - orderBy: [desc(tasks.completedAt)], - limit, - }); + async getCompletedTasks( + userId: string, + limit: number = 50, + offset: number = 0 + ): Promise<{ tasks: TaskWithLabels[]; total: number; hasMore: boolean }> { + // Enforce max limit to prevent abuse + const safeLimit = Math.min(limit, 100); - return this.loadTaskLabelsBatch(result); + const [result, countResult] = await Promise.all([ + this.db.query.tasks.findMany({ + where: and(eq(tasks.userId, userId), eq(tasks.isCompleted, true)), + orderBy: [desc(tasks.completedAt)], + limit: safeLimit, + offset, + }), + this.db + .select({ count: sql`count(*)::int` }) + .from(tasks) + .where(and(eq(tasks.userId, userId), eq(tasks.isCompleted, true))), + ]); + + const total = countResult[0]?.count ?? 0; + const tasksWithLabels = await this.loadTaskLabelsBatch(result); + + return { + tasks: tasksWithLabels, + total, + hasMore: offset + safeLimit < total, + }; } async reorder( diff --git a/apps/todo/apps/web/src/routes/(app)/+layout.svelte b/apps/todo/apps/web/src/routes/(app)/+layout.svelte index 5f51106a5..1f15afb2d 100644 --- a/apps/todo/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/todo/apps/web/src/routes/(app)/+layout.svelte @@ -215,16 +215,20 @@ function handleModeChange(isSidebar: boolean) { isSidebarMode = isSidebar; sidebarModeStore.set(isSidebar); - if (typeof localStorage !== 'undefined') { - localStorage.setItem('todo-nav-sidebar', String(isSidebar)); + try { + localStorage?.setItem('todo-nav-sidebar', String(isSidebar)); + } catch { + // localStorage not available or quota exceeded } } function handleCollapsedChange(collapsed: boolean) { isCollapsed = collapsed; collapsedStore.set(collapsed); - if (typeof localStorage !== 'undefined') { - localStorage.setItem('todo-nav-collapsed', String(collapsed)); + try { + localStorage?.setItem('todo-nav-collapsed', String(collapsed)); + } catch { + // localStorage not available or quota exceeded } } @@ -263,18 +267,26 @@ goto(userSettings.startPage, { replaceState: true }); } - // Initialize sidebar mode from localStorage - const savedSidebar = localStorage.getItem('todo-nav-sidebar'); - if (savedSidebar === 'true') { - isSidebarMode = true; - sidebarModeStore.set(true); + // Initialize sidebar mode from localStorage (with error handling for private browsing) + try { + const savedSidebar = localStorage?.getItem('todo-nav-sidebar'); + if (savedSidebar === 'true') { + isSidebarMode = true; + sidebarModeStore.set(true); + } + } catch { + // localStorage not available (private browsing, quota exceeded, etc.) } // Initialize collapsed state from localStorage - const savedCollapsed = localStorage.getItem('todo-nav-collapsed'); - if (savedCollapsed === 'true') { - isCollapsed = true; - collapsedStore.set(true); + try { + const savedCollapsed = localStorage?.getItem('todo-nav-collapsed'); + if (savedCollapsed === 'true') { + isCollapsed = true; + collapsedStore.set(true); + } + } catch { + // localStorage not available } // Register Service Worker for PWA