mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +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';
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
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')
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue