diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index 70fd2f9..68898e2 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -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 ); diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index bdd1626..09b7883 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -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); diff --git a/src/lib/__tests__/task-status.test.ts b/src/lib/__tests__/task-status.test.ts new file mode 100644 index 0000000..5d46d95 --- /dev/null +++ b/src/lib/__tests__/task-status.test.ts @@ -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') + }) +}) + diff --git a/src/lib/task-status.ts b/src/lib/task-status.ts new file mode 100644 index 0000000..97bfcbd --- /dev/null +++ b/src/lib/task-status.ts @@ -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 +} +