mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-28 00:17:43 +02:00
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:
parent
b1877c4a08
commit
74604b09d3
7 changed files with 218 additions and 34 deletions
|
|
@ -5,6 +5,9 @@ import { ProjectService } from '../../project/project.service';
|
||||||
import { DATABASE_CONNECTION } from '../../db/database.module';
|
import { DATABASE_CONNECTION } from '../../db/database.module';
|
||||||
|
|
||||||
// Mock database
|
// Mock database
|
||||||
|
const mockSelectFrom = jest.fn().mockReturnThis();
|
||||||
|
const mockSelectWhere = jest.fn();
|
||||||
|
|
||||||
const mockDb = {
|
const mockDb = {
|
||||||
query: {
|
query: {
|
||||||
tasks: {
|
tasks: {
|
||||||
|
|
@ -18,6 +21,10 @@ const mockDb = {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
select: jest.fn().mockReturnValue({
|
||||||
|
from: mockSelectFrom,
|
||||||
|
where: mockSelectWhere,
|
||||||
|
}),
|
||||||
insert: jest.fn().mockReturnThis(),
|
insert: jest.fn().mockReturnThis(),
|
||||||
update: jest.fn().mockReturnThis(),
|
update: jest.fn().mockReturnThis(),
|
||||||
delete: jest.fn().mockReturnThis(),
|
delete: jest.fn().mockReturnThis(),
|
||||||
|
|
@ -406,7 +413,7 @@ describe('TaskService', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getCompletedTasks', () => {
|
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 userId = 'user-123';
|
||||||
const mockTasks = Array(50)
|
const mockTasks = Array(50)
|
||||||
.fill(null)
|
.fill(null)
|
||||||
|
|
@ -419,13 +426,16 @@ describe('TaskService', () => {
|
||||||
|
|
||||||
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
|
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
|
||||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
||||||
|
mockSelectWhere.mockResolvedValue([{ count: 75 }]);
|
||||||
|
|
||||||
const result = await service.getCompletedTasks(userId);
|
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 userId = 'user-123';
|
||||||
const mockTasks = Array(10)
|
const mockTasks = Array(10)
|
||||||
.fill(null)
|
.fill(null)
|
||||||
|
|
@ -438,10 +448,35 @@ describe('TaskService', () => {
|
||||||
|
|
||||||
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
|
mockDb.query.tasks.findMany.mockResolvedValue(mockTasks);
|
||||||
mockDb.query.taskLabels.findMany.mockResolvedValue([]);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,16 @@ import {
|
||||||
IsUUID,
|
IsUUID,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsObject,
|
|
||||||
MaxLength,
|
MaxLength,
|
||||||
MinLength,
|
MinLength,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} 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 {
|
export class CreateTaskDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|
@ -58,7 +61,9 @@ export class CreateTaskDto {
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
subtasks?: Omit<Subtask, 'id'>[];
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreateSubtaskDto)
|
||||||
|
subtasks?: CreateSubtaskDto[];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
|
|
@ -66,6 +71,7 @@ export class CreateTaskDto {
|
||||||
labelIds?: string[];
|
labelIds?: string[];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsObject()
|
@ValidateNested()
|
||||||
metadata?: TaskMetadata;
|
@Type(() => TaskMetadataDto)
|
||||||
|
metadata?: TaskMetadataDto;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
58
apps/todo/apps/backend/src/task/dto/metadata.dto.ts
Normal file
58
apps/todo/apps/backend/src/task/dto/metadata.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
48
apps/todo/apps/backend/src/task/dto/subtask.dto.ts
Normal file
48
apps/todo/apps/backend/src/task/dto/subtask.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -33,9 +33,13 @@ export class TaskController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('completed')
|
@Get('completed')
|
||||||
async getCompleted(@CurrentUser() user: CurrentUserData, @Query('limit') limit?: number) {
|
async getCompleted(
|
||||||
const tasks = await this.taskService.getCompletedTasks(user.userId, limit ?? 50);
|
@CurrentUser() user: CurrentUserData,
|
||||||
return { tasks };
|
@Query('limit') limit?: number,
|
||||||
|
@Query('offset') offset?: number
|
||||||
|
) {
|
||||||
|
const result = await this.taskService.getCompletedTasks(user.userId, limit ?? 50, offset ?? 0);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
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 { RRule, RRuleSet, rrulestr } from 'rrule';
|
||||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||||
import { type Database } from '../db/connection';
|
import { type Database } from '../db/connection';
|
||||||
|
|
@ -452,14 +452,35 @@ export class TaskService {
|
||||||
return this.loadTaskLabelsBatch(result);
|
return this.loadTaskLabelsBatch(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCompletedTasks(userId: string, limit: number = 50): Promise<TaskWithLabels[]> {
|
async getCompletedTasks(
|
||||||
const result = await this.db.query.tasks.findMany({
|
userId: string,
|
||||||
where: and(eq(tasks.userId, userId), eq(tasks.isCompleted, true)),
|
limit: number = 50,
|
||||||
orderBy: [desc(tasks.completedAt)],
|
offset: number = 0
|
||||||
limit,
|
): 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(
|
async reorder(
|
||||||
|
|
|
||||||
|
|
@ -215,16 +215,20 @@
|
||||||
function handleModeChange(isSidebar: boolean) {
|
function handleModeChange(isSidebar: boolean) {
|
||||||
isSidebarMode = isSidebar;
|
isSidebarMode = isSidebar;
|
||||||
sidebarModeStore.set(isSidebar);
|
sidebarModeStore.set(isSidebar);
|
||||||
if (typeof localStorage !== 'undefined') {
|
try {
|
||||||
localStorage.setItem('todo-nav-sidebar', String(isSidebar));
|
localStorage?.setItem('todo-nav-sidebar', String(isSidebar));
|
||||||
|
} catch {
|
||||||
|
// localStorage not available or quota exceeded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCollapsedChange(collapsed: boolean) {
|
function handleCollapsedChange(collapsed: boolean) {
|
||||||
isCollapsed = collapsed;
|
isCollapsed = collapsed;
|
||||||
collapsedStore.set(collapsed);
|
collapsedStore.set(collapsed);
|
||||||
if (typeof localStorage !== 'undefined') {
|
try {
|
||||||
localStorage.setItem('todo-nav-collapsed', String(collapsed));
|
localStorage?.setItem('todo-nav-collapsed', String(collapsed));
|
||||||
|
} catch {
|
||||||
|
// localStorage not available or quota exceeded
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -263,18 +267,26 @@
|
||||||
goto(userSettings.startPage, { replaceState: true });
|
goto(userSettings.startPage, { replaceState: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize sidebar mode from localStorage
|
// Initialize sidebar mode from localStorage (with error handling for private browsing)
|
||||||
const savedSidebar = localStorage.getItem('todo-nav-sidebar');
|
try {
|
||||||
if (savedSidebar === 'true') {
|
const savedSidebar = localStorage?.getItem('todo-nav-sidebar');
|
||||||
isSidebarMode = true;
|
if (savedSidebar === 'true') {
|
||||||
sidebarModeStore.set(true);
|
isSidebarMode = true;
|
||||||
|
sidebarModeStore.set(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// localStorage not available (private browsing, quota exceeded, etc.)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize collapsed state from localStorage
|
// Initialize collapsed state from localStorage
|
||||||
const savedCollapsed = localStorage.getItem('todo-nav-collapsed');
|
try {
|
||||||
if (savedCollapsed === 'true') {
|
const savedCollapsed = localStorage?.getItem('todo-nav-collapsed');
|
||||||
isCollapsed = true;
|
if (savedCollapsed === 'true') {
|
||||||
collapsedStore.set(true);
|
isCollapsed = true;
|
||||||
|
collapsedStore.set(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// localStorage not available
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register Service Worker for PWA
|
// Register Service Worker for PWA
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue