tasks.tsβ’15.8 kB
/**
* Task operations for Attio
*/
import { getLazyAttioClient } from '../../api/lazy-client.js';
import { getValidatedAttioClient } from '../../utils/client-resolver.js';
import type { AxiosInstance } from 'axios';
import {
AttioTask,
AttioListResponse,
AttioSingleResponse,
} from '../../types/attio.js';
import { callWithRetry, RetryConfig } from './retry.js';
import { TaskCreateData, TaskUpdateData } from '../../types/api-operations.js';
import { debug, OperationType } from '../../utils/logger.js';
import {
logTaskDebug,
sanitizePayload,
inspectTaskRecordShape,
} from '../../utils/task-debug.js';
/**
* Helper function to transform Attio API task response to internal format
* Handles field name transformations for backward compatibility
*/
function transformTaskResponse(task: AttioTask): AttioTask {
const transformedTask = task as Record<string, unknown>;
// Transform content_plaintext -> content for backward compatibility
if (
'content_plaintext' in transformedTask &&
!('content' in transformedTask)
) {
transformedTask.content = transformedTask.content_plaintext;
}
// Transform is_completed -> status for backward compatibility
if ('is_completed' in transformedTask && !('status' in transformedTask)) {
transformedTask.status = transformedTask.is_completed
? 'completed'
: 'pending';
}
return transformedTask as AttioTask;
}
/**
* Helper function to extract task data from API response
* Handles different response structure patterns
*/
function extractTaskFromResponse(res: Record<string, unknown>): AttioTask {
// Try different response structure patterns
const data = res?.data as Record<string, unknown>;
if (data?.data) {
return data.data as AttioTask;
} else if (data && typeof data === 'object' && 'id' in data) {
// Direct task object in data
return data as unknown as AttioTask;
} else {
throw new Error('Invalid API response structure: missing task data');
}
}
/**
* Helper function to convert date string to ISO 8601 format for Attio API
* Handles various input formats and converts them to proper ISO datetime
* Returns null for invalid or empty inputs to prevent API errors
*/
function formatDateForAttio(dateStr: string): string | null {
// Validate input - return null for invalid values to prevent API errors
if (
!dateStr ||
typeof dateStr !== 'string' ||
dateStr.trim() === '' ||
dateStr === 'undefined' ||
dateStr === 'null'
) {
return null;
}
const trimmedDate = dateStr.trim();
// If already in ISO format, validate and return as-is
if (trimmedDate.includes('T') && trimmedDate.includes('Z')) {
const testDate = new Date(trimmedDate);
if (isNaN(testDate.getTime())) {
return null;
}
return trimmedDate;
}
// Handle YYYY-MM-DD format by adding time component
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmedDate)) {
const testDate = new Date(`${trimmedDate}T00:00:00Z`);
if (isNaN(testDate.getTime())) {
return null;
}
return `${trimmedDate}T00:00:00Z`;
}
// Try parsing other formats and convert to ISO
const date = new Date(trimmedDate);
if (isNaN(date.getTime())) {
return null;
}
return date.toISOString();
}
/**
* Helper function to validate linking parameters
* Both recordId and targetObject must be provided together, or neither
*/
function validateLinkingParameters(
recordId?: string,
targetObject?: string
): void {
const hasRecordId = !!recordId;
const hasTargetObject = !!targetObject;
if (hasRecordId !== hasTargetObject) {
debug(
'tasks.validateLinkingParameters',
'Invalid task linking parameters',
{ recordId, targetObject },
'validateLinkingParameters',
OperationType.VALIDATION
);
throw new Error(
`Invalid task linking: both 'recordId' and 'targetObject' must be provided together, or neither. ` +
`Received recordId: ${recordId ? 'present' : 'missing'}, targetObject: ${targetObject ? 'present' : 'missing'}`
);
}
}
export async function listTasks(
status?: string,
assigneeId?: string,
page: number = 1,
pageSize: number = 25,
retryConfig?: Partial<RetryConfig>
): Promise<AttioTask[]> {
const api = resolveAttioClient();
const params = new URLSearchParams();
params.append('page', String(page));
params.append('pageSize', String(pageSize));
if (status) params.append('status', status);
if (assigneeId) params.append('assignee', assigneeId);
const path = `/tasks?${params.toString()}`;
return callWithRetry(async () => {
const res = await api.get<AttioListResponse<AttioTask>>(path);
const tasks = res?.data?.data || [];
// Transform each task in the response for backward compatibility
return tasks.map((task) => transformTaskResponse(task));
}, retryConfig);
}
export async function getTask(
taskId: string,
retryConfig?: Partial<RetryConfig>
): Promise<AttioTask> {
const api = resolveAttioClient();
const path = `/tasks/${taskId}`;
return callWithRetry(async () => {
const res = await api.get<AttioSingleResponse<AttioTask>>(path);
const task = extractTaskFromResponse(
res as unknown as Record<string, unknown>
);
return transformTaskResponse(task);
}, retryConfig);
}
export async function createTask(
content: string,
options: {
assigneeId?: string;
assignees?: string[];
dueDate?: string;
recordId?: string;
targetObject?: 'companies' | 'people' | 'records';
linked_records?: Array<{
target_object: 'companies' | 'people' | 'deals';
target_record_id: string;
}>;
} = {},
retryConfig?: Partial<RetryConfig>
): Promise<AttioTask> {
const api = resolveAttioClient();
const path = '/tasks';
// Validate linking parameters: both recordId and targetObject required, or neither
// Skip validation if using the new linked_records format
if (!options.linked_records || options.linked_records.length === 0) {
validateLinkingParameters(options.recordId, options.targetObject);
}
// Build task data according to TaskCreateData interface
const taskData: TaskCreateData = {
content,
format: 'plaintext', // Required field for Attio API
};
// Only include deadline_at if a valid date is provided
if (
options.dueDate &&
options.dueDate.trim() &&
options.dueDate !== 'undefined'
) {
const formattedDate = formatDateForAttio(options.dueDate);
if (formattedDate === null) {
debug(
'tasks.createTask',
'Invalid date format provided',
{ dueDate: options.dueDate },
'createTask',
OperationType.VALIDATION
);
throw new Error(
`Invalid date format for task deadline: ${options.dueDate}`
);
}
taskData.deadline_at = formattedDate;
}
// Build the full request payload with all required fields for the API
// Assignees: Attio v2 expects referenced actor references
// Merge assigneeId and assignees arrays, with assignees taking precedence
const allAssigneeIds = [...(options.assignees || [])];
if (options.assigneeId && !allAssigneeIds.includes(options.assigneeId)) {
allAssigneeIds.push(options.assigneeId);
}
const assignees = allAssigneeIds.map((id) => ({
referenced_actor_type: 'workspace-member',
referenced_actor_id: id,
}));
// Always include linked_records as an array (Attio API requires the field)
// If omitted, Attio returns 400 with validation error:
// validation_errors: path ["data","linked_records"], expected "array", received "undefined"
// When linking, use target_object and target_record_id format
let linkedRecords: Array<{
target_object: string;
target_record_id: string;
}> = [];
if (options.linked_records && options.linked_records.length) {
// Use the new typed linked_records format
linkedRecords = options.linked_records;
} else if (options.recordId && options.targetObject) {
// Backward compatibility: single record linking
linkedRecords = [
{
target_object: options.targetObject,
target_record_id: options.recordId,
},
];
}
// Build the request payload conditionally including deadline_at only when present
const dataPayload: Record<string, unknown> = {
content: taskData.content,
format: taskData.format,
is_completed: false, // Always false for new tasks
assignees,
linked_records: linkedRecords, // Always include as array (empty when not linking)
};
// Always include deadline_at in payload - Attio API expects this field to be present
// Use null when no deadline is provided (API validation requires field presence)
dataPayload.deadline_at = taskData.deadline_at || null;
const requestPayload = {
data: dataPayload,
};
return callWithRetry(async () => {
logTaskDebug(
'createTask',
'Prepared create payload',
sanitizePayload({ path, payload: requestPayload })
);
debug(
'tasks.createTask',
'Creating task',
{ path, hasLinkedRecords: linkedRecords.length > 0 },
'createTask',
OperationType.API_CALL
);
let res;
try {
res = await api.post<AttioSingleResponse<AttioTask>>(
path,
requestPayload
);
} catch (err) {
debug(
'tasks.createTask',
'API call failed',
{
errorMessage: err instanceof Error ? err.message : String(err),
isAxiosError: err && typeof err === 'object' && 'isAxiosError' in err,
},
'createTask',
OperationType.API_CALL
);
throw err;
}
// Handle response validation
if (!res) {
debug(
'tasks.createTask',
'API response is null/undefined',
{ path },
'createTask',
OperationType.API_CALL
);
throw new Error('Invalid API response: no response data received');
}
// Debug logging to identify the response structure
debug(
'tasks.createTask',
'Response structure analysis',
{
hasData: !!res,
responseType: typeof res,
hasDataProperty: res && typeof res === 'object' && 'data' in res,
},
'createTask',
OperationType.API_CALL
);
const task = extractTaskFromResponse(
res as unknown as Record<string, unknown>
);
// Note: Only transform content field for create response (status not returned on create)
const transformed = transformTaskResponse(task);
logTaskDebug(
'createTask',
'Create response shape',
inspectTaskRecordShape(transformed)
);
return transformed;
}, retryConfig);
}
export async function updateTask(
taskId: string,
updates: {
content?: string; // Keep for backward compatibility, but will be ignored
status?: string;
assigneeId?: string;
assignees?: string[];
dueDate?: string;
recordIds?: string[];
linked_records?: Array<{
target_object: 'companies' | 'people' | 'deals';
target_record_id: string;
}>;
},
retryConfig?: Partial<RetryConfig>
): Promise<AttioTask> {
const api = resolveAttioClient();
const path = `/tasks/${taskId}`;
const data: TaskUpdateData = {};
// Note: content is immutable and cannot be updated - ignore if provided
if (updates.status) {
// Map status string to is_completed boolean
data.is_completed = updates.status === 'completed';
}
// Assignees: API expects an array in the request envelope
// Merge assigneeId and assignees arrays, with assignees taking precedence
if (updates.assignees || updates.assigneeId) {
const allAssigneeIds = [...(updates.assignees || [])];
if (updates.assigneeId && !allAssigneeIds.includes(updates.assigneeId)) {
allAssigneeIds.push(updates.assigneeId);
}
(data as Record<string, unknown>).assignees = allAssigneeIds.map((id) => ({
referenced_actor_type: 'workspace-member',
referenced_actor_id: id,
}));
}
if (updates.dueDate) {
const formattedDate = formatDateForAttio(updates.dueDate);
if (formattedDate === null) {
debug(
'tasks.updateTask',
'Invalid date format provided',
{ dueDate: updates.dueDate },
'updateTask',
OperationType.VALIDATION
);
throw new Error(
`Invalid date format for task deadline: ${updates.dueDate}`
);
}
data.deadline_at = formattedDate;
}
// Include linked_records in PATCH request (per Attio API docs)
if (updates.linked_records && updates.linked_records.length) {
// Use the new typed linked_records format
(data as Record<string, unknown>).linked_records = updates.linked_records;
} else if (updates.recordIds && updates.recordIds.length) {
// Backward compatibility: map recordIds to linked_records with default companies type
(data as Record<string, unknown>).linked_records = updates.recordIds.map(
(recordId) => ({
target_object: 'companies', // Default to companies for backward compatibility
target_record_id: recordId,
})
);
}
// Wrap in Attio envelope as per API requirements
const requestPayload = { data };
return callWithRetry(async () => {
// Debug request for tracing
debug(
'tasks.updateTask',
'PATCH payload',
{ path, payload: requestPayload },
'updateTask',
OperationType.API_CALL
);
logTaskDebug(
'updateTask',
'Prepared update payload',
sanitizePayload({ path, payload: requestPayload })
);
const res = await api.patch<AttioSingleResponse<AttioTask>>(
path,
requestPayload
);
const task = extractTaskFromResponse(
res as unknown as Record<string, unknown>
);
const transformed = transformTaskResponse(task);
logTaskDebug(
'updateTask',
'Update response shape',
inspectTaskRecordShape(transformed)
);
debug(
'tasks.updateTask',
'PATCH response received',
{
status: (res as unknown as Record<string, unknown>)?.status,
hasData: !!res?.data,
},
'updateTask',
OperationType.API_CALL
);
return transformed;
}, retryConfig);
}
export async function deleteTask(
taskId: string,
retryConfig?: Partial<RetryConfig>
): Promise<boolean> {
const api = resolveAttioClient();
const path = `/tasks/${taskId}`;
return callWithRetry(async () => {
await api.delete(path);
return true;
}, retryConfig);
}
export async function linkRecordToTask(
taskId: string,
recordId: string,
retryConfig?: Partial<RetryConfig>
): Promise<boolean> {
const api = resolveAttioClient();
const path = `/tasks/${taskId}/linked-records`;
return callWithRetry(async () => {
await api.post(path, { record_id: recordId });
return true;
}, retryConfig);
}
export async function unlinkRecordFromTask(
taskId: string,
recordId: string,
retryConfig?: Partial<RetryConfig>
): Promise<boolean> {
const api = resolveAttioClient();
const path = `/tasks/${taskId}/linked-records/${recordId}`;
return callWithRetry(async () => {
await api.delete(path);
return true;
}, retryConfig);
}
/**
* Resolve an Attio API client that works in both runtime and test environments.
* In tests/offline, prefer the mocked getAttioClient if available.
*/
function resolveAttioClient(): AxiosInstance {
try {
// Use the unified client resolver which handles all factory methods
return getValidatedAttioClient();
} catch (error) {
// If resolver fails, try lazy client as fallback
try {
return getLazyAttioClient();
} catch {
throw new Error(
`Could not initialize Attio client: ${error instanceof Error ? error.message : String(error)}`
);
}
}
}