fix(tasks): auto-normalize assignment status transitions

This commit is contained in:
Nyk 2026-03-05 11:13:48 +07:00
parent e8623ac7f0
commit 4e3b52c06c
4 changed files with 107 additions and 11 deletions

View File

@ -6,6 +6,7 @@ import { mutationLimiter } from '@/lib/rate-limit';
import { logger } from '@/lib/logger';
import { validateBody, updateTaskSchema } from '@/lib/validation';
import { resolveMentionRecipients } from '@/lib/mentions';
import { normalizeTaskUpdateStatus } from '@/lib/task-status';
function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
@ -115,7 +116,7 @@ export async function PUT(
const {
title,
description,
status,
status: requestedStatus,
priority,
project_id,
assigned_to,
@ -125,6 +126,12 @@ export async function PUT(
tags,
metadata
} = body;
const normalizedStatus = normalizeTaskUpdateStatus({
currentStatus: currentTask.status,
requestedStatus,
assignedTo: assigned_to,
assignedToProvided: assigned_to !== undefined,
})
const now = Math.floor(Date.now() / 1000);
const descriptionMentionResolution = description !== undefined
@ -152,15 +159,15 @@ export async function PUT(
fieldsToUpdate.push('description = ?');
updateParams.push(description);
}
if (status !== undefined) {
if (status === 'done' && !hasAegisApproval(db, taskId, workspaceId)) {
if (normalizedStatus !== undefined) {
if (normalizedStatus === 'done' && !hasAegisApproval(db, taskId, workspaceId)) {
return NextResponse.json(
{ error: 'Aegis approval is required to move task to done.' },
{ status: 403 }
)
}
fieldsToUpdate.push('status = ?');
updateParams.push(status);
updateParams.push(normalizedStatus);
}
if (priority !== undefined) {
fieldsToUpdate.push('priority = ?');
@ -240,8 +247,8 @@ export async function PUT(
// Track changes and log activities
const changes: string[] = [];
if (status && status !== currentTask.status) {
changes.push(`status: ${currentTask.status}${status}`);
if (normalizedStatus !== undefined && normalizedStatus !== currentTask.status) {
changes.push(`status: ${currentTask.status}${normalizedStatus}`);
// Create notification for status change if assigned
if (currentTask.assigned_to) {
@ -249,7 +256,7 @@ export async function PUT(
currentTask.assigned_to,
'status_change',
'Task Status Updated',
`Task "${currentTask.title}" status changed to ${status}`,
`Task "${currentTask.title}" status changed to ${normalizedStatus}`,
'task',
taskId,
workspaceId
@ -322,7 +329,7 @@ export async function PUT(
priority: currentTask.priority,
assigned_to: currentTask.assigned_to
},
newValues: { title, status, priority, assigned_to }
newValues: { title, status: normalizedStatus ?? currentTask.status, priority, assigned_to }
},
workspaceId
);

View File

@ -6,6 +6,7 @@ import { mutationLimiter } from '@/lib/rate-limit';
import { logger } from '@/lib/logger';
import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation';
import { resolveMentionRecipients } from '@/lib/mentions';
import { normalizeTaskCreateStatus } from '@/lib/task-status';
function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
@ -163,7 +164,7 @@ export async function POST(request: NextRequest) {
const {
title,
description,
status = 'inbox',
status,
priority = 'medium',
project_id,
assigned_to,
@ -173,6 +174,7 @@ export async function POST(request: NextRequest) {
tags = [],
metadata = {}
} = body;
const normalizedStatus = normalizeTaskCreateStatus(status, assigned_to)
// Check for duplicate title
const existingTask = db.prepare('SELECT id FROM tasks WHERE title = ? AND workspace_id = ?').get(title, workspaceId);
@ -212,7 +214,7 @@ export async function POST(request: NextRequest) {
const dbResult = insertStmt.run(
title,
description,
status,
normalizedStatus,
priority,
resolvedProjectId,
row.ticket_counter,
@ -234,7 +236,7 @@ export async function POST(request: NextRequest) {
// Log activity
db_helpers.logActivity('task_created', 'task', taskId, created_by, `Created task: ${title}`, {
title,
status,
status: normalizedStatus,
priority,
assigned_to
}, workspaceId);

View File

@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { normalizeTaskCreateStatus, normalizeTaskUpdateStatus } from '../task-status'
describe('task status normalization', () => {
it('sets assigned status on create when assignee is present', () => {
expect(normalizeTaskCreateStatus(undefined, 'main')).toBe('assigned')
expect(normalizeTaskCreateStatus('inbox', 'main')).toBe('assigned')
})
it('keeps explicit non-inbox status on create', () => {
expect(normalizeTaskCreateStatus('in_progress', 'main')).toBe('in_progress')
})
it('auto-promotes inbox to assigned when assignment is added via update', () => {
expect(
normalizeTaskUpdateStatus({
currentStatus: 'inbox',
requestedStatus: undefined,
assignedTo: 'main',
assignedToProvided: true,
})
).toBe('assigned')
})
it('auto-demotes assigned to inbox when assignment is removed via update', () => {
expect(
normalizeTaskUpdateStatus({
currentStatus: 'assigned',
requestedStatus: undefined,
assignedTo: '',
assignedToProvided: true,
})
).toBe('inbox')
})
it('does not override explicit status changes on update', () => {
expect(
normalizeTaskUpdateStatus({
currentStatus: 'inbox',
requestedStatus: 'in_progress',
assignedTo: 'main',
assignedToProvided: true,
})
).toBe('in_progress')
})
})

40
src/lib/task-status.ts Normal file
View File

@ -0,0 +1,40 @@
import type { Task } from './db'
export type TaskStatus = Task['status']
function hasAssignee(assignedTo: string | null | undefined): boolean {
return Boolean(assignedTo && assignedTo.trim())
}
/**
* Keep task state coherent when a task is created with an assignee.
* If caller asks for `inbox` but also sets `assigned_to`, normalize to `assigned`.
*/
export function normalizeTaskCreateStatus(
requestedStatus: TaskStatus | undefined,
assignedTo: string | undefined
): TaskStatus {
const status = requestedStatus ?? 'inbox'
if (status === 'inbox' && hasAssignee(assignedTo)) return 'assigned'
return status
}
/**
* Auto-adjust status for assignment-only updates when caller does not
* explicitly request a status transition.
*/
export function normalizeTaskUpdateStatus(args: {
currentStatus: TaskStatus
requestedStatus: TaskStatus | undefined
assignedTo: string | null | undefined
assignedToProvided: boolean
}): TaskStatus | undefined {
const { currentStatus, requestedStatus, assignedTo, assignedToProvided } = args
if (requestedStatus !== undefined) return requestedStatus
if (!assignedToProvided) return undefined
if (hasAssignee(assignedTo) && currentStatus === 'inbox') return 'assigned'
if (!hasAssignee(assignedTo) && currentStatus === 'assigned') return 'inbox'
return undefined
}