diff --git a/alfy-bot-frontend/src/api/goals.ts b/alfy-bot-frontend/src/api/goals.ts
index bc283f5..beb347c 100644
--- a/alfy-bot-frontend/src/api/goals.ts
+++ b/alfy-bot-frontend/src/api/goals.ts
@@ -27,6 +27,7 @@ export interface QuestionWithScheduleItem {
export interface UpdateGoalDto {
status?: 'active' | 'completed' | 'archived' | 'deleted'
+ outcome?: 'success' | 'failure' | null
goal_name?: string
parent_goal_id?: number | null
}
diff --git a/alfy-bot-frontend/src/components/GoalCard.vue b/alfy-bot-frontend/src/components/GoalCard.vue
index 850ddc5..8d260e1 100644
--- a/alfy-bot-frontend/src/components/GoalCard.vue
+++ b/alfy-bot-frontend/src/components/GoalCard.vue
@@ -50,7 +50,7 @@ function subGoalsLabel(n: number) {
-
+
-import type { GoalStatus } from '../types'
+import type { GoalOutcome, GoalStatus } from '../types'
-const props = defineProps<{ status: GoalStatus }>()
+const props = defineProps<{ status: GoalStatus, outcome?: GoalOutcome | null }>()
const config: Record = {
active: {
@@ -10,9 +10,9 @@ const config: Record
diff --git a/alfy-bot-frontend/src/types/index.ts b/alfy-bot-frontend/src/types/index.ts
index ee2dccb..727c4b8 100644
--- a/alfy-bot-frontend/src/types/index.ts
+++ b/alfy-bot-frontend/src/types/index.ts
@@ -1,4 +1,5 @@
export type GoalStatus = 'active' | 'completed' | 'archived' | 'deleted'
+export type GoalOutcome = 'success' | 'failure'
export type GoalType = 'SIMPLE' | 'SMART' | 'GLOBAL'
export type QuestionType = 'number' | 'text' | 'rating' | 'emoji_rating' | 'yes_no' | 'time_spent' | 'photo'
export type FrequencyType = 'daily' | 'weekly_days' | 'interval'
@@ -50,6 +51,7 @@ export interface Goal {
goal_start: string | null
goal_end: string | null
status: GoalStatus
+ outcome?: GoalOutcome | null
createdAt: string
is_global: boolean
parent_goal_id: number | null
diff --git a/alfy-bot-frontend/src/views/GoalView.vue b/alfy-bot-frontend/src/views/GoalView.vue
index 8716df0..7f3fde1 100644
--- a/alfy-bot-frontend/src/views/GoalView.vue
+++ b/alfy-bot-frontend/src/views/GoalView.vue
@@ -1,8 +1,8 @@
@@ -458,7 +477,7 @@ function onCancelGoalAction() {
-
+
+
@@ -566,7 +602,7 @@ function onCancelGoalAction() {
@@ -760,6 +796,43 @@ function onCancelGoalAction() {
+ { if (!o) completeOpen = false }"
+ >
+
+
+ Завершить цель?
+
+ Отметьте, чем закончилась цель. Её можно будет найти во вкладке «Завершённые».
+
+
+
+
+ Отмена
+
+
+
+
+
+
+
{ if (!o) goalActionPending = null }"
diff --git a/alfy-bot-frontend/tests/views/GoalView.spec.ts b/alfy-bot-frontend/tests/views/GoalView.spec.ts
index 54d6877..b401969 100644
--- a/alfy-bot-frontend/tests/views/GoalView.spec.ts
+++ b/alfy-bot-frontend/tests/views/GoalView.spec.ts
@@ -290,3 +290,69 @@ describe('goalView — добавление вопроса', () => {
wrapper.unmount()
})
})
+
+describe('goalView — завершение цели', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ setActivePinia(createPinia())
+ const store = useQuestionTypesStore()
+ store.types = SERVER_TYPES
+ store.loaded = true
+ routeParams.id = '5'
+ vi.mocked(fetchGoalById).mockResolvedValue(makeGoal())
+ vi.mocked(fetchGoalReportStatus).mockResolvedValue({
+ goalId: 5,
+ date: '2026-06-04',
+ lastUnfilledDate: null,
+ questions: [],
+ allDone: true,
+ })
+ })
+
+ it('Успех → PATCH status=completed, outcome=success', async () => {
+ const goalsApi = await import('@/api/goals')
+ vi.mocked(goalsApi.updateGoal).mockResolvedValue({
+ ...makeGoal(),
+ status: 'completed',
+ outcome: 'success',
+ })
+ const wrapper = mountGoal()
+ await flushPromises()
+
+ await wrapper.find('[data-testid="goal-complete-cta"]').trigger('click')
+ await flushPromises()
+
+ const success = document.body.querySelector('[data-testid="complete-success"]') as HTMLButtonElement
+ expect(success).toBeTruthy()
+ success.click()
+ await flushPromises()
+
+ expect(goalsApi.updateGoal).toHaveBeenCalledWith(5, {
+ status: 'completed',
+ outcome: 'success',
+ })
+ wrapper.unmount()
+ })
+
+ it('Неудача → PATCH outcome=failure', async () => {
+ const goalsApi = await import('@/api/goals')
+ vi.mocked(goalsApi.updateGoal).mockResolvedValue({
+ ...makeGoal(),
+ status: 'completed',
+ outcome: 'failure',
+ })
+ const wrapper = mountGoal()
+ await flushPromises()
+
+ await wrapper.find('[data-testid="goal-complete-cta"]').trigger('click')
+ await flushPromises()
+ ;(document.body.querySelector('[data-testid="complete-failure"]') as HTMLButtonElement).click()
+ await flushPromises()
+
+ expect(goalsApi.updateGoal).toHaveBeenCalledWith(5, {
+ status: 'completed',
+ outcome: 'failure',
+ })
+ wrapper.unmount()
+ })
+})
diff --git a/alfy-bot/src/modules/bot/scenes/list-goals.scene.ts b/alfy-bot/src/modules/bot/scenes/list-goals.scene.ts
index 3e4cbb9..710f93e 100644
--- a/alfy-bot/src/modules/bot/scenes/list-goals.scene.ts
+++ b/alfy-bot/src/modules/bot/scenes/list-goals.scene.ts
@@ -411,6 +411,11 @@ ${periodLine}📊 Статус: ${statusLabel}
status: GoalStatus,
) {
await this.goalService.updateGoalStatus(goalId, status);
+ if (status === GOAL_STATUSES.COMPLETED) {
+ await this.goalService.update(goalId, { outcome: 'success' });
+ } else if (status === GOAL_STATUSES.ACTIVE) {
+ await this.goalService.update(goalId, { outcome: null });
+ }
let message = '';
switch (status) {
diff --git a/alfy-bot/src/modules/goal/dto/goal-response.dto.ts b/alfy-bot/src/modules/goal/dto/goal-response.dto.ts
index d81f9bf..88de566 100644
--- a/alfy-bot/src/modules/goal/dto/goal-response.dto.ts
+++ b/alfy-bot/src/modules/goal/dto/goal-response.dto.ts
@@ -94,9 +94,17 @@ export class GoalDto {
@ApiPropertyOptional({ example: '2026-05-01', nullable: true })
goal_end: string | null;
- @ApiProperty({ example: 'active', enum: ['active', 'completed', 'deleted'] })
+ @ApiProperty({ example: 'active', enum: ['active', 'completed', 'archived', 'deleted'] })
status: string;
+ @ApiPropertyOptional({
+ example: 'success',
+ enum: ['success', 'failure'],
+ nullable: true,
+ description: 'Результат завершения: успех или неудача. null пока цель активна',
+ })
+ outcome: 'success' | 'failure' | null;
+
@ApiProperty({
example: false,
description: 'Глобальная цель (без дат и вопросов, может иметь подцели)',
diff --git a/alfy-bot/src/modules/goal/dto/update-goal.dto.ts b/alfy-bot/src/modules/goal/dto/update-goal.dto.ts
index 119b28b..89d96b1 100644
--- a/alfy-bot/src/modules/goal/dto/update-goal.dto.ts
+++ b/alfy-bot/src/modules/goal/dto/update-goal.dto.ts
@@ -16,6 +16,16 @@ export class UpdateGoalDto {
@IsString()
goal_name?: string;
+ @ApiPropertyOptional({
+ example: 'success',
+ enum: ['success', 'failure'],
+ nullable: true,
+ description: 'Флаг завершения. Шлётся вместе со status=completed',
+ })
+ @IsOptional()
+ @IsIn(['success', 'failure'])
+ outcome?: 'success' | 'failure' | null;
+
@ApiPropertyOptional({
example: 1,
description:
diff --git a/alfy-bot/src/modules/goal/goal.controller.spec.ts b/alfy-bot/src/modules/goal/goal.controller.spec.ts
index 6da4aab..0135929 100644
--- a/alfy-bot/src/modules/goal/goal.controller.spec.ts
+++ b/alfy-bot/src/modules/goal/goal.controller.spec.ts
@@ -407,6 +407,37 @@ describe('GoalController', () => {
expect(result).toBe(updated);
});
+ it('status=completed + outcome=failure пишет оба поля', async () => {
+ const userId = 42;
+ const goalId = 7;
+ const owned = makeGoal({ id: goalId, user_id: userId, status: 'active' });
+ const updated = makeGoal({
+ id: goalId,
+ user_id: userId,
+ status: 'completed',
+ outcome: 'failure',
+ });
+ goalService.findById
+ .mockResolvedValueOnce(owned)
+ .mockResolvedValueOnce(updated);
+ goalService.updateGoalStatus.mockResolvedValue(undefined);
+ goalService.update.mockResolvedValue(updated);
+
+ const dto: UpdateGoalDto = { status: 'completed', outcome: 'failure' };
+ const req = { user: { sub: userId } } as AuthRequestLike;
+
+ const result = await controller.update(req as never, goalId, dto);
+
+ expect(goalService.updateGoalStatus).toHaveBeenCalledWith(
+ goalId,
+ 'completed',
+ );
+ expect(goalService.update).toHaveBeenCalledWith(goalId, {
+ outcome: 'failure',
+ });
+ expect(result).toBe(updated);
+ });
+
it('валидный parent_goal_id → assertValidParent + update', async () => {
const userId = 42;
const goalId = 7;
diff --git a/alfy-bot/src/modules/goal/goal.controller.ts b/alfy-bot/src/modules/goal/goal.controller.ts
index e7a71c1..61a290d 100644
--- a/alfy-bot/src/modules/goal/goal.controller.ts
+++ b/alfy-bot/src/modules/goal/goal.controller.ts
@@ -196,6 +196,12 @@ export class GoalController {
if (dto.status) {
await this.goalService.updateGoalStatus(id, dto.status);
+ if (dto.status !== 'completed') {
+ await this.goalService.update(id, { outcome: null });
+ }
+ }
+ if (dto.outcome !== undefined) {
+ await this.goalService.update(id, { outcome: dto.outcome });
}
if (dto.goal_name) {
await this.goalService.update(id, { goal_name: dto.goal_name });
diff --git a/alfy-bot/src/shared/entities/goal.entity.ts b/alfy-bot/src/shared/entities/goal.entity.ts
index b154911..91a1a55 100644
--- a/alfy-bot/src/shared/entities/goal.entity.ts
+++ b/alfy-bot/src/shared/entities/goal.entity.ts
@@ -30,6 +30,10 @@ export class Goal {
@Column({ default: false })
is_global: boolean;
+ /** Set when status becomes completed: success | failure. Null while active. */
+ @Column({ type: 'text', nullable: true })
+ outcome: 'success' | 'failure' | null;
+
@Column({ type: 'integer', nullable: true })
parent_goal_id: number | null;