/**
* Todoist Service
* Wraps the official Todoist SDK v6 with caching, rate limiting, and error handling
*/
import { TodoistApi } from '@doist/todoist-api-typescript';
import type {
Task as SdkTask,
PersonalProject,
WorkspaceProject,
Section as SdkSection,
Label as SdkLabel,
Comment as SdkComment,
} from '@doist/todoist-api-typescript';
import { RateLimiter } from './rate-limiter.js';
import { TodoistCache } from './cache.js';
import type {
Task,
Project,
Section,
Label,
Comment,
ListTasksInput,
CreateTaskInput,
UpdateTaskInput,
CreateProjectInput,
CreateLabelInput,
UpdateProjectInput,
UpdateSectionInput,
MoveSectionInput,
UpdateLabelInput,
TodoistError,
} from '../types/index.js';
// Sync API types
interface SyncCommand {
type: string;
uuid: string;
args: Record<string, unknown>;
}
interface SyncResponse {
sync_status?: Record<string, 'ok' | { error: string }>;
sections?: Array<{
id: string;
project_id: string;
name: string;
section_order: number;
is_archived: boolean;
is_deleted: boolean;
collapsed: boolean;
}>;
}
export class TodoistService {
private api: TodoistApi;
private apiToken: string;
private rateLimiter: RateLimiter;
private cache: TodoistCache;
constructor(apiToken: string) {
this.api = new TodoistApi(apiToken);
this.apiToken = apiToken;
this.rateLimiter = new RateLimiter();
this.cache = new TodoistCache();
}
// ─────────────────────────────────────────────────────────────
// Sync API Helper (for operations not in REST API)
// ─────────────────────────────────────────────────────────────
private async syncCommand(commands: SyncCommand[]): Promise<SyncResponse> {
const response = await fetch('https://api.todoist.com/sync/v9/sync', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ commands }),
});
if (!response.ok) {
throw new Error(`Sync API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
private generateUuid(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// ─────────────────────────────────────────────────────────────
// Tasks
// ─────────────────────────────────────────────────────────────
async listTasks(input: ListTasksInput = {}): Promise<Task[]> {
await this.rateLimiter.throttle();
try {
// If filter is provided, use getTasksByFilter
if (input.filter) {
const response = await this.api.getTasksByFilter({ query: input.filter });
const limit = input.limit || 50;
return response.results.slice(0, limit).map(t => this.transformTask(t));
}
// Build filter string for complex queries
const filters: string[] = [];
if (input.dueToday) filters.push('today');
if (input.dueThisWeek) filters.push('7 days');
if (input.overdue) filters.push('overdue');
if (input.noDueDate) filters.push('no date');
if (input.priority) filters.push(`p${5 - input.priority}`); // Convert to p1-p4
if (filters.length > 0) {
const response = await this.api.getTasksByFilter({ query: filters.join(' | ') });
const limit = input.limit || 50;
return response.results.slice(0, limit).map(t => this.transformTask(t));
}
// Otherwise use getTasks with optional filters
const response = await this.api.getTasks({
projectId: input.projectId,
sectionId: input.sectionId,
});
const limit = input.limit || 50;
return response.results.slice(0, limit).map(t => this.transformTask(t));
} catch (error) {
throw this.handleError(error);
}
}
async getTask(taskId: string): Promise<Task> {
await this.rateLimiter.throttle();
try {
const task = await this.api.getTask(taskId);
return this.transformTask(task);
} catch (error) {
throw this.handleError(error);
}
}
async createTask(input: CreateTaskInput): Promise<Task> {
await this.rateLimiter.throttle();
try {
// Build the base args
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const addTaskArgs: any = {
content: input.content,
};
if (input.description) addTaskArgs.description = input.description;
if (input.projectId) addTaskArgs.projectId = input.projectId;
if (input.sectionId) addTaskArgs.sectionId = input.sectionId;
if (input.labels) addTaskArgs.labels = input.labels;
if (input.priority) addTaskArgs.priority = input.priority;
if (input.parentId) addTaskArgs.parentId = input.parentId;
if (input.assigneeId) addTaskArgs.assigneeId = input.assigneeId;
// Duration fields must both be present or both absent
if (input.duration && input.durationUnit) {
addTaskArgs.duration = input.duration;
addTaskArgs.durationUnit = input.durationUnit;
}
// Due date fields are mutually exclusive - only use one
if (input.dueString) {
addTaskArgs.dueString = input.dueString;
} else if (input.dueDatetime) {
addTaskArgs.dueDatetime = input.dueDatetime;
} else if (input.dueDate) {
addTaskArgs.dueDate = input.dueDate;
}
const task = await this.api.addTask(addTaskArgs);
return this.transformTask(task);
} catch (error) {
throw this.handleError(error);
}
}
async updateTask(taskId: string, input: Omit<UpdateTaskInput, 'taskId'>): Promise<Task> {
await this.rateLimiter.throttle();
try {
// Build the base args
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updateTaskArgs: any = {};
if (input.content) updateTaskArgs.content = input.content;
if (input.description !== undefined) updateTaskArgs.description = input.description;
if (input.labels) updateTaskArgs.labels = input.labels;
if (input.priority) updateTaskArgs.priority = input.priority;
if (input.assigneeId !== undefined) updateTaskArgs.assigneeId = input.assigneeId;
// Duration fields must both be present or both absent
if (input.duration && input.durationUnit) {
updateTaskArgs.duration = input.duration;
updateTaskArgs.durationUnit = input.durationUnit;
}
// Due date fields are mutually exclusive - only use one
if (input.dueString) {
updateTaskArgs.dueString = input.dueString;
} else if (input.dueDatetime) {
updateTaskArgs.dueDatetime = input.dueDatetime;
} else if (input.dueDate) {
updateTaskArgs.dueDate = input.dueDate;
}
const task = await this.api.updateTask(taskId, updateTaskArgs);
return this.transformTask(task);
} catch (error) {
throw this.handleError(error);
}
}
async completeTask(taskId: string): Promise<{ success: boolean; task: Task }> {
await this.rateLimiter.throttle();
try {
// Get task before completing to return it
const task = await this.getTask(taskId);
await this.api.closeTask(taskId);
return {
success: true,
task: { ...task, isCompleted: true, completedAt: new Date().toISOString() },
};
} catch (error) {
throw this.handleError(error);
}
}
async uncompleteTask(taskId: string): Promise<{ success: boolean; task: Task }> {
await this.rateLimiter.throttle();
try {
await this.api.reopenTask(taskId);
const task = await this.getTask(taskId);
return { success: true, task };
} catch (error) {
throw this.handleError(error);
}
}
async deleteTask(taskId: string): Promise<{ success: boolean }> {
await this.rateLimiter.throttle();
try {
await this.api.deleteTask(taskId);
return { success: true };
} catch (error) {
throw this.handleError(error);
}
}
async moveTask(
taskId: string,
options: { projectId?: string; sectionId?: string; parentId?: string }
): Promise<Task> {
await this.rateLimiter.throttle();
try {
// Use the SDK's moveTask method
// Exactly one of projectId, sectionId, or parentId must be provided
if (!options.projectId && !options.sectionId && !options.parentId) {
throw new Error('One of projectId, sectionId, or parentId is required');
}
// Build args - only include the one that's set (SDK requires exactly one)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const moveArgs: any = {};
if (options.sectionId) moveArgs.sectionId = options.sectionId;
else if (options.projectId) moveArgs.projectId = options.projectId;
else if (options.parentId) moveArgs.parentId = options.parentId;
const task = await this.api.moveTask(taskId, moveArgs);
return this.transformTask(task);
} catch (error) {
throw this.handleError(error);
}
}
async getSubtasks(parentId: string): Promise<Task[]> {
await this.rateLimiter.throttle();
try {
const response = await this.api.getTasks({ parentId });
return response.results.map(t => this.transformTask(t));
} catch (error) {
throw this.handleError(error);
}
}
// ─────────────────────────────────────────────────────────────
// Projects
// ─────────────────────────────────────────────────────────────
async listProjects(): Promise<Project[]> {
// Check cache first
const cached = this.cache.getAllProjects();
if (cached) return cached;
await this.rateLimiter.throttle();
try {
const response = await this.api.getProjects();
const transformed = response.results.map(p => this.transformProject(p));
this.cache.setAllProjects(transformed);
return transformed;
} catch (error) {
throw this.handleError(error);
}
}
async getProject(projectId: string): Promise<Project> {
// Check cache first
const cached = this.cache.getProject(projectId);
if (cached) return cached;
await this.rateLimiter.throttle();
try {
const project = await this.api.getProject(projectId);
const transformed = this.transformProject(project);
this.cache.setProject(transformed);
return transformed;
} catch (error) {
throw this.handleError(error);
}
}
async createProject(input: CreateProjectInput): Promise<Project> {
await this.rateLimiter.throttle();
try {
const project = await this.api.addProject({
name: input.name,
color: input.color,
parentId: input.parentId,
isFavorite: input.isFavorite,
viewStyle: input.viewStyle,
});
this.cache.invalidateProjects();
return this.transformProject(project);
} catch (error) {
throw this.handleError(error);
}
}
async updateProject(projectId: string, input: UpdateProjectInput): Promise<Project> {
await this.rateLimiter.throttle();
try {
const project = await this.api.updateProject(projectId, {
name: input.name,
color: input.color,
isFavorite: input.isFavorite,
viewStyle: input.viewStyle,
});
this.cache.invalidateProjects();
return this.transformProject(project);
} catch (error) {
throw this.handleError(error);
}
}
async deleteProject(projectId: string): Promise<{ success: boolean }> {
await this.rateLimiter.throttle();
try {
await this.api.deleteProject(projectId);
this.cache.invalidateProjects();
return { success: true };
} catch (error) {
throw this.handleError(error);
}
}
// ─────────────────────────────────────────────────────────────
// Sections
// ─────────────────────────────────────────────────────────────
async listSections(projectId?: string): Promise<Section[]> {
// Check cache if no filter
if (!projectId) {
const cached = this.cache.getAllSections();
if (cached) return cached;
}
await this.rateLimiter.throttle();
try {
const response = await this.api.getSections(projectId ? { projectId } : undefined);
const transformed = response.results.map(s => this.transformSection(s));
if (!projectId) {
this.cache.setAllSections(transformed);
}
return transformed;
} catch (error) {
throw this.handleError(error);
}
}
async createSection(projectId: string, name: string): Promise<Section> {
await this.rateLimiter.throttle();
try {
const section = await this.api.addSection({ projectId, name });
this.cache.invalidateSections();
return this.transformSection(section);
} catch (error) {
throw this.handleError(error);
}
}
async updateSection(sectionId: string, input: UpdateSectionInput): Promise<Section> {
await this.rateLimiter.throttle();
try {
// Use Sync API for collapsed support, REST API for name-only updates
if (input.collapsed !== undefined) {
// Use Sync API which supports collapsed
const args: Record<string, unknown> = { id: sectionId };
if (input.name !== undefined) args.name = input.name;
if (input.collapsed !== undefined) args.collapsed = input.collapsed;
const result = await this.syncCommand([{
type: 'section_update',
uuid: this.generateUuid(),
args,
}]);
// Check for errors
const status = result.sync_status?.[Object.keys(result.sync_status)[0]];
if (status && status !== 'ok') {
throw new Error((status as { error: string }).error);
}
this.cache.invalidateSections();
// Fetch the updated section via REST API
const section = await this.api.getSection(sectionId);
return this.transformSection(section);
} else if (input.name) {
// REST API is fine for name-only updates
const section = await this.api.updateSection(sectionId, {
name: input.name,
});
this.cache.invalidateSections();
return this.transformSection(section);
} else {
throw new Error('At least name or collapsed must be provided');
}
} catch (error) {
throw this.handleError(error);
}
}
async moveSection(sectionId: string, input: MoveSectionInput): Promise<Section> {
await this.rateLimiter.throttle();
try {
// Use Sync API - REST API doesn't support moving sections
const result = await this.syncCommand([{
type: 'section_move',
uuid: this.generateUuid(),
args: {
id: sectionId,
project_id: input.projectId,
},
}]);
// Check for errors
const status = result.sync_status?.[Object.keys(result.sync_status)[0]];
if (status && status !== 'ok') {
throw new Error((status as { error: string }).error);
}
this.cache.invalidateSections();
// Fetch the updated section via REST API
const section = await this.api.getSection(sectionId);
return this.transformSection(section);
} catch (error) {
throw this.handleError(error);
}
}
async deleteSection(sectionId: string): Promise<{ success: boolean }> {
await this.rateLimiter.throttle();
try {
await this.api.deleteSection(sectionId);
this.cache.invalidateSections();
return { success: true };
} catch (error) {
throw this.handleError(error);
}
}
// ─────────────────────────────────────────────────────────────
// Labels
// ─────────────────────────────────────────────────────────────
async listLabels(): Promise<Label[]> {
// Check cache first
const cached = this.cache.getAllLabels();
if (cached) return cached;
await this.rateLimiter.throttle();
try {
const response = await this.api.getLabels();
const transformed = response.results.map(l => this.transformLabel(l));
this.cache.setAllLabels(transformed);
return transformed;
} catch (error) {
throw this.handleError(error);
}
}
async createLabel(input: CreateLabelInput): Promise<Label> {
await this.rateLimiter.throttle();
try {
const label = await this.api.addLabel({
name: input.name,
color: input.color,
isFavorite: input.isFavorite,
});
this.cache.invalidateLabels();
return this.transformLabel(label);
} catch (error) {
throw this.handleError(error);
}
}
async updateLabel(labelId: string, input: UpdateLabelInput): Promise<Label> {
await this.rateLimiter.throttle();
try {
const label = await this.api.updateLabel(labelId, {
name: input.name,
color: input.color,
order: input.order,
isFavorite: input.isFavorite,
});
this.cache.invalidateLabels();
return this.transformLabel(label);
} catch (error) {
throw this.handleError(error);
}
}
async deleteLabel(labelId: string): Promise<{ success: boolean }> {
await this.rateLimiter.throttle();
try {
await this.api.deleteLabel(labelId);
this.cache.invalidateLabels();
return { success: true };
} catch (error) {
throw this.handleError(error);
}
}
// ─────────────────────────────────────────────────────────────
// Comments
// ─────────────────────────────────────────────────────────────
async getComments(taskId: string): Promise<Comment[]> {
await this.rateLimiter.throttle();
try {
const response = await this.api.getComments({ taskId });
return response.results.map(c => this.transformComment(c, taskId));
} catch (error) {
throw this.handleError(error);
}
}
async addComment(taskId: string, content: string): Promise<Comment> {
await this.rateLimiter.throttle();
try {
const comment = await this.api.addComment({ taskId, content });
return this.transformComment(comment, taskId);
} catch (error) {
throw this.handleError(error);
}
}
// ─────────────────────────────────────────────────────────────
// Quick Add
// ─────────────────────────────────────────────────────────────
async quickAdd(text: string): Promise<Task> {
await this.rateLimiter.throttle();
try {
// Use the SDK's quickAddTask which leverages Todoist's natural language parsing
const task = await this.api.quickAddTask({ text });
return this.transformTask(task);
} catch (error) {
throw this.handleError(error);
}
}
// ─────────────────────────────────────────────────────────────
// Utility Methods
// ─────────────────────────────────────────────────────────────
getRateLimitStats() {
return this.rateLimiter.getStats();
}
invalidateCache() {
this.cache.invalidateAll();
}
// ─────────────────────────────────────────────────────────────
// Transform Methods (API response -> our types)
// ─────────────────────────────────────────────────────────────
private transformTask(apiTask: SdkTask): Task {
const task: Task = {
id: apiTask.id,
projectId: apiTask.projectId,
sectionId: apiTask.sectionId || undefined,
parentId: apiTask.parentId || undefined,
content: apiTask.content,
description: apiTask.description || '',
labels: apiTask.labels || [],
priority: apiTask.priority as 1 | 2 | 3 | 4,
order: apiTask.childOrder, // v6 uses childOrder instead of order
isCompleted: apiTask.checked, // v6 uses checked instead of isCompleted
createdAt: apiTask.addedAt || '', // v6 uses addedAt instead of createdAt
creatorId: apiTask.userId, // v6 uses userId
assigneeId: apiTask.responsibleUid || undefined, // v6 uses responsibleUid
commentCount: apiTask.noteCount, // v6 uses noteCount instead of commentCount
url: apiTask.url,
};
// Set completedAt if task is checked
if (apiTask.completedAt) {
task.completedAt = apiTask.completedAt;
}
if (apiTask.due) {
task.due = {
date: apiTask.due.date,
datetime: apiTask.due.datetime || undefined,
timezone: apiTask.due.timezone || undefined,
string: apiTask.due.string,
isRecurring: apiTask.due.isRecurring,
};
// Compute isOverdue and daysUntilDue
const dueDate = new Date(apiTask.due.date);
const today = new Date();
today.setHours(0, 0, 0, 0);
dueDate.setHours(0, 0, 0, 0);
const diffTime = dueDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
task.daysUntilDue = diffDays;
task.isOverdue = diffDays < 0 && !apiTask.checked;
}
if (apiTask.duration) {
task.duration = {
amount: apiTask.duration.amount,
unit: apiTask.duration.unit as 'minute' | 'day',
};
}
return task;
}
private transformProject(apiProject: PersonalProject | WorkspaceProject): Project {
// Check if it's a personal project (has parentId and inboxProject)
const isPersonal = 'parentId' in apiProject;
return {
id: apiProject.id,
name: apiProject.name,
color: apiProject.color,
parentId: isPersonal ? (apiProject as PersonalProject).parentId || undefined : undefined,
order: apiProject.childOrder, // v6 uses childOrder
commentCount: 0, // v6 doesn't have commentCount on projects
isShared: apiProject.isShared,
isFavorite: apiProject.isFavorite,
isInboxProject: isPersonal ? (apiProject as PersonalProject).inboxProject : false, // v6 uses inboxProject
isTeamInbox: !isPersonal, // Workspace projects are team projects
viewStyle: apiProject.viewStyle as 'list' | 'board',
url: apiProject.url,
};
}
private transformSection(apiSection: SdkSection): Section {
return {
id: apiSection.id,
projectId: apiSection.projectId,
name: apiSection.name,
order: apiSection.sectionOrder, // v6 uses sectionOrder instead of order
};
}
private transformLabel(apiLabel: SdkLabel): Label {
return {
id: apiLabel.id,
name: apiLabel.name,
color: apiLabel.color,
order: apiLabel.order || 0,
isFavorite: apiLabel.isFavorite,
};
}
private transformComment(apiComment: SdkComment, taskId: string): Comment {
const comment: Comment = {
id: apiComment.id,
taskId,
content: apiComment.content,
postedAt: apiComment.postedAt,
};
if (apiComment.fileAttachment) {
comment.attachment = {
fileName: apiComment.fileAttachment.fileName || '',
fileType: apiComment.fileAttachment.fileType || '',
fileUrl: apiComment.fileAttachment.fileUrl || '',
resourceType: apiComment.fileAttachment.resourceType || '',
};
}
return comment;
}
// ─────────────────────────────────────────────────────────────
// Error Handling
// ─────────────────────────────────────────────────────────────
private handleError(error: unknown): TodoistError {
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes('429') || message.includes('rate limit')) {
return {
code: 'RATE_LIMITED',
message: 'Rate limit exceeded, retry after cooldown',
httpStatus: 429,
retryAfter: 60,
};
}
if (message.includes('401') || message.includes('unauthorized')) {
return {
code: 'AUTH_FAILED',
message: 'Authentication failed, check API token',
httpStatus: 401,
};
}
if (message.includes('404') || message.includes('not found')) {
return {
code: 'NOT_FOUND',
message: 'Resource not found',
httpStatus: 404,
};
}
if (message.includes('400') || message.includes('bad request')) {
return {
code: 'INVALID_REQUEST',
message: `Invalid request: ${error.message}`,
httpStatus: 400,
};
}
return {
code: 'NETWORK_ERROR',
message: error.message,
};
}
return {
code: 'NETWORK_ERROR',
message: 'Unknown error occurred',
};
}
}