feat(todo): add deep validation for DTOs and completed task pagination

Validation:
- Create SubtaskDto with title MinLength/MaxLength, order validation
- Create TaskMetadataDto with typed EffectiveDuration, storyPoints enum
  validation (Fibonacci only), funRating bounds (1-10)
- Use @ValidateNested with @Type() for deep validation in CreateTaskDto

Pagination:
- Add offset parameter and total count to getCompletedTasks()
- Return { tasks, total, hasMore } structure for frontend pagination
- Enforce max limit of 100 to prevent abuse
- Add 3 pagination tests (basic, offset, max enforcement)

Error handling:
- Add try-catch to localStorage operations in +layout.svelte
- Handles private browsing mode and quota exceeded errors gracefully

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-10 14:38:11 +01:00 committed by Wuesteon
parent b1877c4a08
commit 74604b09d3
7 changed files with 218 additions and 34 deletions

View file

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

View file

@ -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<Subtask, 'id'>[];
@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;
}

View file

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

View file

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

View file

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

View file

@ -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<TaskWithLabels[]> {
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<number>`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(

View file

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