feat(FN-1260): add full-text search for tasks and comments
- Add FTS5 virtual table with v21 database migration for task search - Add searchTasks() method to TaskStore with FTS5 query support - Add q= search parameter to GET /api/tasks route for server-side search - Update useTasks hook and frontend API to support searchQuery prop - Update Board.tsx and App.tsx to pass searchQuery through component hierarchy - Add comprehensive tests for FTS5 index and searchTasks functionality
This commit is contained in:
@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
|
||||
|
||||
expect(tableNames.has("task_documents")).toBe(true);
|
||||
expect(tableNames.has("task_document_revisions")).toBe(true);
|
||||
expect(db.getSchemaVersion()).toBe(20);
|
||||
expect(db.getSchemaVersion()).toBe(21);
|
||||
|
||||
const index = db
|
||||
.prepare(
|
||||
|
||||
@@ -106,7 +106,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(20);
|
||||
expect(db.getSchemaVersion()).toBe(21);
|
||||
});
|
||||
|
||||
it("seeds lastModified", () => {
|
||||
@@ -129,7 +129,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(20);
|
||||
expect(db.getSchemaVersion()).toBe(21);
|
||||
});
|
||||
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
@@ -736,7 +736,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 5 (includes v1→v2, v2→v3, v3→v4, and v4→v5 migrations)
|
||||
expect(db.getSchemaVersion()).toBe(20);
|
||||
expect(db.getSchemaVersion()).toBe(21);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -761,11 +761,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(20);
|
||||
expect(db.getSchemaVersion()).toBe(21);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(20);
|
||||
expect(db.getSchemaVersion()).toBe(21);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -781,7 +781,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(20);
|
||||
expect(db.getSchemaVersion()).toBe(21);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||
@@ -805,7 +805,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(20);
|
||||
expect(db.getSchemaVersion()).toBe(21);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||
@@ -909,7 +909,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 5
|
||||
expect(db.getSchemaVersion()).toBe(20);
|
||||
expect(db.getSchemaVersion()).toBe(21);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1093,6 +1093,162 @@ describe("schema migrations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("FTS5 full-text search", () => {
|
||||
let tmpDir: string;
|
||||
let kbDir: string;
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpDir();
|
||||
kbDir = join(tmpDir, ".fusion");
|
||||
db = new Database(kbDir);
|
||||
db.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates tasks_fts virtual table after init", () => {
|
||||
const row = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='tasks_fts'"
|
||||
).get() as { name: string } | undefined;
|
||||
expect(row?.name).toBe("tasks_fts");
|
||||
});
|
||||
|
||||
it("creates FTS5 triggers after init", () => {
|
||||
const triggers = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='trigger'"
|
||||
).all() as { name: string }[];
|
||||
const triggerNames = triggers.map((t) => t.name);
|
||||
|
||||
expect(triggerNames).toContain("tasks_fts_ai");
|
||||
expect(triggerNames).toContain("tasks_fts_au");
|
||||
expect(triggerNames).toContain("tasks_fts_ad");
|
||||
});
|
||||
|
||||
it("populates FTS index from existing tasks on migration", () => {
|
||||
// Insert a task directly into the database (bypassing triggers for this test)
|
||||
db.prepare(
|
||||
"INSERT INTO tasks (id, title, description, \"column\", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
).run(
|
||||
"FN-FTS-001",
|
||||
"Full-text search test",
|
||||
"Testing the FTS index",
|
||||
"todo",
|
||||
"2025-01-01T00:00:00.000Z",
|
||||
"2025-01-01T00:00:00.000Z"
|
||||
);
|
||||
|
||||
// Verify the task appears in the FTS index by joining with tasks table
|
||||
const ftsRow = db.prepare(`
|
||||
SELECT t.* FROM tasks t
|
||||
JOIN tasks_fts fts ON t.rowid = fts.rowid
|
||||
WHERE t.id = 'FN-FTS-001'
|
||||
`).get() as any;
|
||||
|
||||
expect(ftsRow).toBeDefined();
|
||||
expect(ftsRow.id).toBe("FN-FTS-001");
|
||||
expect(ftsRow.title).toBe("Full-text search test");
|
||||
expect(ftsRow.description).toBe("Testing the FTS index");
|
||||
});
|
||||
|
||||
it("INSERT trigger indexes new tasks", () => {
|
||||
// Use upsertTask equivalent via direct insert
|
||||
db.prepare(`
|
||||
INSERT INTO tasks (id, title, description, "column", createdAt, updatedAt)
|
||||
VALUES ('FN-FTS-002', 'New task title', 'New task description', 'triage', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')
|
||||
`).run();
|
||||
|
||||
// Verify the task appears in the FTS index via trigger by joining with tasks
|
||||
const ftsRow = db.prepare(`
|
||||
SELECT t.* FROM tasks t
|
||||
JOIN tasks_fts fts ON t.rowid = fts.rowid
|
||||
WHERE t.id = 'FN-FTS-002'
|
||||
`).get() as any;
|
||||
|
||||
expect(ftsRow).toBeDefined();
|
||||
expect(ftsRow.id).toBe("FN-FTS-002");
|
||||
expect(ftsRow.title).toBe("New task title");
|
||||
});
|
||||
|
||||
it("UPDATE trigger reindexes updated tasks", () => {
|
||||
// Insert a task
|
||||
db.prepare(`
|
||||
INSERT INTO tasks (id, title, description, "column", createdAt, updatedAt)
|
||||
VALUES ('FN-FTS-003', 'Original title', 'Original description', 'todo', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')
|
||||
`).run();
|
||||
|
||||
// Update the task
|
||||
db.prepare(`
|
||||
UPDATE tasks SET title = 'Updated title', updatedAt = '2025-01-02T00:00:00.000Z' WHERE id = 'FN-FTS-003'
|
||||
`).run();
|
||||
|
||||
// Verify FTS index has the updated content
|
||||
const ftsRow = db.prepare(`
|
||||
SELECT t.* FROM tasks t
|
||||
JOIN tasks_fts fts ON t.rowid = fts.rowid
|
||||
WHERE t.id = 'FN-FTS-003'
|
||||
`).get() as any;
|
||||
|
||||
expect(ftsRow).toBeDefined();
|
||||
expect(ftsRow.title).toBe("Updated title");
|
||||
expect(ftsRow.description).toBe("Original description"); // description should still be there
|
||||
});
|
||||
|
||||
it("DELETE trigger removes tasks from index", () => {
|
||||
// Insert a task
|
||||
db.prepare(`
|
||||
INSERT INTO tasks (id, title, description, "column", createdAt, updatedAt)
|
||||
VALUES ('FN-FTS-004', 'Task to delete', 'Will be removed', 'todo', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')
|
||||
`).run();
|
||||
|
||||
// Verify it's in the FTS index
|
||||
const beforeDelete = db.prepare(`
|
||||
SELECT t.* FROM tasks t
|
||||
JOIN tasks_fts fts ON t.rowid = fts.rowid
|
||||
WHERE t.id = 'FN-FTS-004'
|
||||
`).get();
|
||||
expect(beforeDelete).toBeDefined();
|
||||
|
||||
// Delete the task
|
||||
db.prepare("DELETE FROM tasks WHERE id = 'FN-FTS-004'").run();
|
||||
|
||||
// Verify it's no longer in the FTS index
|
||||
const afterDelete = db.prepare(`
|
||||
SELECT t.* FROM tasks t
|
||||
JOIN tasks_fts fts ON t.rowid = fts.rowid
|
||||
WHERE t.id = 'FN-FTS-004'
|
||||
`).get();
|
||||
expect(afterDelete).toBeUndefined();
|
||||
});
|
||||
|
||||
it("FTS index includes comments in JSON format", () => {
|
||||
// Insert a task with comments
|
||||
db.prepare(`
|
||||
INSERT INTO tasks (id, title, description, "column", createdAt, updatedAt, comments)
|
||||
VALUES ('FN-FTS-005', 'Task with comments', 'Has a comment', 'todo', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z', '[{"id":"c1","text":"xylophone_plan_keyword","author":"tester","createdAt":"2025-01-01T00:00:00.000Z"}]')
|
||||
`).run();
|
||||
|
||||
// Verify the task appears in FTS with comments tokenized using MATCH
|
||||
const ftsRows = db.prepare(`
|
||||
SELECT t.* FROM tasks t
|
||||
JOIN tasks_fts fts ON t.rowid = fts.rowid
|
||||
WHERE tasks_fts MATCH 'xylophone'
|
||||
`).all() as any[];
|
||||
|
||||
expect(ftsRows.length).toBeGreaterThan(0);
|
||||
const ftsRow = ftsRows.find((r) => r.id === "FN-FTS-005");
|
||||
expect(ftsRow).toBeDefined();
|
||||
expect(ftsRow.comments).toContain("xylophone");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDatabase factory", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
@@ -1119,7 +1275,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(20);
|
||||
expect(db.getSchemaVersion()).toBe(21);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 20;
|
||||
const SCHEMA_VERSION = 21;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -749,6 +749,69 @@ export class Database {
|
||||
this.addColumnIfMissing("tasks", "checkedOutAt", "TEXT");
|
||||
});
|
||||
}
|
||||
|
||||
// FTS5 full-text search index for tasks.
|
||||
// All task writes go through upsertTask() (called by atomicWriteTaskJson()),
|
||||
// which does INSERT OR REPLACE INTO tasks. The SQLite triggers below fire on
|
||||
// INSERT/UPDATE/DELETE and keep the FTS index in sync automatically.
|
||||
// The comments column is a JSON array - FTS5 tokenizes the raw JSON which picks
|
||||
// up comment text, IDs, timestamps, and author names. This is acceptable for v1.
|
||||
if (version < 21) {
|
||||
this.applyMigration(21, () => {
|
||||
// Create FTS5 virtual table for full-text search
|
||||
// Note: Column names must match the tasks table for external content mode to work
|
||||
this.db.exec(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS tasks_fts USING fts5(
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
comments,
|
||||
content='tasks',
|
||||
content_rowid='rowid'
|
||||
)
|
||||
`);
|
||||
|
||||
// Populate FTS index from existing tasks
|
||||
// Handle both older schemas (without title) and newer schemas (with title)
|
||||
if (this.hasColumn("tasks", "title")) {
|
||||
this.db.exec(`
|
||||
INSERT INTO tasks_fts(rowid, id, title, description, comments)
|
||||
SELECT rowid, id, COALESCE(title, ''), description, COALESCE(comments, '[]') FROM tasks
|
||||
`);
|
||||
} else {
|
||||
this.db.exec(`
|
||||
INSERT INTO tasks_fts(rowid, id, title, description, comments)
|
||||
SELECT rowid, id, '', description, COALESCE(comments, '[]') FROM tasks
|
||||
`);
|
||||
}
|
||||
|
||||
// AFTER INSERT trigger - index new tasks
|
||||
this.db.exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS tasks_fts_ai AFTER INSERT ON tasks BEGIN
|
||||
INSERT INTO tasks_fts(rowid, id, title, description, comments)
|
||||
VALUES (new.rowid, new.id, COALESCE(new.title, ''), new.description, COALESCE(new.comments, '[]'));
|
||||
END
|
||||
`);
|
||||
|
||||
// AFTER UPDATE trigger - reindex updated tasks (delete old + insert new)
|
||||
this.db.exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS tasks_fts_au AFTER UPDATE ON tasks BEGIN
|
||||
INSERT INTO tasks_fts(tasks_fts, rowid, id, title, description, comments)
|
||||
VALUES('delete', old.rowid, old.id, COALESCE(old.title, ''), old.description, COALESCE(old.comments, '[]'));
|
||||
INSERT INTO tasks_fts(rowid, id, title, description, comments)
|
||||
VALUES (new.rowid, new.id, COALESCE(new.title, ''), new.description, COALESCE(new.comments, '[]'));
|
||||
END
|
||||
`);
|
||||
|
||||
// AFTER DELETE trigger - remove deleted tasks from index
|
||||
this.db.exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS tasks_fts_ad AFTER DELETE ON tasks BEGIN
|
||||
INSERT INTO tasks_fts(tasks_fts, rowid, id, title, description, comments)
|
||||
VALUES('delete', old.rowid, old.id, COALESCE(old.title, ''), old.description, COALESCE(old.comments, '[]'));
|
||||
END
|
||||
`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6117,4 +6117,143 @@ Task with acceptance criteria
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchTasks", () => {
|
||||
it("searches tasks by ID", async () => {
|
||||
const task1 = await store.createTask({ description: "First task" });
|
||||
const task2 = await store.createTask({ description: "Second task" });
|
||||
|
||||
const results = await store.searchTasks("FN-001");
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe("FN-001");
|
||||
expect(results.some((t) => t.id === "FN-002")).toBe(false);
|
||||
});
|
||||
|
||||
it("searches tasks by title", async () => {
|
||||
await store.createTask({ title: "Fix login bug", description: "Login issue" });
|
||||
await store.createTask({ title: "Add dashboard feature", description: "New UI" });
|
||||
|
||||
const results = await store.searchTasks("dashboard");
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].title).toBe("Add dashboard feature");
|
||||
});
|
||||
|
||||
it("searches tasks by description", async () => {
|
||||
await store.createTask({ description: "Fix the login button on the homepage" });
|
||||
await store.createTask({ description: "Update the settings page layout" });
|
||||
|
||||
const results = await store.searchTasks("homepage");
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].description).toContain("homepage");
|
||||
});
|
||||
|
||||
it("searches tasks by comment text", async () => {
|
||||
const task = await store.createTask({ description: "A task" });
|
||||
// Add a comment containing a unique word
|
||||
await store.addComment(task.id, { text: "Need to prioritize the xylophone implementation", author: "tester" });
|
||||
|
||||
const results = await store.searchTasks("xylophone");
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe(task.id);
|
||||
});
|
||||
|
||||
it("is case insensitive", async () => {
|
||||
await store.createTask({ title: "UPPERCASE SEARCH TEST", description: "Testing case insensitivity" });
|
||||
|
||||
const results = await store.searchTasks("uppercase");
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].title).toBe("UPPERCASE SEARCH TEST");
|
||||
});
|
||||
|
||||
it("falls back to listTasks for empty query", async () => {
|
||||
await store.createTask({ description: "Task 1" });
|
||||
await store.createTask({ description: "Task 2" });
|
||||
|
||||
const results = await store.searchTasks("");
|
||||
const allTasks = await store.listTasks();
|
||||
|
||||
expect(results).toHaveLength(allTasks.length);
|
||||
});
|
||||
|
||||
it("falls back to listTasks for whitespace-only query", async () => {
|
||||
await store.createTask({ description: "Task 1" });
|
||||
|
||||
const results = await store.searchTasks(" ");
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses OR semantics for multi-word queries", async () => {
|
||||
await store.createTask({ title: "Fix login", description: "Button issues" });
|
||||
await store.createTask({ title: "Add dashboard", description: "New features" });
|
||||
|
||||
const results = await store.searchTasks("login dashboard");
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns empty array for non-existent query", async () => {
|
||||
await store.createTask({ description: "Regular task description" });
|
||||
|
||||
const results = await store.searchTasks("xyznonexistent12345");
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("respects limit option", async () => {
|
||||
await store.createTask({ description: "Task 1" });
|
||||
await store.createTask({ description: "Task 2" });
|
||||
await store.createTask({ description: "Task 3" });
|
||||
await store.createTask({ description: "Task 4" });
|
||||
await store.createTask({ description: "Task 5" });
|
||||
|
||||
const results = await store.searchTasks("", { limit: 2 });
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("respects offset option", async () => {
|
||||
await store.createTask({ description: "Task 1" });
|
||||
await store.createTask({ description: "Task 2" });
|
||||
await store.createTask({ description: "Task 3" });
|
||||
|
||||
const allResults = await store.searchTasks("");
|
||||
const offsetResults = await store.searchTasks("", { offset: 1 });
|
||||
|
||||
expect(allResults.length).toBe(3);
|
||||
expect(offsetResults.length).toBe(2);
|
||||
expect(offsetResults[0].id).toBe(allResults[1].id);
|
||||
});
|
||||
|
||||
it("immediately indexes new comments", async () => {
|
||||
const task = await store.createTask({ description: "A task without comments" });
|
||||
const uniqueWord = `unique_search_term_${Date.now()}`;
|
||||
|
||||
// Initially should not be found
|
||||
const beforeResults = await store.searchTasks(uniqueWord);
|
||||
expect(beforeResults).toHaveLength(0);
|
||||
|
||||
// Add comment with unique word
|
||||
await store.addComment(task.id, { text: `Important note about the ${uniqueWord} feature`, author: "tester" });
|
||||
|
||||
// Should now be found immediately (trigger fires synchronously)
|
||||
const afterResults = await store.searchTasks(uniqueWord);
|
||||
expect(afterResults).toHaveLength(1);
|
||||
expect(afterResults[0].id).toBe(task.id);
|
||||
});
|
||||
|
||||
it("sanitizes FTS5 special characters from query", async () => {
|
||||
await store.createTask({ title: "Test with special chars", description: "Query parsing test" });
|
||||
|
||||
// This should not throw and should work correctly
|
||||
const results = await store.searchTasks("test + special (chars)");
|
||||
|
||||
expect(results.length).toBeGreaterThanOrEqual(0); // Should not throw
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1175,6 +1175,60 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
return sorted.slice(offset, offset + Math.max(0, limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* Search tasks by full-text query across title, ID, description, and comments.
|
||||
* Uses SQLite FTS5 for fast tokenized matching with relevance ranking.
|
||||
* Falls back to listTasks() for empty/whitespace-only queries.
|
||||
*
|
||||
* @param query - The search query string
|
||||
* @param options - Optional limit and offset for pagination
|
||||
*/
|
||||
async searchTasks(query: string, options?: { limit?: number; offset?: number }): Promise<Task[]> {
|
||||
// Fall back to listTasks for empty/whitespace-only queries
|
||||
const trimmedQuery = query?.trim();
|
||||
if (!trimmedQuery) {
|
||||
return this.listTasks(options);
|
||||
}
|
||||
|
||||
// Sanitize query for FTS5 safety: strip dangerous operators but preserve alphanumeric
|
||||
const sanitizedTokens = trimmedQuery
|
||||
.split(/\s+/)
|
||||
.filter((token) => token.length > 0)
|
||||
.map((token) => token.replace(/["{}:*^+()]/g, ""))
|
||||
.filter((token) => token.length > 0);
|
||||
|
||||
if (sanitizedTokens.length === 0) {
|
||||
return this.listTasks(options);
|
||||
}
|
||||
|
||||
// For FTS5 MATCH, quote tokens that contain special characters like hyphens
|
||||
// to prevent them from being interpreted as operators
|
||||
const ftsQuery = sanitizedTokens
|
||||
.map((token) => {
|
||||
// If token contains FTS5 special chars, wrap in double quotes
|
||||
if (/[":(){}*^+-]/.test(token)) {
|
||||
return `"${token.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
return token;
|
||||
})
|
||||
.join(" OR ");
|
||||
|
||||
// Execute FTS query with ranking
|
||||
const limit = options?.limit ?? -1;
|
||||
const offset = options?.offset ?? 0;
|
||||
const offsetClause = offset > 0 ? ` OFFSET ${offset}` : "";
|
||||
|
||||
const rows = this.db.prepare(`
|
||||
SELECT t.* FROM tasks t
|
||||
JOIN tasks_fts fts ON t.rowid = fts.rowid
|
||||
WHERE tasks_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ${limit >= 0 ? limit : -1}${offsetClause}
|
||||
`).all(ftsQuery) as any[];
|
||||
|
||||
return rows.map((row) => this.rowToTask(row));
|
||||
}
|
||||
|
||||
async selectNextTaskForAgent(agentId: string): Promise<InboxTask | null> {
|
||||
const tasks = await this.listTasks();
|
||||
if (tasks.length === 0) {
|
||||
|
||||
@@ -41,9 +41,12 @@ function AppInner() {
|
||||
const { nodes } = useNodes();
|
||||
const { currentProject, setCurrentProject, clearCurrentProject, loading: currentProjectLoading } = useCurrentProject(projects);
|
||||
|
||||
// Tasks hook with project context
|
||||
// Search query state - must be defined before useTasks
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Tasks hook with project context and search query
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone } = useTasks(
|
||||
currentProject ? { projectId: currentProject.id } : undefined
|
||||
currentProject ? { projectId: currentProject.id, searchQuery: searchQuery || undefined } : { searchQuery: searchQuery || undefined }
|
||||
);
|
||||
|
||||
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||
@@ -106,7 +109,6 @@ function AppInner() {
|
||||
toggleGlobalPause,
|
||||
toggleEnginePause,
|
||||
} = useAppSettings(currentProject?.id);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const {
|
||||
availableModels,
|
||||
favoriteProviders,
|
||||
|
||||
@@ -93,11 +93,12 @@ async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export function fetchTasks(limit?: number, offset?: number, projectId?: string): Promise<Task[]> {
|
||||
export function fetchTasks(limit?: number, offset?: number, projectId?: string, q?: string): Promise<Task[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (limit !== undefined) search.set("limit", String(limit));
|
||||
if (offset !== undefined) search.set("offset", String(offset));
|
||||
if (projectId) search.set("projectId", projectId);
|
||||
if (q) search.set("q", q);
|
||||
const suffix = search.size > 0 ? `?${search.toString()}` : "";
|
||||
return api<Task[]>(`/tasks${suffix}`);
|
||||
}
|
||||
|
||||
@@ -81,18 +81,8 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
|
||||
setArchivedCollapsed((current) => !current);
|
||||
}, []);
|
||||
|
||||
// Filter tasks based on search query (matches id, title, or description)
|
||||
const filteredTasks = useMemo(() => {
|
||||
if (!searchQuery.trim()) return tasks;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return tasks.filter(
|
||||
(t) =>
|
||||
t.id.toLowerCase().includes(query) ||
|
||||
(t.title && t.title.toLowerCase().includes(query)) ||
|
||||
t.description.toLowerCase().includes(query)
|
||||
);
|
||||
}, [tasks, searchQuery]);
|
||||
|
||||
// Tasks are already server-filtered when searchQuery is active (via useTasks hook).
|
||||
// Client-side filtering is removed - tasks prop is used directly.
|
||||
// Keep per-column array identities stable for unchanged columns so React.memo(Column)
|
||||
// can skip sibling rerenders during unrelated task updates.
|
||||
const tasksByColumn = useMemo(() => {
|
||||
@@ -100,7 +90,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
|
||||
COLUMNS.map((column) => [column, [] as Task[]]),
|
||||
) as Record<ColumnType, Task[]>;
|
||||
|
||||
for (const task of filteredTasks) {
|
||||
for (const task of tasks) {
|
||||
nextGrouped[task.column].push(task);
|
||||
}
|
||||
|
||||
@@ -116,14 +106,14 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
|
||||
|
||||
tasksByColumnCacheRef.current = stableGrouped;
|
||||
return stableGrouped;
|
||||
}, [filteredTasks]);
|
||||
}, [tasks]);
|
||||
|
||||
// Collect task IDs with GitHub badge info for batch fetching
|
||||
const taskIdsWithBadges = useMemo(() => {
|
||||
return filteredTasks
|
||||
return tasks
|
||||
.filter((t) => t.prInfo || t.issueInfo)
|
||||
.map((t) => t.id);
|
||||
}, [filteredTasks]);
|
||||
}, [tasks]);
|
||||
|
||||
// Batch fetch badge statuses on mount and when visible tasks change
|
||||
useEffect(() => {
|
||||
@@ -171,7 +161,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
|
||||
onUpdateTask={onUpdateTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
allTasks={filteredTasks}
|
||||
allTasks={tasks}
|
||||
availableModels={availableModels}
|
||||
onOpenDetailWithTab={onOpenDetailWithTab}
|
||||
favoriteProviders={favoriteProviders}
|
||||
|
||||
@@ -59,6 +59,11 @@ const mockUseTasks = vi.fn(() => ({
|
||||
archiveAllDone: vi.fn(),
|
||||
}));
|
||||
|
||||
// Accept both old and new hook signatures
|
||||
vi.mock("../../hooks/useTasks", () => ({
|
||||
useTasks: (options?: { projectId?: string; searchQuery?: string }) => mockUseTasks(options),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useTasks", () => ({
|
||||
useTasks: () => mockUseTasks(),
|
||||
}));
|
||||
|
||||
@@ -107,14 +107,17 @@ describe("Board", () => {
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("filters tasks by ID when search query is provided", () => {
|
||||
it("renders server-filtered tasks by ID when search query is provided", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "FN-001", description: "First task", column: "todo" }),
|
||||
createTask({ id: "FN-002", description: "Second task", column: "todo" }),
|
||||
createTask({ id: "FN-003", description: "Third task", column: "in-progress" }),
|
||||
];
|
||||
|
||||
renderBoard({ tasks, searchQuery: "FN-002" });
|
||||
// Pre-filtered tasks - only FN-002 matches the search
|
||||
const filteredTasks = [tasks[1]];
|
||||
|
||||
renderBoard({ tasks: filteredTasks, searchQuery: "FN-002" });
|
||||
|
||||
const todoColumn = screen.getByTestId("column-todo");
|
||||
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]");
|
||||
@@ -126,14 +129,17 @@ describe("Board", () => {
|
||||
expect(inProgressTasks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("filters tasks by title when search query is provided", () => {
|
||||
it("renders server-filtered tasks by title when search query is provided", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "FN-001", title: "Fix login bug", description: "First task", column: "todo" }),
|
||||
createTask({ id: "FN-002", title: "Add dashboard feature", description: "Second task", column: "todo" }),
|
||||
createTask({ id: "FN-003", title: "Update documentation", description: "Third task", column: "todo" }),
|
||||
];
|
||||
|
||||
renderBoard({ tasks, searchQuery: "dashboard" });
|
||||
// Pre-filtered tasks - only dashboard matches
|
||||
const filteredTasks = [tasks[1]];
|
||||
|
||||
renderBoard({ tasks: filteredTasks, searchQuery: "dashboard" });
|
||||
|
||||
const todoColumn = screen.getByTestId("column-todo");
|
||||
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]");
|
||||
@@ -141,14 +147,17 @@ describe("Board", () => {
|
||||
expect(todoTasks[0].id).toBe("FN-002");
|
||||
});
|
||||
|
||||
it("filters tasks by description when search query is provided", () => {
|
||||
it("renders server-filtered tasks by description when search query is provided", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "FN-001", description: "Implement user authentication", column: "todo" }),
|
||||
createTask({ id: "FN-002", description: "Fix database connection issue", column: "todo" }),
|
||||
createTask({ id: "FN-003", description: "Add caching layer", column: "todo" }),
|
||||
];
|
||||
|
||||
renderBoard({ tasks, searchQuery: "database" });
|
||||
// Pre-filtered tasks - only database matches
|
||||
const filteredTasks = [tasks[1]];
|
||||
|
||||
renderBoard({ tasks: filteredTasks, searchQuery: "database" });
|
||||
|
||||
const todoColumn = screen.getByTestId("column-todo");
|
||||
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]");
|
||||
@@ -156,13 +165,16 @@ describe("Board", () => {
|
||||
expect(todoTasks[0].id).toBe("FN-002");
|
||||
});
|
||||
|
||||
it("search is case-insensitive", () => {
|
||||
it("search is case-insensitive (server handles this)", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "FN-001", title: "Fix Login Bug", description: "First task", column: "todo" }),
|
||||
createTask({ id: "FN-002", title: "Add Dashboard Feature", description: "Second task", column: "todo" }),
|
||||
];
|
||||
|
||||
renderBoard({ tasks, searchQuery: "login" });
|
||||
// Pre-filtered tasks - only FN-001 matches
|
||||
const filteredTasks = [tasks[0]];
|
||||
|
||||
renderBoard({ tasks: filteredTasks, searchQuery: "login" });
|
||||
|
||||
const todoColumn = screen.getByTestId("column-todo");
|
||||
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]");
|
||||
@@ -170,12 +182,15 @@ describe("Board", () => {
|
||||
expect(todoTasks[0].id).toBe("FN-001");
|
||||
});
|
||||
|
||||
it("search is case-insensitive for lowercase query matching uppercase content", () => {
|
||||
it("search is case-insensitive for lowercase query matching uppercase content (server handles this)", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "FN-UPPER", title: "UPPERCASE TITLE", description: "DESC", column: "todo" }),
|
||||
];
|
||||
|
||||
renderBoard({ tasks, searchQuery: "upper" });
|
||||
// Pre-filtered tasks - FN-UPPER matches
|
||||
const filteredTasks = [tasks[0]];
|
||||
|
||||
renderBoard({ tasks: filteredTasks, searchQuery: "upper" });
|
||||
|
||||
const todoColumn = screen.getByTestId("column-todo");
|
||||
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]");
|
||||
@@ -201,13 +216,16 @@ describe("Board", () => {
|
||||
expect(inProgressTasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows no tasks when search query matches nothing", () => {
|
||||
it("shows no tasks when search query matches nothing (server returns empty)", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "FN-001", description: "First task", column: "todo" }),
|
||||
createTask({ id: "FN-002", description: "Second task", column: "todo" }),
|
||||
];
|
||||
|
||||
renderBoard({ tasks, searchQuery: "nonexistent" });
|
||||
// Pre-filtered tasks - empty array because server found no matches
|
||||
const filteredTasks: Task[] = [];
|
||||
|
||||
renderBoard({ tasks: filteredTasks, searchQuery: "nonexistent" });
|
||||
|
||||
const todoColumn = screen.getByTestId("column-todo");
|
||||
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]");
|
||||
@@ -299,24 +317,27 @@ describe("Board", () => {
|
||||
expect(todoTasks[2].id).toBe("FN-003");
|
||||
});
|
||||
|
||||
it("matches tasks across multiple fields simultaneously", () => {
|
||||
it("renders server-filtered tasks matching across multiple fields simultaneously", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "SEARCH-123", title: "Searchable title", description: "Normal description", column: "todo" }),
|
||||
createTask({ id: "FN-999", title: "Other task", description: "This has searchable content", column: "todo" }),
|
||||
createTask({ id: "FN-888", title: "Unrelated", description: "No match here", column: "todo" }),
|
||||
];
|
||||
|
||||
renderBoard({ tasks, searchQuery: "search" });
|
||||
// Pre-filtered tasks - only the two matching tasks
|
||||
const filteredTasks = [tasks[0], tasks[1]];
|
||||
|
||||
renderBoard({ tasks: filteredTasks, searchQuery: "search" });
|
||||
|
||||
const todoColumn = screen.getByTestId("column-todo");
|
||||
const todoTasks = JSON.parse(todoColumn.getAttribute("data-tasks") || "[]");
|
||||
|
||||
// Should match both tasks with "search" in ID, title, or description
|
||||
// Should have both matching tasks
|
||||
expect(todoTasks).toHaveLength(2);
|
||||
expect(todoTasks.map((t: Task) => t.id).sort()).toEqual(["FN-999", "SEARCH-123"]);
|
||||
});
|
||||
|
||||
it("trims whitespace from search query", () => {
|
||||
it("shows all tasks for whitespace-only search query (server treats as empty)", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "FN-001", description: "First task", column: "todo" }),
|
||||
];
|
||||
|
||||
@@ -34,10 +34,16 @@ export interface UseTasksOptions {
|
||||
* Note: SSE updates are not filtered by project in current implementation.
|
||||
*/
|
||||
projectId?: string;
|
||||
/**
|
||||
* When provided, fetches tasks matching this search query.
|
||||
* Server-side full-text search across title, ID, description, and comments.
|
||||
*/
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export function useTasks(options?: UseTasksOptions) {
|
||||
const projectId = options?.projectId;
|
||||
const searchQuery = options?.searchQuery;
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [connectionNonce, setConnectionNonce] = useState(0);
|
||||
const tasksRef = useRef(tasks);
|
||||
@@ -47,11 +53,12 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
|
||||
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
|
||||
|
||||
const refreshTasks = useCallback(async (options?: { clearOnError?: boolean }) => {
|
||||
const refreshTasks = useCallback(async (options?: { clearOnError?: boolean; searchQueryOverride?: string }) => {
|
||||
const requestVersion = ++fetchVersionRef.current;
|
||||
const query = options?.searchQueryOverride ?? searchQuery;
|
||||
|
||||
try {
|
||||
const fetchedTasks = await api.fetchTasks(undefined, undefined, projectId);
|
||||
const fetchedTasks = await api.fetchTasks(undefined, undefined, projectId, query);
|
||||
if (fetchVersionRef.current !== requestVersion) {
|
||||
return;
|
||||
}
|
||||
@@ -66,7 +73,16 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
}
|
||||
setTasks((current) => current);
|
||||
}
|
||||
}, [projectId]);
|
||||
}, [projectId, searchQuery]);
|
||||
|
||||
// Debounced search effect - separate from refreshTasks to avoid dependency cycle
|
||||
useEffect(() => {
|
||||
if (searchQuery === undefined) return;
|
||||
const timer = setTimeout(() => {
|
||||
void refreshTasks({ searchQueryOverride: searchQuery });
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]); // intentionally NOT including refreshTasks in deps
|
||||
|
||||
// Fetch initial tasks and recover when the tab becomes visible again.
|
||||
useEffect(() => {
|
||||
@@ -124,6 +140,11 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
const handleCreated = (e: MessageEvent) => {
|
||||
resetHeartbeat();
|
||||
const task = normalizeTask(JSON.parse(e.data) as Task);
|
||||
// When search is active, re-fetch to get server-filtered results
|
||||
if (searchQuery) {
|
||||
void refreshTasks({ searchQueryOverride: searchQuery });
|
||||
return;
|
||||
}
|
||||
// In project mode, only add if this task belongs to our project
|
||||
// Since we can't determine project from event, we add and let subsequent
|
||||
// fetches correct the state, or filter by checking if task exists in our set
|
||||
@@ -136,6 +157,11 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
|
||||
const handleMoved = (e: MessageEvent) => {
|
||||
resetHeartbeat();
|
||||
// When search is active, re-fetch to get server-filtered results
|
||||
if (searchQuery) {
|
||||
void refreshTasks({ searchQueryOverride: searchQuery });
|
||||
return;
|
||||
}
|
||||
const { task, to }: { task: Task; from: Column; to: Column } = JSON.parse(e.data);
|
||||
const normalizedTask = normalizeTask(task);
|
||||
setTasks((prev) =>
|
||||
@@ -147,6 +173,11 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
|
||||
const handleUpdated = (e: MessageEvent) => {
|
||||
resetHeartbeat();
|
||||
// When search is active, re-fetch to get server-filtered results
|
||||
if (searchQuery) {
|
||||
void refreshTasks({ searchQueryOverride: searchQuery });
|
||||
return;
|
||||
}
|
||||
const incoming = normalizeTask(JSON.parse(e.data) as Task);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
@@ -177,12 +208,22 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
|
||||
const handleDeleted = (e: MessageEvent) => {
|
||||
resetHeartbeat();
|
||||
// When search is active, re-fetch to get server-filtered results
|
||||
if (searchQuery) {
|
||||
void refreshTasks({ searchQueryOverride: searchQuery });
|
||||
return;
|
||||
}
|
||||
const task = normalizeTask(JSON.parse(e.data) as Task);
|
||||
setTasks((prev) => prev.filter((t) => t.id !== task.id));
|
||||
};
|
||||
|
||||
const handleMerged = (e: MessageEvent) => {
|
||||
resetHeartbeat();
|
||||
// When search is active, re-fetch to get server-filtered results
|
||||
if (searchQuery) {
|
||||
void refreshTasks({ searchQueryOverride: searchQuery });
|
||||
return;
|
||||
}
|
||||
const { task }: { task: Task } = JSON.parse(e.data);
|
||||
const normalizedTask = normalizeTask(task);
|
||||
setTasks((prev) =>
|
||||
@@ -237,7 +278,7 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
closedByCleanup = true;
|
||||
cleanup();
|
||||
};
|
||||
}, [connectionNonce, projectId, refreshTasks]);
|
||||
}, [connectionNonce, projectId, searchQuery, refreshTasks]);
|
||||
|
||||
const createTask = useCallback(async (input: TaskCreateInput): Promise<Task> => {
|
||||
const task = normalizeTask(await api.createTask(input, projectId));
|
||||
|
||||
@@ -60,6 +60,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
getTask: vi.fn(),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
searchTasks: vi.fn().mockResolvedValue([]),
|
||||
createTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
@@ -171,6 +172,45 @@ describe("GET /tasks", () => {
|
||||
expect(store.listTasks).toHaveBeenCalledWith({ limit: 10, offset: 5 });
|
||||
});
|
||||
|
||||
it("returns tasks for search query", async () => {
|
||||
(store.searchTasks as ReturnType<typeof vi.fn>).mockResolvedValueOnce([FAKE_TASK_DETAIL]);
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks?q=FN-001");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(store.searchTasks).toHaveBeenCalledWith("FN-001", { limit: undefined, offset: undefined });
|
||||
});
|
||||
|
||||
it("returns tasks for search query with limit", async () => {
|
||||
(store.searchTasks as ReturnType<typeof vi.fn>).mockResolvedValueOnce([FAKE_TASK_DETAIL]);
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks?q=something&limit=5");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(store.searchTasks).toHaveBeenCalledWith("something", { limit: 5, offset: undefined });
|
||||
});
|
||||
|
||||
it("returns empty array for non-existent search query", async () => {
|
||||
(store.searchTasks as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks?q=nonexistent");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to listTasks for empty search query", async () => {
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValueOnce([FAKE_TASK_DETAIL]);
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks?q=");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.listTasks).toHaveBeenCalled();
|
||||
expect(store.searchTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for invalid pagination params", async () => {
|
||||
const res = await GET(buildApp(), "/api/tasks?limit=-1");
|
||||
|
||||
|
||||
@@ -1907,6 +1907,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const limit = typeof req.query.limit === "string" ? Number.parseInt(req.query.limit, 10) : undefined;
|
||||
const offset = typeof req.query.offset === "string" ? Number.parseInt(req.query.offset, 10) : undefined;
|
||||
const q = typeof req.query.q === "string" ? req.query.q.trim() : undefined;
|
||||
|
||||
if (limit !== undefined && (!Number.isFinite(limit) || limit < 0)) {
|
||||
throw badRequest("limit must be a non-negative integer");
|
||||
@@ -1916,7 +1917,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
throw badRequest("offset must be a non-negative integer");
|
||||
}
|
||||
|
||||
const tasks = await scopedStore.listTasks({ limit, offset });
|
||||
let tasks;
|
||||
if (q && q.length > 0) {
|
||||
tasks = await scopedStore.searchTasks(q, { limit, offset });
|
||||
} else {
|
||||
tasks = await scopedStore.listTasks({ limit, offset });
|
||||
}
|
||||
res.json(tasks);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
|
||||
Reference in New Issue
Block a user