From 87688fe84e7b133cd0d4c79731ce11906c0a7d70 Mon Sep 17 00:00:00 2001 From: "i.seliverstov" Date: Sun, 16 Aug 2026 17:57:40 +0500 Subject: [PATCH] feat(tasks): link tasks to multiple goals Store relations in a links table instead of a goalId column, with replace-set APIs on both sides. UI: goal picker in the task dialog, badges on cards/calendar, and a tasks block on GoalView. Co-authored-by: Cursor --- alfy-bot-frontend/src/api/goals.ts | 16 + .../src/components/UserSection.vue | 2 +- .../ui/dropdown-menu/DropdownMenuContent.vue | 3 +- .../dropdown-menu/DropdownMenuSubContent.vue | 2 +- .../calendar/ui/CalendarEventBlock.vue | 8 +- .../src/features/goals/ui/GoalPicker.vue | 91 +++++ .../features/goals/ui/GoalPickerContent.vue | 368 ++++++++++++++++++ .../projects/ui/BoardColumnHeader.vue | 2 +- .../features/projects/ui/ProjectPicker.vue | 2 +- .../features/projects/ui/ProjectTreeItem.vue | 2 +- .../src/features/tasks/model/task-store.ts | 25 +- .../src/features/tasks/model/types.ts | 1 + .../src/features/tasks/ui/DeadlinePicker.vue | 2 +- .../src/features/tasks/ui/PriorityPicker.vue | 2 +- .../features/tasks/ui/RecurrencePicker.vue | 2 +- .../src/features/tasks/ui/TaskCard.vue | 12 +- .../features/tasks/ui/TaskDetailDialog.vue | 52 ++- .../src/features/tasks/ui/TaskForm.vue | 6 +- .../features/tasks/ui/TaskListOptionsMenu.vue | 2 +- .../features/tasks/ui/TaskPropertyChips.vue | 9 + alfy-bot-frontend/src/mocks/handlers/tasks.ts | 12 + alfy-bot-frontend/src/views/GoalView.vue | 180 +++++++++ .../calendar/ui/CalendarEventBlock.spec.ts | 18 + .../features/goals/GoalPickerContent.spec.ts | 71 ++++ .../tests/features/tasks/ui/TaskCard.spec.ts | 15 + .../tests/views/GoalView.spec.ts | 31 +- alfy-bot/src/app.module.ts | 2 + .../modules/goal/dto/set-goal-tasks.dto.ts | 12 + .../src/modules/goal/goal.controller.spec.ts | 12 +- alfy-bot/src/modules/goal/goal.controller.ts | 37 +- alfy-bot/src/modules/goal/goal.module.ts | 7 +- .../task/domain/task-goal-query.port.ts | 11 + .../src/modules/task/domain/task-link.port.ts | 36 ++ .../task/domain/task-repository.port.ts | 1 + .../src/modules/task/dto/create-task.dto.ts | 6 + .../modules/task/dto/set-task-goals.dto.ts | 9 + .../src/modules/task/dto/update-task.dto.ts | 1 + .../typeorm-task-link.repository.ts | 128 ++++++ .../infrastructure/typeorm-task.repository.ts | 10 +- alfy-bot/src/modules/task/task.controller.ts | 11 + alfy-bot/src/modules/task/task.module.ts | 18 +- .../src/modules/task/task.service.spec.ts | 138 +++++++ alfy-bot/src/modules/task/task.service.ts | 139 ++++++- alfy-bot/src/shared/entities/index.ts | 1 + alfy-bot/src/shared/entities/link.entity.ts | 35 ++ alfy-bot/src/shared/entities/task.entity.ts | 3 + docs/tasks/task-goal-links.md | 149 +++++++ 47 files changed, 1663 insertions(+), 39 deletions(-) create mode 100644 alfy-bot-frontend/src/features/goals/ui/GoalPicker.vue create mode 100644 alfy-bot-frontend/src/features/goals/ui/GoalPickerContent.vue create mode 100644 alfy-bot-frontend/tests/features/goals/GoalPickerContent.spec.ts create mode 100644 alfy-bot/src/modules/goal/dto/set-goal-tasks.dto.ts create mode 100644 alfy-bot/src/modules/task/domain/task-goal-query.port.ts create mode 100644 alfy-bot/src/modules/task/domain/task-link.port.ts create mode 100644 alfy-bot/src/modules/task/dto/set-task-goals.dto.ts create mode 100644 alfy-bot/src/modules/task/infrastructure/typeorm-task-link.repository.ts create mode 100644 alfy-bot/src/shared/entities/link.entity.ts create mode 100644 docs/tasks/task-goal-links.md diff --git a/alfy-bot-frontend/src/api/goals.ts b/alfy-bot-frontend/src/api/goals.ts index beb347c..15d860d 100644 --- a/alfy-bot-frontend/src/api/goals.ts +++ b/alfy-bot-frontend/src/api/goals.ts @@ -1,4 +1,5 @@ import type { FrequencyType, Goal, GoalStatus, Question, QuestionType, Schedule } from '../types' +import type { Task } from '@/features/tasks/model/types' import { api } from './client' export interface UpdateScheduleDto { @@ -117,3 +118,18 @@ export async function fetchQuestionAnswerCount(questionId: number): Promise(`/questions/${questionId}/answer-count`) return data.count } + +export async function fetchGoalTasks(goalId: number): Promise { + const { data } = await api.get[]>(`/goals/${goalId}/tasks`) + return data.map(raw => ({ + ...raw, + dueDate: raw.dueDate ? new Date(raw.dueDate as string) : undefined, + deadline: raw.deadline ? new Date(raw.deadline as string) : undefined, + goalIds: Array.isArray(raw.goalIds) ? raw.goalIds as number[] : [], + }) as Task) +} + +export async function setGoalTasks(goalId: number, taskIds: string[]): Promise { + const { data } = await api.put<{ taskIds: string[] }>(`/goals/${goalId}/tasks`, { taskIds }) + return data.taskIds +} diff --git a/alfy-bot-frontend/src/components/UserSection.vue b/alfy-bot-frontend/src/components/UserSection.vue index 84efc6f..7eafabd 100644 --- a/alfy-bot-frontend/src/components/UserSection.vue +++ b/alfy-bot-frontend/src/components/UserSection.vue @@ -85,7 +85,7 @@ function handleLogout() { - + (), { sideOffset: 4, + align: "start", }, ) const emits = defineEmits() @@ -27,7 +28,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits) diff --git a/alfy-bot-frontend/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue b/alfy-bot-frontend/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue index d7c6b08..7df3da2 100644 --- a/alfy-bot-frontend/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue +++ b/alfy-bot-frontend/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue @@ -20,7 +20,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits) diff --git a/alfy-bot-frontend/src/features/calendar/ui/CalendarEventBlock.vue b/alfy-bot-frontend/src/features/calendar/ui/CalendarEventBlock.vue index 6adf3ae..2733644 100644 --- a/alfy-bot-frontend/src/features/calendar/ui/CalendarEventBlock.vue +++ b/alfy-bot-frontend/src/features/calendar/ui/CalendarEventBlock.vue @@ -33,6 +33,12 @@ @update:model-value="emit('toggle', event.taskId)" /> + import { computed } from 'vue' -import { CalendarPlus, Flag, Repeat, Timer } from 'lucide-vue-next' +import { CalendarPlus, Flag, Repeat, Target, Timer } from 'lucide-vue-next' import RoundCheckbox from '@/components/ui/roundCheckbox/RoundCheckbox.vue' import type { CalendarEvent } from '../model/types' import { PRIORITY_LABELS } from '@/features/tasks/model/constants' diff --git a/alfy-bot-frontend/src/features/goals/ui/GoalPicker.vue b/alfy-bot-frontend/src/features/goals/ui/GoalPicker.vue new file mode 100644 index 0000000..b56a8a8 --- /dev/null +++ b/alfy-bot-frontend/src/features/goals/ui/GoalPicker.vue @@ -0,0 +1,91 @@ + + + diff --git a/alfy-bot-frontend/src/features/goals/ui/GoalPickerContent.vue b/alfy-bot-frontend/src/features/goals/ui/GoalPickerContent.vue new file mode 100644 index 0000000..14e9508 --- /dev/null +++ b/alfy-bot-frontend/src/features/goals/ui/GoalPickerContent.vue @@ -0,0 +1,368 @@ + + + diff --git a/alfy-bot-frontend/src/features/projects/ui/BoardColumnHeader.vue b/alfy-bot-frontend/src/features/projects/ui/BoardColumnHeader.vue index 6e88d30..75ebefb 100644 --- a/alfy-bot-frontend/src/features/projects/ui/BoardColumnHeader.vue +++ b/alfy-bot-frontend/src/features/projects/ui/BoardColumnHeader.vue @@ -35,7 +35,7 @@ - + Переименовать diff --git a/alfy-bot-frontend/src/features/projects/ui/ProjectPicker.vue b/alfy-bot-frontend/src/features/projects/ui/ProjectPicker.vue index 6b0bed3..a237402 100644 --- a/alfy-bot-frontend/src/features/projects/ui/ProjectPicker.vue +++ b/alfy-bot-frontend/src/features/projects/ui/ProjectPicker.vue @@ -17,7 +17,7 @@ - + route.params.projectId === props.node.id) - + Изменить diff --git a/alfy-bot-frontend/src/features/tasks/model/task-store.ts b/alfy-bot-frontend/src/features/tasks/model/task-store.ts index e36983d..a7dcf3e 100644 --- a/alfy-bot-frontend/src/features/tasks/model/task-store.ts +++ b/alfy-bot-frontend/src/features/tasks/model/task-store.ts @@ -44,6 +44,7 @@ function parseTask(raw: Record): Task { isAutoCreated: (raw.isAutoCreated as boolean) ?? false, isOverdue: (raw.isOverdue as boolean) ?? false, onMissed: (raw.onMissed as 'shift' | 'freeze') ?? 'shift', + goalIds: Array.isArray(raw.goalIds) ? (raw.goalIds as number[]) : [], } as Task } @@ -63,7 +64,12 @@ export const useTaskStore = defineStore('tasks', () => { const index = tasks.value.findIndex(t => t.id === taskId) if (index !== -1) { const existing = tasks.value[index]! - tasks.value[index] = { ...existing, ...updatedTask, checklist: existing.checklist } + tasks.value[index] = { + ...existing, + ...updatedTask, + checklist: existing.checklist, + goalIds: updatedTask.goalIds ?? existing.goalIds ?? [], + } } // Handle recurring: add or refresh next instance in store. @@ -156,7 +162,7 @@ export const useTaskStore = defineStore('tasks', () => { isPomodoroTask, pomodoroCount, pomodoroDuration, shortBreak, longBreak, longBreakInterval, pomodoroCompleted, recurringCompletedCount, isAutoCreated, recurringParentId, - recurrenceAnchorDate, + recurrenceAnchorDate, goalIds, ...rest } = updates as Record const { data } = await api.patch(`/tasks/${taskId}`, serializeTaskDates(rest)) @@ -379,6 +385,20 @@ export const useTaskStore = defineStore('tasks', () => { return created } + const setTaskGoals = async (taskId: string, goalIds: number[]) => { + if (taskId.includes('__virtual__')) { + console.warn('Attempted to link a virtual task instance, ignoring:', taskId) + return + } + + const { data } = await api.put<{ goalIds: number[] }>(`/tasks/${taskId}/goals`, { goalIds }) + const index = tasks.value.findIndex(t => t.id === taskId) + if (index !== -1) { + tasks.value[index] = { ...tasks.value[index]!, goalIds: data.goalIds } + } + return data.goalIds + } + const completedTasks = computed(() => tasks.value.filter(t => t.completed)) const pendingTasks = computed(() => tasks.value.filter(t => !t.completed)) const pomodoroTasks = computed(() => tasks.value.filter(t => t.isPomodoroTask)) @@ -404,5 +424,6 @@ export const useTaskStore = defineStore('tasks', () => { updateChecklist, updatePomodoroConfig, materializeOccurrence, + setTaskGoals, } }) diff --git a/alfy-bot-frontend/src/features/tasks/model/types.ts b/alfy-bot-frontend/src/features/tasks/model/types.ts index 80061a4..f939189 100644 --- a/alfy-bot-frontend/src/features/tasks/model/types.ts +++ b/alfy-bot-frontend/src/features/tasks/model/types.ts @@ -45,6 +45,7 @@ export interface Task { columnId?: string | null order?: number parentId?: string + goalIds?: number[] subtasks?: Task[] checklistProgress?: { total: number diff --git a/alfy-bot-frontend/src/features/tasks/ui/DeadlinePicker.vue b/alfy-bot-frontend/src/features/tasks/ui/DeadlinePicker.vue index e51b281..5845efe 100644 --- a/alfy-bot-frontend/src/features/tasks/ui/DeadlinePicker.vue +++ b/alfy-bot-frontend/src/features/tasks/ui/DeadlinePicker.vue @@ -1,5 +1,5 @@ diff --git a/alfy-bot-frontend/tests/features/calendar/ui/CalendarEventBlock.spec.ts b/alfy-bot-frontend/tests/features/calendar/ui/CalendarEventBlock.spec.ts index 2d7a990..d86b72c 100644 --- a/alfy-bot-frontend/tests/features/calendar/ui/CalendarEventBlock.spec.ts +++ b/alfy-bot-frontend/tests/features/calendar/ui/CalendarEventBlock.spec.ts @@ -64,6 +64,24 @@ describe('CalendarEventBlock', () => { expect(wrapper.find('[data-testid="event-side-stripe"]').exists()).toBe(false) }) + it('иконка цели если у задачи есть goalIds', () => { + const wrapper = mount(CalendarEventBlock, { + props: { + event: makeEvent({ + task: makeTask({ goalIds: [1] }), + }), + }, + }) + expect(wrapper.find('[data-testid="goal-link-icon"]').exists()).toBe(true) + }) + + it('без goalIds нет иконки цели', () => { + const wrapper = mount(CalendarEventBlock, { + props: { event: makeEvent() }, + }) + expect(wrapper.find('[data-testid="goal-link-icon"]').exists()).toBe(false) + }) + it('выполненная и просроченная с приоритетом — без флага и полоски', () => { const done = mount(CalendarEventBlock, { props: { event: makeEvent({ priority: 'high', completed: true }) }, diff --git a/alfy-bot-frontend/tests/features/goals/GoalPickerContent.spec.ts b/alfy-bot-frontend/tests/features/goals/GoalPickerContent.spec.ts new file mode 100644 index 0000000..144483a --- /dev/null +++ b/alfy-bot-frontend/tests/features/goals/GoalPickerContent.spec.ts @@ -0,0 +1,71 @@ +import type { Goal } from '@/types' +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import GoalPickerContent from '@/features/goals/ui/GoalPickerContent.vue' + +function makeGoal(overrides: Partial = {}): Goal { + return { + id: 1, + goal_name: 'Тестовая цель', + goal_start: null, + goal_end: null, + status: 'active', + createdAt: '2025-01-01', + is_global: false, + parent_goal_id: null, + questions: [], + ...overrides, + } +} + +const goals: Goal[] = [ + makeGoal({ id: 1, goal_name: 'Здоровье', is_global: true }), + makeGoal({ id: 2, goal_name: 'Бег', parent_goal_id: 1 }), + makeGoal({ id: 3, goal_name: 'Карьера' }), +] + +describe('GoalPickerContent', () => { + it('фильтрует по имени и показывает родителя как подпись', async () => { + const wrapper = mount(GoalPickerContent, { + props: { modelValue: [], goals, loading: false }, + }) + + await wrapper.find('input').setValue('бег') + + expect(wrapper.text()).toContain('Бег') + expect(wrapper.text()).toContain('Здоровье') + expect(wrapper.text()).not.toContain('Карьера') + }) + + it('пустой поиск: подсказка, без списка', async () => { + const wrapper = mount(GoalPickerContent, { + props: { modelValue: [], goals, loading: false }, + }) + + await wrapper.find('input').setValue('zzzz') + + expect(wrapper.text()).toContain('Ничего не нашлось') + expect(wrapper.text()).toContain('Попробуйте другое название') + expect(wrapper.text()).not.toContain('Карьера') + }) + + it('Enter выбирает первую найденную', async () => { + const wrapper = mount(GoalPickerContent, { + props: { modelValue: [], goals, loading: false }, + }) + + await wrapper.find('input').setValue('карьер') + await wrapper.find('input').trigger('keydown.enter') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([[3]]) + }) + + it('выбранные цели на вкладке, а не секцией в списке', () => { + const wrapper = mount(GoalPickerContent, { + props: { modelValue: [3], goals, loading: false }, + }) + + expect(wrapper.get('[data-testid="goal-picker-tab-selected"]').text()).toContain('1') + expect(wrapper.findAll('p').map(p => p.text())).not.toContain('Выбрано') + }) +}) diff --git a/alfy-bot-frontend/tests/features/tasks/ui/TaskCard.spec.ts b/alfy-bot-frontend/tests/features/tasks/ui/TaskCard.spec.ts index 76f4976..0fee402 100644 --- a/alfy-bot-frontend/tests/features/tasks/ui/TaskCard.spec.ts +++ b/alfy-bot-frontend/tests/features/tasks/ui/TaskCard.spec.ts @@ -56,6 +56,21 @@ describe('TaskCard', () => { expect(wrapper.text()).toContain('+1') }) + it('рендерит бейдж Цели при одной привязанной цели', () => { + const wrapper = mount(TaskCard, { + props: { task: apiPayloadToTask({ goalIds: [1], priority: undefined, tags: [], isPomodoroTask: false }) }, + }) + expect(wrapper.find('[data-testid="task-goal-badge"]').exists()).toBe(true) + expect(wrapper.text()).toContain('Цель') + }) + + it('рендерит N цели при нескольких целях', () => { + const wrapper = mount(TaskCard, { + props: { task: apiPayloadToTask({ goalIds: [1, 2], priority: undefined, tags: [], isPomodoroTask: false }) }, + }) + expect(wrapper.text()).toContain('2 цели') + }) + it('не показывает tags-block при пустом tags', () => { const task = apiPayloadToTask({ tags: [] }) const wrapper = mount(TaskCard, { props: { task } }) diff --git a/alfy-bot-frontend/tests/views/GoalView.spec.ts b/alfy-bot-frontend/tests/views/GoalView.spec.ts index b401969..5a5e136 100644 --- a/alfy-bot-frontend/tests/views/GoalView.spec.ts +++ b/alfy-bot-frontend/tests/views/GoalView.spec.ts @@ -23,6 +23,8 @@ vi.mock('vue-router', () => ({ vi.mock('@/api/goals', () => ({ fetchGoalById: vi.fn(), fetchGoals: vi.fn(), + fetchGoalTasks: vi.fn().mockResolvedValue([]), + setGoalTasks: vi.fn(), addGoalQuestions: vi.fn(), deleteQuestion: vi.fn(), fetchQuestionAnswerCount: vi.fn(), @@ -83,11 +85,15 @@ const stubs = { GoalStatusBadge: true, PageContainer: { template: '
' }, SummaryCard: true, + TaskForm: true, + TaskCard: true, + TaskDetailDialog: true, } describe('goalView — global goal', () => { beforeEach(() => { vi.clearAllMocks() + setActivePinia(createPinia()) push.mockReset() routeParams.id = '1' }) @@ -153,6 +159,7 @@ describe('goalView — global goal', () => { describe('goalView — regular goal with parent', () => { beforeEach(() => { vi.clearAllMocks() + setActivePinia(createPinia()) push.mockReset() routeParams.id = '1' }) @@ -186,6 +193,28 @@ describe('goalView — regular goal with parent', () => { expect(wrapper.text()).toContain('Вопросы цели') expect(wrapper.text()).not.toContain('Цели внутри') }) + + it('рендерит блок Задачи и карточки из fetchGoalTasks', async () => { + const goalsApi = await import('@/api/goals') + vi.mocked(goalsApi.fetchGoalById).mockResolvedValue(makeRegularGoal()) + vi.mocked(goalsApi.fetchGoalTasks).mockResolvedValue([ + { id: 't-1', title: 'Пробежать 5 км', completed: false, goalIds: [1] }, + ]) + + const wrapper = mount(GoalView, { + global: { + stubs: { + ...stubs, + TaskCard: { props: ['task'], template: '
{{ task.title }}
' }, + }, + }, + }) + await flushPromises() + + expect(wrapper.find('[data-testid="goal-tasks"]').exists()).toBe(true) + expect(wrapper.text()).toContain('Задачи') + expect(wrapper.text()).toContain('Пробежать 5 км') + }) }) const SERVER_TYPES: QuestionTypeOption[] = [ @@ -209,7 +238,7 @@ function mountGoal() { return mount(GoalView, { attachTo: document.body, global: { - stubs: { AppHeader: true }, + stubs: { AppHeader: true, TaskForm: true, TaskCard: true, TaskDetailDialog: true }, }, }) } diff --git a/alfy-bot/src/app.module.ts b/alfy-bot/src/app.module.ts index 9c2b1d2..43b8647 100644 --- a/alfy-bot/src/app.module.ts +++ b/alfy-bot/src/app.module.ts @@ -31,6 +31,7 @@ import { Project, ProjectColumn, ApiToken, + Link, } from './shared/entities'; import { ScheduleMigrationService } from './shared/database/schedule-migration.service'; import { QuestionMigrationService } from './shared/database/question-migration.service'; @@ -69,6 +70,7 @@ const telegramImports = isTelegramEnabled() Project, ProjectColumn, ApiToken, + Link, ], synchronize: true, }), diff --git a/alfy-bot/src/modules/goal/dto/set-goal-tasks.dto.ts b/alfy-bot/src/modules/goal/dto/set-goal-tasks.dto.ts new file mode 100644 index 0000000..9e45708 --- /dev/null +++ b/alfy-bot/src/modules/goal/dto/set-goal-tasks.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsUUID } from 'class-validator'; + +export class SetGoalTasksDto { + @ApiProperty({ + type: [String], + example: ['550e8400-e29b-41d4-a716-446655440000'], + }) + @IsArray() + @IsUUID('4', { each: true }) + taskIds: string[]; +} diff --git a/alfy-bot/src/modules/goal/goal.controller.spec.ts b/alfy-bot/src/modules/goal/goal.controller.spec.ts index 0135929..477c546 100644 --- a/alfy-bot/src/modules/goal/goal.controller.spec.ts +++ b/alfy-bot/src/modules/goal/goal.controller.spec.ts @@ -9,6 +9,7 @@ import { AddQuestionsDto } from './dto/add-questions.dto'; import { CreateGoalDto } from './dto/create-goal.dto'; import { UpdateGoalDto } from './dto/update-goal.dto'; import { GoalController } from './goal.controller'; +import { TaskGoalQueryPort } from '../task/domain/task-goal-query.port'; type AuthRequestLike = { user: { sub: number } }; @@ -67,7 +68,16 @@ describe('GoalController', () => { const module: TestingModule = await Test.createTestingModule({ controllers: [GoalController], - providers: [{ provide: GoalService, useValue: goalService }], + providers: [ + { provide: GoalService, useValue: goalService }, + { + provide: TaskGoalQueryPort, + useValue: { + listByGoal: jest.fn(), + replaceTaskLinksForGoal: jest.fn(), + }, + }, + ], }) .overrideGuard(JwtOrApiTokenGuard) .useValue({ canActivate: () => true }) diff --git a/alfy-bot/src/modules/goal/goal.controller.ts b/alfy-bot/src/modules/goal/goal.controller.ts index 61a290d..5def601 100644 --- a/alfy-bot/src/modules/goal/goal.controller.ts +++ b/alfy-bot/src/modules/goal/goal.controller.ts @@ -8,6 +8,7 @@ import { ParseIntPipe, Patch, Post, + Put, Query, Request, UseGuards, @@ -31,7 +32,9 @@ import { GoalService } from './application/goal.service'; import { AddQuestionsDto } from './dto/add-questions.dto'; import { CreateGoalDto } from './dto/create-goal.dto'; import { GoalDto, QuestionDto } from './dto/goal-response.dto'; +import { SetGoalTasksDto } from './dto/set-goal-tasks.dto'; import { UpdateGoalDto } from './dto/update-goal.dto'; +import { TaskGoalQueryPort } from '../task/domain/task-goal-query.port'; interface AuthRequest extends Request { user: JwtPayload; @@ -42,7 +45,10 @@ interface AuthRequest extends Request { @UseGuards(JwtOrApiTokenGuard) @Controller('goals') export class GoalController { - constructor(private readonly goalService: GoalService) {} + constructor( + private readonly goalService: GoalService, + private readonly taskGoals: TaskGoalQueryPort, + ) {} @Get() @ApiOperation({ summary: 'Список целей пользователя' }) @@ -80,6 +86,35 @@ export class GoalController { return this.goalService.findAllByUser(userId, scope) as Promise; } + @Get(':id/tasks') + @ApiOperation({ summary: 'Задачи, привязанные к цели' }) + @ApiOkResponse({ description: 'Список задач цели' }) + @ApiNotFoundResponse({ description: 'Цель не найдена' }) + async listTasks( + @Request() req: AuthRequest, + @Param('id', ParseIntPipe) id: number, + ) { + await this.assertOwnedGoal(req, id); + return this.taskGoals.listByGoal(req.user.sub, id); + } + + @Put(':id/tasks') + @ApiOperation({ summary: 'Заменить набор задач цели' }) + @ApiOkResponse({ description: '{ taskIds }' }) + @ApiNotFoundResponse({ description: 'Цель или задача не найдена' }) + async setTasks( + @Request() req: AuthRequest, + @Param('id', ParseIntPipe) id: number, + @Body() dto: SetGoalTasksDto, + ) { + await this.assertOwnedGoal(req, id); + return this.taskGoals.replaceTaskLinksForGoal( + req.user.sub, + id, + dto.taskIds, + ); + } + @Get(':id') @ApiOperation({ summary: 'Цель по ID с вопросами и расписаниями' }) @ApiOkResponse({ type: GoalDto }) diff --git a/alfy-bot/src/modules/goal/goal.module.ts b/alfy-bot/src/modules/goal/goal.module.ts index 5745555..1a77759 100644 --- a/alfy-bot/src/modules/goal/goal.module.ts +++ b/alfy-bot/src/modules/goal/goal.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Goal, Question, Schedule } from '../../shared/entities'; import { AuthModule } from '../auth/auth.module'; +import { TaskModule } from '../task/task.module'; import { GoalService } from './application/goal.service'; import { ScheduleService } from './application/schedule.service'; import { GoalRepositoryPort } from './domain/goal-repository.port'; @@ -11,7 +12,11 @@ import { TypeOrmGoalRepository } from './infrastructure/typeorm-goal.repository' import { TypeOrmScheduleRepository } from './infrastructure/typeorm-schedule.repository'; @Module({ - imports: [TypeOrmModule.forFeature([Goal, Question, Schedule]), AuthModule], + imports: [ + TypeOrmModule.forFeature([Goal, Question, Schedule]), + AuthModule, + TaskModule, + ], controllers: [GoalController], providers: [ { provide: GoalRepositoryPort, useClass: TypeOrmGoalRepository }, diff --git a/alfy-bot/src/modules/task/domain/task-goal-query.port.ts b/alfy-bot/src/modules/task/domain/task-goal-query.port.ts new file mode 100644 index 0000000..1852f00 --- /dev/null +++ b/alfy-bot/src/modules/task/domain/task-goal-query.port.ts @@ -0,0 +1,11 @@ +import { Task } from '../../../shared/entities'; + +export abstract class TaskGoalQueryPort { + abstract listByGoal(userId: number, goalId: number): Promise; + + abstract replaceTaskLinksForGoal( + userId: number, + goalId: number, + taskIds: string[], + ): Promise<{ taskIds: string[] }>; +} diff --git a/alfy-bot/src/modules/task/domain/task-link.port.ts b/alfy-bot/src/modules/task/domain/task-link.port.ts new file mode 100644 index 0000000..7f17d99 --- /dev/null +++ b/alfy-bot/src/modules/task/domain/task-link.port.ts @@ -0,0 +1,36 @@ +export const LINK_KIND_GOAL = 'goal'; + +export abstract class TaskLinkPort { + abstract findGoalIdsByTaskIds( + userId: number, + taskIds: string[], + ): Promise>; + + abstract replaceGoalLinks( + userId: number, + taskId: string, + goalIds: number[], + ): Promise; + + abstract findTaskIdsByGoal( + userId: number, + goalId: number, + ): Promise; + + abstract replaceTaskLinksForGoal( + userId: number, + goalId: number, + taskIds: string[], + ): Promise; + + abstract filterOwnedGoalIds( + userId: number, + goalIds: number[], + ): Promise; + + abstract copyGoalLinks( + userId: number, + fromTaskId: string, + toTaskId: string, + ): Promise; +} diff --git a/alfy-bot/src/modules/task/domain/task-repository.port.ts b/alfy-bot/src/modules/task/domain/task-repository.port.ts index 2955954..8315277 100644 --- a/alfy-bot/src/modules/task/domain/task-repository.port.ts +++ b/alfy-bot/src/modules/task/domain/task-repository.port.ts @@ -10,6 +10,7 @@ export type { ChecklistItem, ChecklistData }; export abstract class TaskRepositoryPort { abstract findAllByUser(userId: number): Promise; abstract findById(id: string, userId: number): Promise; + abstract findByIds(userId: number, ids: string[]): Promise; abstract create(task: Partial): Promise; abstract save(task: Task): Promise; abstract update( diff --git a/alfy-bot/src/modules/task/dto/create-task.dto.ts b/alfy-bot/src/modules/task/dto/create-task.dto.ts index 658012c..445f6ca 100644 --- a/alfy-bot/src/modules/task/dto/create-task.dto.ts +++ b/alfy-bot/src/modules/task/dto/create-task.dto.ts @@ -142,4 +142,10 @@ export class CreateTaskDto { @IsOptional() @IsNumber() longBreakInterval?: number; + + @ApiPropertyOptional({ type: [Number], example: [1, 2] }) + @IsOptional() + @IsArray() + @IsInt({ each: true }) + goalIds?: number[]; } diff --git a/alfy-bot/src/modules/task/dto/set-task-goals.dto.ts b/alfy-bot/src/modules/task/dto/set-task-goals.dto.ts new file mode 100644 index 0000000..4ba3bf1 --- /dev/null +++ b/alfy-bot/src/modules/task/dto/set-task-goals.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsInt } from 'class-validator'; + +export class SetTaskGoalsDto { + @ApiProperty({ type: [Number], example: [1, 2] }) + @IsArray() + @IsInt({ each: true }) + goalIds: number[]; +} diff --git a/alfy-bot/src/modules/task/dto/update-task.dto.ts b/alfy-bot/src/modules/task/dto/update-task.dto.ts index 0586480..a4716ba 100644 --- a/alfy-bot/src/modules/task/dto/update-task.dto.ts +++ b/alfy-bot/src/modules/task/dto/update-task.dto.ts @@ -31,4 +31,5 @@ export class UpdateTaskDto extends PartialType(CreateTaskDto) { longBreakInterval?: never; pomodoroCompleted?: never; isOverdue?: never; + goalIds?: never; } diff --git a/alfy-bot/src/modules/task/infrastructure/typeorm-task-link.repository.ts b/alfy-bot/src/modules/task/infrastructure/typeorm-task-link.repository.ts new file mode 100644 index 0000000..de6c711 --- /dev/null +++ b/alfy-bot/src/modules/task/infrastructure/typeorm-task-link.repository.ts @@ -0,0 +1,128 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Not, Repository } from 'typeorm'; +import { Goal, Link } from '../../../shared/entities'; +import { LINK_KIND_GOAL, TaskLinkPort } from '../domain/task-link.port'; + +@Injectable() +export class TypeOrmTaskLinkRepository extends TaskLinkPort { + constructor( + @InjectRepository(Link) + private readonly linkRepo: Repository, + @InjectRepository(Goal) + private readonly goalRepo: Repository, + ) { + super(); + } + + async findGoalIdsByTaskIds( + userId: number, + taskIds: string[], + ): Promise> { + const map = new Map(); + if (taskIds.length === 0) return map; + + const rows = await this.linkRepo.find({ + where: { + userId, + kind: LINK_KIND_GOAL, + taskId: In(taskIds), + }, + }); + + for (const row of rows) { + const list = map.get(row.taskId) ?? []; + list.push(row.targetId); + map.set(row.taskId, list); + } + return map; + } + + async replaceGoalLinks( + userId: number, + taskId: string, + goalIds: number[], + ): Promise { + await this.linkRepo.manager.transaction(async (em) => { + await em.delete(Link, { userId, taskId, kind: LINK_KIND_GOAL }); + if (goalIds.length === 0) return; + await em.save( + goalIds.map((targetId) => + em.create(Link, { + userId, + taskId, + kind: LINK_KIND_GOAL, + targetId, + }), + ), + ); + }); + } + + async findTaskIdsByGoal(userId: number, goalId: number): Promise { + const rows = await this.linkRepo.find({ + where: { userId, kind: LINK_KIND_GOAL, targetId: goalId }, + select: ['taskId'], + }); + return rows.map((r) => r.taskId); + } + + async replaceTaskLinksForGoal( + userId: number, + goalId: number, + taskIds: string[], + ): Promise { + await this.linkRepo.manager.transaction(async (em) => { + await em.delete(Link, { + userId, + kind: LINK_KIND_GOAL, + targetId: goalId, + }); + if (taskIds.length === 0) return; + await em.save( + taskIds.map((taskId) => + em.create(Link, { + userId, + taskId, + kind: LINK_KIND_GOAL, + targetId: goalId, + }), + ), + ); + }); + } + + async filterOwnedGoalIds( + userId: number, + goalIds: number[], + ): Promise { + if (goalIds.length === 0) return []; + const unique = [...new Set(goalIds)]; + const rows = await this.goalRepo.find({ + where: { user_id: userId, id: In(unique), status: Not('deleted') }, + select: ['id'], + }); + return rows.map((r) => r.id); + } + + async copyGoalLinks( + userId: number, + fromTaskId: string, + toTaskId: string, + ): Promise { + const rows = await this.linkRepo.find({ + where: { userId, taskId: fromTaskId, kind: LINK_KIND_GOAL }, + }); + if (rows.length === 0) return; + await this.linkRepo.save( + rows.map((row) => + this.linkRepo.create({ + userId, + taskId: toTaskId, + kind: LINK_KIND_GOAL, + targetId: row.targetId, + }), + ), + ); + } +} diff --git a/alfy-bot/src/modules/task/infrastructure/typeorm-task.repository.ts b/alfy-bot/src/modules/task/infrastructure/typeorm-task.repository.ts index a83bc81..e95442c 100644 --- a/alfy-bot/src/modules/task/infrastructure/typeorm-task.repository.ts +++ b/alfy-bot/src/modules/task/infrastructure/typeorm-task.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { IsNull, LessThan, Not, Repository } from 'typeorm'; +import { In, IsNull, LessThan, Not, Repository } from 'typeorm'; import { Task, PomodoroConfig } from '../../../shared/entities'; import { TaskRepositoryPort, @@ -33,6 +33,14 @@ export class TypeOrmTaskRepository extends TaskRepositoryPort { }); } + async findByIds(userId: number, ids: string[]): Promise { + if (ids.length === 0) return []; + return this.taskRepo.find({ + where: { userId, id: In(ids) }, + relations: ['pomodoroConfig'], + }); + } + async create(task: Partial): Promise { const entity = this.taskRepo.create(task); return this.taskRepo.save(entity); diff --git a/alfy-bot/src/modules/task/task.controller.ts b/alfy-bot/src/modules/task/task.controller.ts index 9eaafff..a72ad1b 100644 --- a/alfy-bot/src/modules/task/task.controller.ts +++ b/alfy-bot/src/modules/task/task.controller.ts @@ -20,6 +20,7 @@ import { OverdueRecurringService } from './overdue-recurring.service'; import { UserSettingsPort } from './domain/user-settings.port'; import { CreateTaskDto } from './dto/create-task.dto'; import { UpdateTaskDto } from './dto/update-task.dto'; +import { SetTaskGoalsDto } from './dto/set-task-goals.dto'; import { UpdateChecklistDto } from './dto/update-checklist.dto'; import { UpdatePomodoroConfigDto } from './dto/update-pomodoro-config.dto'; import { UpsertTimerSessionDto } from './dto/upsert-timer-session.dto'; @@ -122,6 +123,16 @@ export class TaskController { return this.taskService.updateChecklist(req.user.sub, id, dto); } + @Put(':id/goals') + @ApiOperation({ summary: 'Заменить набор целей задачи' }) + async setGoals( + @Request() req: AuthRequest, + @Param('id') id: string, + @Body() dto: SetTaskGoalsDto, + ) { + return this.taskService.replaceGoalLinks(req.user.sub, id, dto.goalIds); + } + @Put(':id/pomodoro-config') @ApiOperation({ summary: 'Обновить/создать помодоро-конфигурацию' }) async updatePomodoroConfig( diff --git a/alfy-bot/src/modules/task/task.module.ts b/alfy-bot/src/modules/task/task.module.ts index 31143e1..1e7d08b 100644 --- a/alfy-bot/src/modules/task/task.module.ts +++ b/alfy-bot/src/modules/task/task.module.ts @@ -5,16 +5,20 @@ import { PomodoroConfig, TimerSession, User, + Link, + Goal, } from '../../shared/entities'; import { AuthModule } from '../auth/auth.module'; import { UserModule } from '../user/user.module'; import { NotificationModule } from '../notification/notification.module'; import { EventsModule } from '../events/events.module'; import { TaskRepositoryPort } from './domain/task-repository.port'; +import { TaskLinkPort } from './domain/task-link.port'; import { TimerSessionRepositoryPort } from './domain/timer-session-repository.port'; import { NotificationPort } from './domain/notification.port'; import { TelegramUserLookupPort } from './domain/telegram-user-lookup.port'; import { TypeOrmTaskRepository } from './infrastructure/typeorm-task.repository'; +import { TypeOrmTaskLinkRepository } from './infrastructure/typeorm-task-link.repository'; import { UserSettingsPort } from './domain/user-settings.port'; import { TypeOrmUserSettingsAdapter } from './infrastructure/typeorm-user-settings.adapter'; import { TypeOrmTimerSessionRepository } from './infrastructure/typeorm-timer-session.repository'; @@ -29,6 +33,7 @@ import { TaskService } from './task.service'; import { TimerSessionService } from './timer-session.service'; import { OverdueRecurringService } from './overdue-recurring.service'; import { TaskController } from './task.controller'; +import { TaskGoalQueryPort } from './domain/task-goal-query.port'; const notificationProviders = isTelegramEnabled() ? [ @@ -39,7 +44,14 @@ const notificationProviders = isTelegramEnabled() @Module({ imports: [ - TypeOrmModule.forFeature([Task, PomodoroConfig, TimerSession, User]), + TypeOrmModule.forFeature([ + Task, + PomodoroConfig, + TimerSession, + User, + Link, + Goal, + ]), AuthModule, UserModule, NotificationModule, @@ -48,6 +60,7 @@ const notificationProviders = isTelegramEnabled() controllers: [TaskController], providers: [ { provide: TaskRepositoryPort, useClass: TypeOrmTaskRepository }, + { provide: TaskLinkPort, useClass: TypeOrmTaskLinkRepository }, { provide: TimerSessionRepositoryPort, useClass: TypeOrmTimerSessionRepository, @@ -63,7 +76,8 @@ const notificationProviders = isTelegramEnabled() TimerExpiryScheduler, OverdueRecurringService, OverdueRecurringScheduler, + { provide: TaskGoalQueryPort, useExisting: TaskService }, ], - exports: [TaskService, TaskRepositoryPort], + exports: [TaskService, TaskRepositoryPort, TaskGoalQueryPort], }) export class TaskModule {} diff --git a/alfy-bot/src/modules/task/task.service.spec.ts b/alfy-bot/src/modules/task/task.service.spec.ts index 4e3243e..e3f96f1 100644 --- a/alfy-bot/src/modules/task/task.service.spec.ts +++ b/alfy-bot/src/modules/task/task.service.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { TaskService } from './task.service'; import { TaskRepositoryPort } from './domain/task-repository.port'; +import { TaskLinkPort } from './domain/task-link.port'; import { UserSettingsPort } from './domain/user-settings.port'; import { Task, PomodoroConfig } from '../../shared/entities'; import type { RecurrenceRule } from '../../shared/types/recurrence.types'; @@ -55,11 +56,13 @@ function makePomodoroConfig( describe('TaskService', () => { let service: TaskService; let repo: Record; + let linkPort: Record; beforeEach(async () => { repo = { findAllByUser: jest.fn().mockResolvedValue([]), findById: jest.fn().mockResolvedValue(null), + findByIds: jest.fn().mockResolvedValue([]), create: jest .fn() .mockImplementation((data) => Promise.resolve(makeTask(data))), @@ -90,6 +93,19 @@ describe('TaskService', () => { }), }; + linkPort = { + findGoalIdsByTaskIds: jest.fn().mockResolvedValue(new Map()), + replaceGoalLinks: jest.fn().mockResolvedValue(undefined), + findTaskIdsByGoal: jest.fn().mockResolvedValue([]), + replaceTaskLinksForGoal: jest.fn().mockResolvedValue(undefined), + filterOwnedGoalIds: jest + .fn() + .mockImplementation((_userId: number, ids: number[]) => + Promise.resolve([...new Set(ids)]), + ), + copyGoalLinks: jest.fn().mockResolvedValue(undefined), + }; + const userSettings = { getTimezone: jest.fn().mockResolvedValue('UTC'), }; @@ -98,6 +114,7 @@ describe('TaskService', () => { providers: [ TaskService, { provide: TaskRepositoryPort, useValue: repo }, + { provide: TaskLinkPort, useValue: linkPort }, { provide: UserSettingsPort, useValue: userSettings }, ], }).compile(); @@ -1330,4 +1347,125 @@ describe('TaskService', () => { expect(repo.deleteByParentId).not.toHaveBeenCalled(); }); }); + + describe('goal links', () => { + it('getAll навешивает goalIds без дублей задач', async () => { + const a = makeTask({ id: 'a', title: 'A' }); + const b = makeTask({ id: 'b', title: 'B' }); + repo.findAllByUser.mockResolvedValue([a, b]); + linkPort.findGoalIdsByTaskIds.mockResolvedValue( + new Map([ + ['a', [1, 2, 3]], + ['b', []], + ]), + ); + + const result = await service.getAll(1); + + expect(result).toHaveLength(2); + expect(result[0]!.goalIds).toEqual([1, 2, 3]); + expect(result[1]!.goalIds).toEqual([]); + expect(linkPort.findGoalIdsByTaskIds).toHaveBeenCalledWith(1, ['a', 'b']); + }); + + it('replaceGoalLinks заменяет набор', async () => { + repo.findById.mockResolvedValue(makeTask({ id: 'task-1' })); + + const result = await service.replaceGoalLinks(1, 'task-1', [2, 1, 2]); + + expect(linkPort.filterOwnedGoalIds).toHaveBeenCalledWith(1, [2, 1]); + expect(linkPort.replaceGoalLinks).toHaveBeenCalledWith(1, 'task-1', [ + 2, 1, + ]); + expect(result).toEqual({ goalIds: [2, 1] }); + }); + + it('replaceGoalLinks([]) снимает все связи', async () => { + repo.findById.mockResolvedValue(makeTask({ id: 'task-1' })); + + const result = await service.replaceGoalLinks(1, 'task-1', []); + + expect(linkPort.replaceGoalLinks).toHaveBeenCalledWith(1, 'task-1', []); + expect(result).toEqual({ goalIds: [] }); + }); + + it('чужая цель → 404, replace не вызывается', async () => { + repo.findById.mockResolvedValue(makeTask({ id: 'task-1' })); + linkPort.filterOwnedGoalIds.mockResolvedValue([1]); + + await expect( + service.replaceGoalLinks(1, 'task-1', [1, 99]), + ).rejects.toBeInstanceOf(NotFoundException); + expect(linkPort.replaceGoalLinks).not.toHaveBeenCalled(); + }); + + it('виртуальный id → 400', async () => { + await expect( + service.replaceGoalLinks(1, 'task-1__virtual__123', [1]), + ).rejects.toBeInstanceOf(BadRequestException); + expect(linkPort.replaceGoalLinks).not.toHaveBeenCalled(); + }); + + it('create с goalIds линкует после создания', async () => { + const created = await service.create(1, { + title: 'С целью', + goalIds: [7], + }); + + const arg = repo.create.mock.calls[0][0] as Partial; + expect(arg).not.toHaveProperty('goalIds'); + expect(linkPort.replaceGoalLinks).toHaveBeenCalledWith(1, created.id, [ + 7, + ]); + }); + + it('create с чужой целью не создаёт задачу', async () => { + linkPort.filterOwnedGoalIds.mockResolvedValue([]); + + await expect( + service.create(1, { title: 'Nope', goalIds: [99] }), + ).rejects.toBeInstanceOf(NotFoundException); + expect(repo.create).not.toHaveBeenCalled(); + }); + + it('complete recurring копирует goal-links на новый инстанс', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-04-05T10:00:00.000Z')); + const task = makeTask({ + id: 'root-1', + recurrence: { + frequency: 'daily', + interval: 1, + }, + dueDate: new Date('2026-04-05T10:00:00.000Z'), + completed: false, + recurringParentId: null, + recurringCompletedCount: 0, + }); + repo.findById.mockResolvedValue(task); + repo.findByParentId.mockResolvedValue([task]); + + await service.update(1, 'root-1', { completed: true }); + + expect(linkPort.copyGoalLinks).toHaveBeenCalledWith( + 1, + 'root-1', + expect.any(String), + ); + jest.useRealTimers(); + }); + + it('listByGoal 404 если цель чужая', async () => { + linkPort.filterOwnedGoalIds.mockResolvedValue([]); + await expect(service.listByGoal(1, 99)).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('replaceTaskLinksForGoal пустой набор снимает связи', async () => { + const result = await service.replaceTaskLinksForGoal(1, 5, []); + expect(linkPort.replaceTaskLinksForGoal).toHaveBeenCalledWith(1, 5, []); + expect(result).toEqual({ taskIds: [] }); + }); + }); }); diff --git a/alfy-bot/src/modules/task/task.service.ts b/alfy-bot/src/modules/task/task.service.ts index aab3d3b..ac80ba3 100644 --- a/alfy-bot/src/modules/task/task.service.ts +++ b/alfy-bot/src/modules/task/task.service.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import { Task, PomodoroConfig } from '../../shared/entities'; import { TaskRepositoryPort } from './domain/task-repository.port'; +import { TaskLinkPort } from './domain/task-link.port'; import { CreateTaskDto } from './dto/create-task.dto'; import { UpdateTaskDto } from './dto/update-task.dto'; import { MaterializeOccurrenceDto } from './dto/materialize-occurrence.dto'; @@ -53,10 +54,12 @@ export class TaskService { constructor( private readonly taskRepo: TaskRepositoryPort, private readonly userSettings: UserSettingsPort, + private readonly linkPort: TaskLinkPort, ) {} async getAll(userId: number): Promise { - return this.taskRepo.findAllByUser(userId); + const tasks = await this.taskRepo.findAllByUser(userId); + return this.attachGoalIds(userId, tasks); } async create(userId: number, dto: CreateTaskDto): Promise { @@ -71,6 +74,7 @@ export class TaskService { deadline, checklist, recurrence, + goalIds, ...rest } = dto; @@ -95,7 +99,15 @@ export class TaskService { taskData.pomodoroConfig = config; } - return this.taskRepo.create(taskData); + const uniqueGoalIds = goalIds?.length + ? await this.assertOwnedGoals(userId, goalIds) + : []; + + const created = await this.taskRepo.create(taskData); + if (uniqueGoalIds.length) { + await this.linkPort.replaceGoalLinks(userId, created.id, uniqueGoalIds); + } + return this.attachGoalIdsOne(userId, created); } async materializeOccurrence( @@ -176,7 +188,9 @@ export class TaskService { ); } - return this.taskRepo.create(instanceData); + const created = await this.taskRepo.create(instanceData); + await this.linkPort.copyGoalLinks(userId, source.id, created.id); + return this.attachGoalIdsOne(userId, created); } async update( @@ -252,7 +266,7 @@ export class TaskService { } const saved = await this.taskRepo.save(task); - return { task: saved }; + return this.decorateUpdate(userId, { task: saved }); } private async retargetSubsequentRecurrence( @@ -350,6 +364,7 @@ export class TaskService { ); } nextInstance = await this.taskRepo.create(instanceData); + await this.linkPort.copyGoalLinks(userId, task.id, nextInstance.id); } if (rootTask) { @@ -365,7 +380,7 @@ export class TaskService { task.completed = true; const saved = await this.taskRepo.save(task); - return { task: saved, nextInstance }; + return this.decorateUpdate(userId, { task: saved, nextInstance }); } private async uncompleteRecurringTask( @@ -407,7 +422,10 @@ export class TaskService { } const saved = await this.taskRepo.save(task); - return { task: saved, nextInstance: promotedInstance }; + return this.decorateUpdate(userId, { + task: saved, + nextInstance: promotedInstance, + }); } async updatePomodoroConfig( @@ -417,7 +435,10 @@ export class TaskService { ): Promise { const task = await this.taskRepo.findById(taskId, userId); if (!task) throw new NotFoundException(`Task #${taskId} not found`); - return this.taskRepo.updatePomodoroConfig(task, dto); + return this.attachGoalIdsOne( + userId, + await this.taskRepo.updatePomodoroConfig(task, dto), + ); } async updateChecklist( @@ -427,7 +448,10 @@ export class TaskService { ): Promise { const task = await this.taskRepo.findById(taskId, userId); if (!task) throw new NotFoundException(`Task #${taskId} not found`); - return this.taskRepo.updateChecklist(task, { items: dto.items }); + return this.attachGoalIdsOne( + userId, + await this.taskRepo.updateChecklist(task, { items: dto.items }), + ); } async incrementPomodoro( @@ -460,7 +484,7 @@ export class TaskService { return this.update(userId, taskId, { completed: true }); } - return { task: refreshed }; + return this.decorateUpdate(userId, { task: refreshed }); } async delete(userId: number, id: string): Promise { @@ -561,6 +585,101 @@ export class TaskService { task.projectId = null; task.columnId = null; task.order = order; - return this.taskRepo.save(task); + return this.attachGoalIdsOne(userId, await this.taskRepo.save(task)); + } + + async replaceGoalLinks( + userId: number, + taskId: string, + goalIds: number[], + ): Promise<{ goalIds: number[] }> { + this.assertNotVirtual(taskId); + const task = await this.taskRepo.findById(taskId, userId); + if (!task) throw new NotFoundException(`Task #${taskId} not found`); + + const unique = await this.assertOwnedGoals(userId, goalIds); + await this.linkPort.replaceGoalLinks(userId, taskId, unique); + return { goalIds: unique }; + } + + async listByGoal(userId: number, goalId: number): Promise { + const owned = await this.linkPort.filterOwnedGoalIds(userId, [goalId]); + if (owned.length === 0) { + throw new NotFoundException(`Goal #${goalId} not found`); + } + const taskIds = await this.linkPort.findTaskIdsByGoal(userId, goalId); + const tasks = await this.taskRepo.findByIds(userId, taskIds); + return this.attachGoalIds(userId, tasks); + } + + async replaceTaskLinksForGoal( + userId: number, + goalId: number, + taskIds: string[], + ): Promise<{ taskIds: string[] }> { + const ownedGoals = await this.linkPort.filterOwnedGoalIds(userId, [goalId]); + if (ownedGoals.length === 0) { + throw new NotFoundException(`Goal #${goalId} not found`); + } + const unique = [...new Set(taskIds)]; + for (const id of unique) this.assertNotVirtual(id); + + if (unique.length > 0) { + const tasks = await this.taskRepo.findByIds(userId, unique); + if (tasks.length !== unique.length) { + throw new NotFoundException('Task not found'); + } + } + + await this.linkPort.replaceTaskLinksForGoal(userId, goalId, unique); + return { taskIds: unique }; + } + + private assertNotVirtual(id: string): void { + if (id.includes('__virtual__')) { + throw new BadRequestException( + 'Virtual task instances cannot be modified directly.', + ); + } + } + + private async assertOwnedGoals( + userId: number, + goalIds: number[], + ): Promise { + const unique = [...new Set(goalIds)]; + const owned = await this.linkPort.filterOwnedGoalIds(userId, unique); + if (owned.length !== unique.length) { + throw new NotFoundException('Goal not found'); + } + return unique; + } + + private async attachGoalIds(userId: number, tasks: Task[]): Promise { + if (tasks.length === 0) return tasks; + const map = await this.linkPort.findGoalIdsByTaskIds( + userId, + tasks.map((t) => t.id), + ); + for (const task of tasks) { + task.goalIds = map.get(task.id) ?? []; + } + return tasks; + } + + private async attachGoalIdsOne(userId: number, task: Task): Promise { + await this.attachGoalIds(userId, [task]); + return task; + } + + private async decorateUpdate( + userId: number, + response: UpdateTaskResponse, + ): Promise { + const tasks = [response.task, response.nextInstance].filter( + (t): t is Task => !!t, + ); + await this.attachGoalIds(userId, tasks); + return response; } } diff --git a/alfy-bot/src/shared/entities/index.ts b/alfy-bot/src/shared/entities/index.ts index 6f0e3f2..53909f0 100644 --- a/alfy-bot/src/shared/entities/index.ts +++ b/alfy-bot/src/shared/entities/index.ts @@ -12,3 +12,4 @@ export { PushSubscription } from './push-subscription.entity'; export { Project } from './project.entity'; export { ProjectColumn } from './project-column.entity'; export { ApiToken } from './api-token.entity'; +export { Link } from './link.entity'; diff --git a/alfy-bot/src/shared/entities/link.entity.ts b/alfy-bot/src/shared/entities/link.entity.ts new file mode 100644 index 0000000..ecc92ff --- /dev/null +++ b/alfy-bot/src/shared/entities/link.entity.ts @@ -0,0 +1,35 @@ +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Unique, +} from 'typeorm'; +import { Task } from './task.entity'; + +@Entity('links') +@Unique(['taskId', 'kind', 'targetId']) +@Index(['userId', 'kind', 'taskId']) +@Index(['userId', 'kind', 'targetId']) +export class Link { + @PrimaryGeneratedColumn() + id: number; + + @Column() + userId: number; + + @Column() + taskId: string; + + @Column({ type: 'text' }) + kind: string; + + @Column({ type: 'integer' }) + targetId: number; + + @ManyToOne(() => Task, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'taskId' }) + task: Task; +} diff --git a/alfy-bot/src/shared/entities/task.entity.ts b/alfy-bot/src/shared/entities/task.entity.ts index 38aa181..a88ae5f 100644 --- a/alfy-bot/src/shared/entities/task.entity.ts +++ b/alfy-bot/src/shared/entities/task.entity.ts @@ -116,4 +116,7 @@ export class Task { eager: true, }) pomodoroConfig: PomodoroConfig | null; + + /** Not a column — populated by TaskService when returning to clients. */ + goalIds?: number[]; } diff --git a/docs/tasks/task-goal-links.md b/docs/tasks/task-goal-links.md new file mode 100644 index 0000000..c297aef --- /dev/null +++ b/docs/tasks/task-goal-links.md @@ -0,0 +1,149 @@ +# Связь задач с целями + +**Status:** reviewing +**Branch:** feat/task-goal-links +**Worktree:** /Users/v/projects/Alfy +**Mode:** interactive + +## Design + +Двусторонняя связь задача ↔ цель. Привычки (`kind='question'`) вне скоупа. Завершение задачи не меняет `status`/`outcome` цели. Отвязка ≠ удаление задачи. Recurring: линк на живую строку, не на `__virtual__`. DnD на цели в сайдбаре нет. + +Таблица `links` (имя под будущие привычки), не колонка `goalId` на `tasks` и не `task_goals`: + +``` +links + task_id uuid + user_id int + kind text -- сейчас только 'goal' + target_id int -- goal id + UNIQUE(task_id, kind, target_id) +``` + +Индексы: `(user_id, kind, task_id)`, `(user_id, kind, target_id)`. + +`projectId` = полка (inbox/проект). Goal-link = стикер. Несколько целей на задачу ок. + +Загрузка `GET /tasks`: не TypeORM `relations` join (N целей → дубли строк). Два запроса: `findAllByUser`, затем `SELECT task_id, target_id FROM links WHERE kind='goal' AND user_id=? AND task_id IN (...)`, маппинг в `goalIds: number[]`. + +API отдельно от `PATCH /tasks/:id`: + +- `PUT /tasks/:id/goals` `{ goalIds: number[] }` — replace-set +- `GET /goals/:id/tasks` — задачи цели +- `PUT /goals/:id/tasks` `{ taskIds: uuid[] }` — тот же join +- `GET /tasks` отдаёт `goalIds: number[]` (`[]` если нет) +- `POST /tasks` опционально `goalIds` (создание с GoalView) + +Цикл модулей: `GoalModule` импортирует `TaskModule`. `TaskModule` не импортирует `GoalModule`. Ownership целей — через `TypeOrmModule.forFeature([Goal])` в адаптере линков. `synchronize: true`, файлов миграций нет. + +TDD: yes (replace-set, чужая цель/задача → 404, пустой набор снимает все связи, виртуальный id → 400, `GET /tasks` без дублей при 3 целях). + +### Invariants + +- Связь живёт только в `links` с `kind='goal'`. Колонки `goalId` на `tasks` нет. +- `UNIQUE(task_id, kind, target_id)`. Одна задача — много целей, одна цель — много задач. +- Все выборки линков фильтруют `user_id`. Чужой `goalId`/`taskId` → 404, набор не применяется частично. +- `PUT` — replace-set. `goalIds: []` / `taskIds: []` снимает связи, задачи/цели не удаляет. +- `__virtual__` id нельзя линковать (400). +- `GET /tasks` не джойнит `links` через TypeORM relations. `goalIds` навешивается отдельным запросом. +- Завершение задачи не трогает `Goal.status` / `Goal.outcome`. +- Новый живой инстанс recurring (complete → next) копирует goal-links со старой строки. +- `TaskModule` не импортирует `GoalModule`. +- Привычки/`kind='question'`, MCP-tools линков, автозакрытие цели, DnD на цели — не делаем. + +### Principles + +- Линк — отдельный ресурс, не поле `PATCH /tasks/:id`. +- Адаптер линков знает про таблицу `goals` только для ownership-проверки, не для бизнес-логики целей. +- Фронт читает `goalIds` с задачи; имена целей — только в пикере/диалоге. + +### Assumptions + +- `synchronize: true` создаст таблицу на старте. +- Цели со статусом `deleted` нельзя привязать; уже существующие орфан-id на карточке допустимы до следующего replace. +- `GET /tasks` уже кормит список и календарь — `goalIds` едет тем же payload. + +## Plan + +Approach: таблица `links` + порт в task-модуле; replace-set эндпоинты с двух сторон; фронт — пикер как ProjectPicker (мультичекбокс), бейдж/иконка, блок на GoalView. + +### PH1 — backend links API + +- **1.1** `alfy-bot/src/shared/entities/link.entity.ts` (create) + - `Link { id, userId, taskId, kind, targetId }` + Unique + индексы + `ManyToOne Task onDelete CASCADE` + - Respects: IV1, IV2 +- **1.2** `alfy-bot/src/shared/entities/index.ts`, `alfy-bot/src/app.module.ts:58-72` (modify) + - экспорт + регистрация `Link` в `entities` +- **1.3** `alfy-bot/src/shared/entities/task.entity.ts` (modify) + - виртуальное `goalIds?: number[]` (не колонка) +- **1.4** `alfy-bot/src/modules/task/domain/task-link.port.ts` (create) + - `TaskLinkPort.findGoalIdsByTaskIds(userId, taskIds) -> Map` + - `replaceGoalLinks(userId, taskId, goalIds) -> void` + - `findTaskIdsByGoal(userId, goalId) -> string[]` + - `replaceTaskLinksForGoal(userId, goalId, taskIds) -> void` + - `filterOwnedGoalIds(userId, goalIds) -> number[]` + - `copyGoalLinks(userId, fromTaskId, toTaskId) -> void` +- **1.5** `alfy-bot/src/modules/task/infrastructure/typeorm-task-link.repository.ts` (create) +- **1.6** `alfy-bot/src/modules/task/domain/task-repository.port.ts` + `typeorm-task.repository.ts` (modify) + - `findByIds(userId, ids) -> Task[]` +- **1.7** `alfy-bot/src/modules/task/dto/set-task-goals.dto.ts`, `create-task.dto.ts`, `update-task.dto.ts` (create/modify) + - `SetTaskGoalsDto { goalIds: number[] }` + - optional `goalIds` на create; `goalIds?: never` на update +- **1.8** `alfy-bot/src/modules/goal/dto/set-goal-tasks.dto.ts` (create) + - `SetGoalTasksDto { taskIds: string[] }` +- **1.9** `alfy-bot/src/modules/task/task.service.ts` (modify) + - `getAll`/`create`/`update`/`incrementPomodoro`/`materialize` навешивают `goalIds` + - `replaceGoalLinks`, `listByGoal`, `replaceTaskLinksForGoal` + - `completeRecurringTask` копирует линки на новый инстанс +- **1.10** `alfy-bot/src/modules/task/task.controller.ts` (modify) + - `PUT :id/goals` до generic `:id` +- **1.11** `alfy-bot/src/modules/goal/goal.controller.ts` + `goal.module.ts` (modify) + - `GET/PUT :id/tasks`; `GoalModule` imports `TaskModule` +- **1.12** `alfy-bot/src/modules/task/task.module.ts` (modify) + - `forFeature([Link, Goal])`, биндинг `TaskLinkPort` +- **1.13** `alfy-bot/src/modules/task/task.service.spec.ts` (modify) + - мок `TaskLinkPort` в `beforeEach` + - `describe('goal links')`: replace-set, empty unlink, чужая цель 404, virtual 400, getAll мапит goalIds без дублей, create с goalIds, copy на recurring complete +- Commit: `feat(tasks): link tasks to goals via links table` + +### PH2 — frontend picker / card / calendar / GoalView + +- **2.1** `alfy-bot-frontend/src/features/tasks/model/types.ts` — `goalIds?: number[]` +- **2.2** `task-store.ts` — `parseTask` + `setTaskGoals`; exclude `goalIds` из PATCH +- **2.3** `src/api/goals.ts` — `fetchGoalTasks`, `setGoalTasks` +- **2.4** `features/goals/ui/GoalPicker.vue` + `GoalPickerContent.vue` (create) + - как ProjectPicker, мультичекбокс, immediate PUT +- **2.5** `TaskDetailDialog.vue` + `TaskPropertyChips.vue` — ряд под проектом, drawer `goals` +- **2.6** `TaskCard.vue` — Target + «Цель» / «N цели» +- **2.7** `CalendarEventBlock.vue` — Target 10px рядом с Repeat/Flag +- **2.8** `views/GoalView.vue` — блок «Задачи»: список, создать с `goalIds`, привязать существующую, отвязать +- **2.9** тесты: `TaskCard.spec.ts`, `CalendarEventBlock.spec.ts`, `GoalView.spec.ts`; MSW `PUT /tasks/:id/goals` +- Commit: `feat(web): attach tasks to goals in dialog, card, calendar, GoalView` + +### Interfaces + +``` +TaskLinkPort + findGoalIdsByTaskIds(userId, taskIds) -> Map + replaceGoalLinks(userId, taskId, goalIds) + findTaskIdsByGoal(userId, goalId) -> taskIds + replaceTaskLinksForGoal(userId, goalId, taskIds) + filterOwnedGoalIds(userId, goalIds) -> ownedIds + copyGoalLinks(userId, fromTaskId, toTaskId) + +Task.goalIds?: number[] // virtual, JSON only +PUT /tasks/:id/goals { goalIds } -> { goalIds } +GET /goals/:id/tasks -> Task[] +PUT /goals/:id/tasks { taskIds } -> { taskIds } +POST /tasks goalIds?: number[] +``` + +### Interface graph + +``` +GoalController --TaskService--> TaskLinkPort +TaskController --TaskService--> TaskLinkPort +TaskService.getAll --> TaskRepositoryPort + TaskLinkPort.findGoalIdsByTaskIds +GoalPicker --> PUT /tasks/:id/goals --> taskStore.setTaskGoals +GoalView --> GET /goals/:id/tasks + POST /tasks(goalIds) +```