tasks
This commit is contained in:
@@ -12,6 +12,7 @@ import { ListTemplatesModule } from './list-templates/list-templates.module';
|
||||
import { ListsModule } from './lists/lists.module';
|
||||
import { MailModule } from './mail/mail.module';
|
||||
import { McpModule } from './mcp/mcp.module';
|
||||
import { TasksModule } from './tasks/tasks.module';
|
||||
import {
|
||||
databaseLoggerOptionsFromEnv,
|
||||
parseDatabaseLogging,
|
||||
@@ -64,6 +65,7 @@ import { DatabaseLogger } from './database/database.logger';
|
||||
MailModule,
|
||||
ListsModule,
|
||||
ListTemplatesModule,
|
||||
TasksModule,
|
||||
McpModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -28,7 +28,12 @@ export type AuditAction =
|
||||
| 'list.item_updated'
|
||||
| 'list.item_checked'
|
||||
| 'list.item_unchecked'
|
||||
| 'list.item_deleted';
|
||||
| 'list.item_deleted'
|
||||
| 'task.created'
|
||||
| 'task.updated'
|
||||
| 'task.completed'
|
||||
| 'task.reopened'
|
||||
| 'task.deleted';
|
||||
|
||||
export interface AuditLogInput {
|
||||
actorUserId?: string | null;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ListTemplateEntity } from '../list-templates/list-template.entity';
|
||||
import { ListTemplateShareEntity } from '../list-templates/list-template-share.entity';
|
||||
import { UserListEntity } from '../lists/user-list.entity';
|
||||
import { UserListShareEntity } from '../lists/user-list-share.entity';
|
||||
import { UserTaskEntity } from '../tasks/user-task.entity';
|
||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
||||
|
||||
@Entity('users')
|
||||
@@ -72,6 +73,9 @@ export class UserEntity {
|
||||
@OneToMany(() => UserListShareEntity, (share) => share.user)
|
||||
sharedLists?: UserListShareEntity[];
|
||||
|
||||
@OneToMany(() => UserTaskEntity, (task) => task.owner)
|
||||
tasks?: UserTaskEntity[];
|
||||
|
||||
@OneToMany(() => ListTemplateShareEntity, (share) => share.user)
|
||||
sharedTemplates?: ListTemplateShareEntity[];
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ListTemplateShareEntity } from '../list-templates/list-template-share.e
|
||||
import { TemplateSeedEntity } from '../list-templates/template-seed.entity';
|
||||
import { UserListEntity } from '../lists/user-list.entity';
|
||||
import { UserListItemEntity } from '../lists/user-list-item.entity';
|
||||
import { UserTaskEntity } from '../tasks/user-task.entity';
|
||||
import {
|
||||
databaseLoggerOptionsFromEnv,
|
||||
parseDatabaseLogging,
|
||||
@@ -43,6 +44,7 @@ export default new DataSource({
|
||||
TemplateSeedEntity,
|
||||
UserListEntity,
|
||||
UserListItemEntity,
|
||||
UserTaskEntity,
|
||||
WeeklyListSuggestionSnapshotEntity,
|
||||
],
|
||||
migrations: ['src/database/migrations/*.ts'],
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateUserTasks1781900000000 implements MigrationInterface {
|
||||
name = 'CreateUserTasks1781900000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (!(await queryRunner.hasTable('user_tasks'))) {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE \`user_tasks\` (
|
||||
\`id\` varchar(36) NOT NULL,
|
||||
\`ownerId\` varchar(36) NOT NULL,
|
||||
\`title\` varchar(220) NOT NULL,
|
||||
\`notes\` text NULL,
|
||||
\`dueDate\` varchar(10) NOT NULL,
|
||||
\`completed\` tinyint NOT NULL DEFAULT 0,
|
||||
\`completedAt\` datetime(3) NULL,
|
||||
\`createdAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
\`updatedAt\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
\`deletedAt\` datetime(3) NULL,
|
||||
INDEX \`IDX_user_tasks_owner_id\` (\`ownerId\`),
|
||||
INDEX \`IDX_user_tasks_owner_due_date\` (\`ownerId\`, \`dueDate\`),
|
||||
INDEX \`IDX_user_tasks_deleted_at\` (\`deletedAt\`),
|
||||
PRIMARY KEY (\`id\`)
|
||||
) ENGINE=InnoDB
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE \`user_tasks\`
|
||||
ADD CONSTRAINT \`FK_user_tasks_owner_id\`
|
||||
FOREIGN KEY (\`ownerId\`) REFERENCES \`users\`(\`id\`)
|
||||
ON DELETE CASCADE ON UPDATE NO ACTION
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
if (await queryRunner.hasTable('user_tasks')) {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE `user_tasks` DROP FOREIGN KEY `FK_user_tasks_owner_id`',
|
||||
);
|
||||
await queryRunner.query('DROP TABLE `user_tasks`');
|
||||
}
|
||||
}
|
||||
}
|
||||
5
listify-api/src/tasks/dto/create-task.dto.ts
Normal file
5
listify-api/src/tasks/dto/create-task.dto.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export class CreateTaskDto {
|
||||
title?: string;
|
||||
notes?: string;
|
||||
dueDate?: string;
|
||||
}
|
||||
6
listify-api/src/tasks/dto/update-task.dto.ts
Normal file
6
listify-api/src/tasks/dto/update-task.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class UpdateTaskDto {
|
||||
title?: string;
|
||||
notes?: string | null;
|
||||
dueDate?: string;
|
||||
completed?: boolean;
|
||||
}
|
||||
77
listify-api/src/tasks/tasks.controller.ts
Normal file
77
listify-api/src/tasks/tasks.controller.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CreateTaskDto } from './dto/create-task.dto';
|
||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||
import { TasksService } from './tasks.service';
|
||||
import type { AuthenticatedRequest } from '../auth/auth.types';
|
||||
|
||||
@Controller('tasks')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TasksController {
|
||||
constructor(private readonly tasksService: TasksService) {}
|
||||
|
||||
@Post()
|
||||
createTask(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Body() createDto: CreateTaskDto,
|
||||
) {
|
||||
return this.tasksService.createTask(this.requireUserId(request), createDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
listTasks(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Query('date') date?: string,
|
||||
) {
|
||||
return this.tasksService.listTasks(this.requireUserId(request), date);
|
||||
}
|
||||
|
||||
@Get(':taskId')
|
||||
getTask(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('taskId') taskId: string,
|
||||
) {
|
||||
return this.tasksService.getTask(this.requireUserId(request), taskId);
|
||||
}
|
||||
|
||||
@Patch(':taskId')
|
||||
updateTask(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('taskId') taskId: string,
|
||||
@Body() updateDto: UpdateTaskDto,
|
||||
) {
|
||||
return this.tasksService.updateTask(
|
||||
this.requireUserId(request),
|
||||
taskId,
|
||||
updateDto,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':taskId')
|
||||
deleteTask(
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@Param('taskId') taskId: string,
|
||||
) {
|
||||
return this.tasksService.deleteTask(this.requireUserId(request), taskId);
|
||||
}
|
||||
|
||||
private requireUserId(request: AuthenticatedRequest): string {
|
||||
if (!request.user?.sub) {
|
||||
throw new UnauthorizedException('Authenticated user is required.');
|
||||
}
|
||||
|
||||
return request.user.sub;
|
||||
}
|
||||
}
|
||||
14
listify-api/src/tasks/tasks.module.ts
Normal file
14
listify-api/src/tasks/tasks.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { TasksController } from './tasks.controller';
|
||||
import { TasksService } from './tasks.service';
|
||||
import { UserTaskEntity } from './user-task.entity';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, TypeOrmModule.forFeature([UserTaskEntity])],
|
||||
controllers: [TasksController],
|
||||
providers: [TasksService],
|
||||
exports: [TasksService],
|
||||
})
|
||||
export class TasksModule {}
|
||||
87
listify-api/src/tasks/tasks.service.spec.ts
Normal file
87
listify-api/src/tasks/tasks.service.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InMemoryRepository } from '../testing/in-memory-repository';
|
||||
import { TasksService } from './tasks.service';
|
||||
import { UserTaskEntity } from './user-task.entity';
|
||||
|
||||
describe('TasksService', () => {
|
||||
let service: TasksService;
|
||||
let tasksRepository: InMemoryRepository<UserTaskEntity>;
|
||||
|
||||
beforeEach(() => {
|
||||
tasksRepository = new InMemoryRepository<UserTaskEntity>();
|
||||
service = new TasksService(tasksRepository as never);
|
||||
});
|
||||
|
||||
it('creates and lists day tasks for the owning user', async () => {
|
||||
const task = await service.createTask('user-1', {
|
||||
title: 'Arzttermin buchen',
|
||||
notes: 'Vormittags anrufen',
|
||||
dueDate: '2026-06-29',
|
||||
});
|
||||
|
||||
expect(task.title).toBe('Arzttermin buchen');
|
||||
expect(task.notes).toBe('Vormittags anrufen');
|
||||
expect(task.dueDate).toBe('2026-06-29');
|
||||
expect(task.completed).toBe(false);
|
||||
expect(task.completedAt).toBeUndefined();
|
||||
await expect(service.listTasks('user-1', '2026-06-29')).resolves.toEqual([
|
||||
task,
|
||||
]);
|
||||
await expect(service.listTasks('user-2', '2026-06-29')).resolves.toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('updates, completes, reopens and deletes tasks', async () => {
|
||||
const task = await service.createTask('user-1', {
|
||||
title: 'Rechnung zahlen',
|
||||
dueDate: '2026-06-29',
|
||||
});
|
||||
const updatedTask = await service.updateTask('user-1', task.id, {
|
||||
title: 'Stromrechnung zahlen',
|
||||
dueDate: '2026-06-30',
|
||||
completed: true,
|
||||
});
|
||||
const reopenedTask = await service.updateTask('user-1', task.id, {
|
||||
completed: false,
|
||||
});
|
||||
const deleteResponse = await service.deleteTask('user-1', task.id);
|
||||
|
||||
expect(updatedTask.title).toBe('Stromrechnung zahlen');
|
||||
expect(updatedTask.dueDate).toBe('2026-06-30');
|
||||
expect(updatedTask.completed).toBe(true);
|
||||
expect(updatedTask.completedAt).toBeDefined();
|
||||
expect(reopenedTask.completed).toBe(false);
|
||||
expect(reopenedTask.completedAt).toBeUndefined();
|
||||
expect(deleteResponse.message).toBe('Task deleted.');
|
||||
await expect(service.listTasks('user-1')).resolves.toEqual([]);
|
||||
await expect(service.getTask('user-1', task.id)).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid input and blocks foreign access', async () => {
|
||||
await expect(
|
||||
service.createTask('user-1', { title: ' ', dueDate: '2026-06-29' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
await expect(
|
||||
service.createTask('user-1', { title: 'Task', dueDate: '2026-02-31' }),
|
||||
).rejects.toThrow('Task due date must be a valid date.');
|
||||
|
||||
const task = await service.createTask('user-1', {
|
||||
title: 'Privat',
|
||||
dueDate: '2026-06-29',
|
||||
});
|
||||
|
||||
await expect(service.getTask('user-2', task.id)).rejects.toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
await expect(service.getTask('user-1', 'missing')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
250
listify-api/src/tasks/tasks.service.ts
Normal file
250
listify-api/src/tasks/tasks.service.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { CreateTaskDto } from './dto/create-task.dto';
|
||||
import { UpdateTaskDto } from './dto/update-task.dto';
|
||||
import { UserTask } from './tasks.types';
|
||||
import { UserTaskEntity } from './user-task.entity';
|
||||
|
||||
@Injectable()
|
||||
export class TasksService {
|
||||
constructor(
|
||||
@InjectRepository(UserTaskEntity)
|
||||
private readonly tasksRepository: Repository<UserTaskEntity>,
|
||||
@Optional()
|
||||
private readonly auditLogService?: AuditLogService,
|
||||
) {}
|
||||
|
||||
async createTask(
|
||||
ownerId: string,
|
||||
createDto: CreateTaskDto,
|
||||
): Promise<UserTask> {
|
||||
const task = this.tasksRepository.create({
|
||||
id: randomUUID(),
|
||||
ownerId,
|
||||
title: this.requireTitle(createDto.title),
|
||||
notes: this.normalizeOptionalText(createDto.notes),
|
||||
dueDate: this.normalizeDueDate(createDto.dueDate ?? this.todayKey()),
|
||||
completed: false,
|
||||
completedAt: null,
|
||||
deletedAt: null,
|
||||
});
|
||||
const savedTask = await this.tasksRepository.save(task);
|
||||
|
||||
await this.auditLogService?.record({
|
||||
actorUserId: ownerId,
|
||||
action: 'task.created',
|
||||
entityType: 'task',
|
||||
entityId: savedTask.id,
|
||||
metadata: {
|
||||
title: savedTask.title,
|
||||
dueDate: savedTask.dueDate,
|
||||
},
|
||||
});
|
||||
|
||||
return this.toUserTask(savedTask);
|
||||
}
|
||||
|
||||
async listTasks(ownerId: string, dueDate?: string): Promise<UserTask[]> {
|
||||
const normalizedDueDate =
|
||||
dueDate !== undefined ? this.normalizeDueDate(dueDate) : undefined;
|
||||
const tasks = await this.tasksRepository.find({
|
||||
where: {
|
||||
ownerId,
|
||||
deletedAt: IsNull(),
|
||||
...(normalizedDueDate ? { dueDate: normalizedDueDate } : {}),
|
||||
},
|
||||
order: { dueDate: 'ASC', completed: 'ASC', createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
return tasks.map((task) => this.toUserTask(task));
|
||||
}
|
||||
|
||||
async getTask(ownerId: string, taskId: string): Promise<UserTask> {
|
||||
return this.toUserTask(await this.findOwnedTask(ownerId, taskId));
|
||||
}
|
||||
|
||||
async updateTask(
|
||||
ownerId: string,
|
||||
taskId: string,
|
||||
updateDto: UpdateTaskDto,
|
||||
): Promise<UserTask> {
|
||||
const task = await this.findOwnedTask(ownerId, taskId);
|
||||
const wasCompleted = task.completed;
|
||||
|
||||
if (updateDto.title !== undefined) {
|
||||
task.title = this.requireTitle(updateDto.title);
|
||||
}
|
||||
|
||||
if (updateDto.notes !== undefined) {
|
||||
task.notes = this.normalizeOptionalText(updateDto.notes ?? undefined);
|
||||
}
|
||||
|
||||
if (updateDto.dueDate !== undefined) {
|
||||
task.dueDate = this.normalizeDueDate(updateDto.dueDate);
|
||||
}
|
||||
|
||||
if (updateDto.completed !== undefined) {
|
||||
task.completed = this.normalizeBoolean(updateDto.completed, 'completed');
|
||||
task.completedAt = task.completed ? new Date() : null;
|
||||
}
|
||||
|
||||
const savedTask = await this.tasksRepository.save(task);
|
||||
|
||||
await this.auditLogService?.record({
|
||||
actorUserId: ownerId,
|
||||
action:
|
||||
updateDto.completed === true
|
||||
? 'task.completed'
|
||||
: updateDto.completed === false
|
||||
? 'task.reopened'
|
||||
: 'task.updated',
|
||||
entityType: 'task',
|
||||
entityId: savedTask.id,
|
||||
metadata: {
|
||||
title: savedTask.title,
|
||||
dueDate: savedTask.dueDate,
|
||||
previousCompleted: wasCompleted,
|
||||
completed: savedTask.completed,
|
||||
changedFields: Object.keys(updateDto).filter(
|
||||
(field) => updateDto[field as keyof UpdateTaskDto] !== undefined,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
return this.toUserTask(savedTask);
|
||||
}
|
||||
|
||||
async deleteTask(
|
||||
ownerId: string,
|
||||
taskId: string,
|
||||
): Promise<{ message: string }> {
|
||||
const task = await this.findOwnedTask(ownerId, taskId);
|
||||
|
||||
task.deletedAt = new Date();
|
||||
await this.tasksRepository.save(task);
|
||||
|
||||
await this.auditLogService?.record({
|
||||
actorUserId: ownerId,
|
||||
action: 'task.deleted',
|
||||
entityType: 'task',
|
||||
entityId: taskId,
|
||||
metadata: {
|
||||
title: task.title,
|
||||
dueDate: task.dueDate,
|
||||
completed: task.completed,
|
||||
},
|
||||
});
|
||||
|
||||
return { message: 'Task deleted.' };
|
||||
}
|
||||
|
||||
private async findOwnedTask(
|
||||
ownerId: string,
|
||||
taskId: string,
|
||||
): Promise<UserTaskEntity> {
|
||||
const task = await this.tasksRepository.findOne({
|
||||
where: { id: taskId, deletedAt: IsNull() },
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException('Task was not found.');
|
||||
}
|
||||
|
||||
if (task.ownerId !== ownerId) {
|
||||
throw new ForbiddenException('You do not have access to this task.');
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
private requireTitle(value: string | undefined): string {
|
||||
const title = this.compactText(value, 220);
|
||||
|
||||
if (!title) {
|
||||
throw new BadRequestException('Task title is required.');
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
private normalizeOptionalText(value: string | undefined): string | null {
|
||||
return this.compactText(value, 1000) ?? null;
|
||||
}
|
||||
|
||||
private normalizeDueDate(value: string): string {
|
||||
const candidate = value.trim();
|
||||
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(candidate)) {
|
||||
throw new BadRequestException('Task due date must be YYYY-MM-DD.');
|
||||
}
|
||||
|
||||
const parsedDate = new Date(`${candidate}T00:00:00.000Z`);
|
||||
|
||||
if (
|
||||
Number.isNaN(parsedDate.getTime()) ||
|
||||
parsedDate.toISOString().slice(0, 10) !== candidate
|
||||
) {
|
||||
throw new BadRequestException('Task due date must be a valid date.');
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private normalizeBoolean(value: boolean, fieldName: string): boolean {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new BadRequestException(`${fieldName} must be a boolean.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private compactText(
|
||||
value: string | undefined,
|
||||
maxLength: number,
|
||||
): string | undefined {
|
||||
const compacted = value?.replace(/\s+/g, ' ').trim();
|
||||
|
||||
if (!compacted) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (compacted.length <= maxLength) {
|
||||
return compacted;
|
||||
}
|
||||
|
||||
return `${compacted.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
private todayKey(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
private toUserTask(task: UserTaskEntity): UserTask {
|
||||
return {
|
||||
id: task.id,
|
||||
ownerId: task.ownerId,
|
||||
title: task.title,
|
||||
notes: task.notes ?? undefined,
|
||||
dueDate: task.dueDate,
|
||||
completed: task.completed,
|
||||
completedAt: task.completedAt
|
||||
? this.toIsoString(task.completedAt)
|
||||
: undefined,
|
||||
createdAt: this.toIsoString(task.createdAt),
|
||||
updatedAt: this.toIsoString(task.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
private toIsoString(value?: Date): string {
|
||||
return (value ?? new Date()).toISOString();
|
||||
}
|
||||
}
|
||||
11
listify-api/src/tasks/tasks.types.ts
Normal file
11
listify-api/src/tasks/tasks.types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface UserTask {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
title: string;
|
||||
notes?: string;
|
||||
dueDate: string;
|
||||
completed: boolean;
|
||||
completedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
62
listify-api/src/tasks/user-task.entity.ts
Normal file
62
listify-api/src/tasks/user-task.entity.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { UserEntity } from '../auth/user.entity';
|
||||
|
||||
@Entity('user_tasks')
|
||||
@Index('IDX_user_tasks_owner_due_date', ['ownerId', 'dueDate'])
|
||||
export class UserTaskEntity {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'varchar', length: 36 })
|
||||
ownerId!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 220 })
|
||||
title!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 10 })
|
||||
dueDate!: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
completed!: boolean;
|
||||
|
||||
@Column({ type: 'datetime', precision: 3, nullable: true })
|
||||
completedAt?: Date | null;
|
||||
|
||||
@CreateDateColumn({
|
||||
type: 'datetime',
|
||||
precision: 3,
|
||||
default: () => 'CURRENT_TIMESTAMP(3)',
|
||||
})
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({
|
||||
type: 'datetime',
|
||||
precision: 3,
|
||||
default: () => 'CURRENT_TIMESTAMP(3)',
|
||||
onUpdate: 'CURRENT_TIMESTAMP(3)',
|
||||
})
|
||||
updatedAt!: Date;
|
||||
|
||||
@Index('IDX_user_tasks_deleted_at')
|
||||
@Column({ type: 'datetime', precision: 3, nullable: true })
|
||||
deletedAt?: Date | null;
|
||||
|
||||
@ManyToOne(() => UserEntity, (user) => user.tasks, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'ownerId' })
|
||||
owner?: UserEntity;
|
||||
}
|
||||
@@ -116,6 +116,9 @@ export class InMemoryRepository<T extends object> {
|
||||
const sortedRecords = [...records];
|
||||
const typedOrder = order as {
|
||||
name?: 'ASC' | 'DESC';
|
||||
createdAt?: 'ASC' | 'DESC';
|
||||
dueDate?: 'ASC' | 'DESC';
|
||||
completed?: 'ASC' | 'DESC';
|
||||
reminderAt?: 'ASC' | 'DESC';
|
||||
items?: { position?: 'ASC' | 'DESC' };
|
||||
};
|
||||
@@ -142,6 +145,45 @@ export class InMemoryRepository<T extends object> {
|
||||
});
|
||||
}
|
||||
|
||||
if (typedOrder?.dueDate) {
|
||||
sortedRecords.sort((left, right) => {
|
||||
const leftDate = String(
|
||||
(left as Record<string, unknown>)['dueDate'] ?? '',
|
||||
);
|
||||
const rightDate = String(
|
||||
(right as Record<string, unknown>)['dueDate'] ?? '',
|
||||
);
|
||||
return typedOrder.dueDate === 'DESC'
|
||||
? rightDate.localeCompare(leftDate)
|
||||
: leftDate.localeCompare(rightDate);
|
||||
});
|
||||
}
|
||||
|
||||
if (typedOrder?.completed) {
|
||||
sortedRecords.sort((left, right) => {
|
||||
const leftCompleted = Boolean(
|
||||
(left as Record<string, unknown>)['completed'],
|
||||
);
|
||||
const rightCompleted = Boolean(
|
||||
(right as Record<string, unknown>)['completed'],
|
||||
);
|
||||
const comparison = Number(leftCompleted) - Number(rightCompleted);
|
||||
return typedOrder.completed === 'DESC' ? -comparison : comparison;
|
||||
});
|
||||
}
|
||||
|
||||
if (typedOrder?.createdAt) {
|
||||
sortedRecords.sort((left, right) => {
|
||||
const leftDate = (left as Record<string, unknown>)['createdAt'];
|
||||
const rightDate = (right as Record<string, unknown>)['createdAt'];
|
||||
const leftTime = leftDate instanceof Date ? leftDate.getTime() : 0;
|
||||
const rightTime = rightDate instanceof Date ? rightDate.getTime() : 0;
|
||||
return typedOrder.createdAt === 'DESC'
|
||||
? rightTime - leftTime
|
||||
: leftTime - rightTime;
|
||||
});
|
||||
}
|
||||
|
||||
if (typedOrder?.items?.position) {
|
||||
for (const record of sortedRecords) {
|
||||
const items = (record as { items?: Array<{ position: number }> }).items;
|
||||
|
||||
Reference in New Issue
Block a user