This is page 7 of 8. Use http://codebase.md/cyanheads/atlas-mcp-server?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .clinerules
├── .dockerignore
├── .env.example
├── .github
│ ├── FUNDING.yml
│ └── workflows
│ └── publish.yml
├── .gitignore
├── .ncurc.json
├── .repomixignore
├── automated-tests
│ └── AGENT_TEST_05282025.md
├── CHANGELOG.md
├── CLAUDE.md
├── docker-compose.yml
├── docs
│ └── tree.md
├── examples
│ ├── backup-example
│ │ ├── knowledges.json
│ │ ├── projects.json
│ │ ├── relationships.json
│ │ └── tasks.json
│ ├── deep-research-example
│ │ ├── covington_community_grant_research.md
│ │ └── full-export.json
│ ├── README.md
│ └── webui-example.png
├── LICENSE
├── mcp.json
├── package-lock.json
├── package.json
├── README.md
├── repomix.config.json
├── scripts
│ ├── clean.ts
│ ├── fetch-openapi-spec.ts
│ ├── make-executable.ts
│ └── tree.ts
├── smithery.yaml
├── src
│ ├── config
│ │ └── index.ts
│ ├── index.ts
│ ├── mcp
│ │ ├── resources
│ │ │ ├── index.ts
│ │ │ ├── knowledge
│ │ │ │ └── knowledgeResources.ts
│ │ │ ├── projects
│ │ │ │ └── projectResources.ts
│ │ │ ├── tasks
│ │ │ │ └── taskResources.ts
│ │ │ └── types.ts
│ │ ├── server.ts
│ │ ├── tools
│ │ │ ├── atlas_database_clean
│ │ │ │ ├── cleanDatabase.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── responseFormat.ts
│ │ │ │ └── types.ts
│ │ │ ├── atlas_deep_research
│ │ │ │ ├── deepResearch.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── responseFormat.ts
│ │ │ │ └── types.ts
│ │ │ ├── atlas_knowledge_add
│ │ │ │ ├── addKnowledge.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── responseFormat.ts
│ │ │ │ └── types.ts
│ │ │ ├── atlas_knowledge_delete
│ │ │ │ ├── deleteKnowledge.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── responseFormat.ts
│ │ │ │ └── types.ts
│ │ │ ├── atlas_knowledge_list
│ │ │ │ ├── index.ts
│ │ │ │ ├── listKnowledge.ts
│ │ │ │ ├── responseFormat.ts
│ │ │ │ └── types.ts
│ │ │ ├── atlas_project_create
│ │ │ │ ├── createProject.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── responseFormat.ts
│ │ │ │ └── types.ts
│ │ │ ├── atlas_project_delete
│ │ │ │ ├── deleteProject.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── responseFormat.ts
│ │ │ │ └── types.ts
│ │ │ ├── atlas_project_list
│ │ │ │ ├── index.ts
│ │ │ │ ├── listProjects.ts
│ │ │ │ ├── responseFormat.ts
│ │ │ │ └── types.ts
│ │ │ ├── atlas_project_update
│ │ │ │ ├── index.ts
│ │ │ │ ├── responseFormat.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── updateProject.ts
│ │ │ ├── atlas_task_create
│ │ │ │ ├── createTask.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── responseFormat.ts
│ │ │ │ └── types.ts
│ │ │ ├── atlas_task_delete
│ │ │ │ ├── deleteTask.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── responseFormat.ts
│ │ │ │ └── types.ts
│ │ │ ├── atlas_task_list
│ │ │ │ ├── index.ts
│ │ │ │ ├── listTasks.ts
│ │ │ │ ├── responseFormat.ts
│ │ │ │ └── types.ts
│ │ │ ├── atlas_task_update
│ │ │ │ ├── index.ts
│ │ │ │ ├── responseFormat.ts
│ │ │ │ ├── types.ts
│ │ │ │ └── updateTask.ts
│ │ │ └── atlas_unified_search
│ │ │ ├── index.ts
│ │ │ ├── responseFormat.ts
│ │ │ ├── types.ts
│ │ │ └── unifiedSearch.ts
│ │ └── transports
│ │ ├── authentication
│ │ │ └── authMiddleware.ts
│ │ ├── httpTransport.ts
│ │ └── stdioTransport.ts
│ ├── services
│ │ └── neo4j
│ │ ├── backupRestoreService
│ │ │ ├── backupRestoreTypes.ts
│ │ │ ├── backupUtils.ts
│ │ │ ├── exportLogic.ts
│ │ │ ├── importLogic.ts
│ │ │ ├── index.ts
│ │ │ └── scripts
│ │ │ ├── db-backup.ts
│ │ │ └── db-import.ts
│ │ ├── driver.ts
│ │ ├── events.ts
│ │ ├── helpers.ts
│ │ ├── index.ts
│ │ ├── knowledgeService.ts
│ │ ├── projectService.ts
│ │ ├── searchService
│ │ │ ├── fullTextSearchLogic.ts
│ │ │ ├── index.ts
│ │ │ ├── searchTypes.ts
│ │ │ └── unifiedSearchLogic.ts
│ │ ├── taskService.ts
│ │ ├── types.ts
│ │ └── utils.ts
│ ├── types
│ │ ├── errors.ts
│ │ ├── mcp.ts
│ │ └── tool.ts
│ ├── utils
│ │ ├── index.ts
│ │ ├── internal
│ │ │ ├── errorHandler.ts
│ │ │ ├── index.ts
│ │ │ ├── logger.ts
│ │ │ └── requestContext.ts
│ │ ├── metrics
│ │ │ ├── index.ts
│ │ │ └── tokenCounter.ts
│ │ ├── parsing
│ │ │ ├── dateParser.ts
│ │ │ ├── index.ts
│ │ │ └── jsonParser.ts
│ │ └── security
│ │ ├── idGenerator.ts
│ │ ├── index.ts
│ │ ├── rateLimiter.ts
│ │ └── sanitization.ts
│ └── webui
│ ├── index.html
│ ├── logic
│ │ ├── api-service.js
│ │ ├── app-state.js
│ │ ├── config.js
│ │ ├── dom-elements.js
│ │ ├── main.js
│ │ └── ui-service.js
│ └── styling
│ ├── base.css
│ ├── components.css
│ ├── layout.css
│ └── theme.css
├── tsconfig.json
├── tsconfig.typedoc.json
└── typedoc.json
```
# Files
--------------------------------------------------------------------------------
/src/services/neo4j/projectService.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { logger, requestContextService } from "../../utils/index.js"; // Updated import path
2 | import { neo4jDriver } from "./driver.js";
3 | import { buildListQuery, generateId } from "./helpers.js"; // Import buildListQuery
4 | import {
5 | Neo4jProject,
6 | NodeLabels,
7 | PaginatedResult,
8 | ProjectDependencyType, // Import the new enum
9 | ProjectFilterOptions,
10 | RelationshipTypes,
11 | } from "./types.js";
12 | import { Neo4jUtils } from "./utils.js";
13 |
14 | /**
15 | * Service for managing Project entities in Neo4j
16 | */
17 | export class ProjectService {
18 | /**
19 | * Create a new project
20 | * @param project Project data
21 | * @returns The created project
22 | */
23 | static async createProject(
24 | project: Omit<Neo4jProject, "id" | "createdAt" | "updatedAt"> & {
25 | id?: string;
26 | },
27 | ): Promise<Neo4jProject> {
28 | const session = await neo4jDriver.getSession();
29 |
30 | try {
31 | const projectId = project.id || `proj_${generateId()}`;
32 | const now = Neo4jUtils.getCurrentTimestamp();
33 |
34 | // Neo4j properties must be primitive types or arrays of primitives.
35 | // Serialize the 'urls' array (which contains objects) to a JSON string for storage.
36 | const query = `
37 | CREATE (p:${NodeLabels.Project} {
38 | id: $id,
39 | name: $name,
40 | description: $description,
41 | status: $status,
42 | urls: $urls,
43 | completionRequirements: $completionRequirements,
44 | outputFormat: $outputFormat,
45 | taskType: $taskType,
46 | createdAt: $createdAt,
47 | updatedAt: $updatedAt
48 | })
49 | RETURN p.id as id,
50 | p.name as name,
51 | p.description as description,
52 | p.status as status,
53 | p.urls as urls,
54 | p.completionRequirements as completionRequirements,
55 | p.outputFormat as outputFormat,
56 | p.taskType as taskType,
57 | p.createdAt as createdAt,
58 | p.updatedAt as updatedAt
59 | `;
60 |
61 | // Serialize urls to JSON string before passing as parameter
62 | const params = {
63 | id: projectId,
64 | name: project.name,
65 | description: project.description,
66 | status: project.status,
67 | urls: JSON.stringify(project.urls || []), // Serialize to JSON string
68 | completionRequirements: project.completionRequirements,
69 | outputFormat: project.outputFormat,
70 | taskType: project.taskType,
71 | createdAt: now,
72 | updatedAt: now,
73 | };
74 |
75 | const result = await session.executeWrite(async (tx) => {
76 | const result = await tx.run(query, params);
77 | // Use .get() for each field to ensure type safety
78 | return result.records.length > 0 ? result.records[0] : null;
79 | });
80 |
81 | if (!result) {
82 | throw new Error("Failed to create project or retrieve its properties");
83 | }
84 |
85 | // Explicitly construct the object and deserialize urls from JSON string
86 | const createdProjectData: Neo4jProject = {
87 | id: result.get("id"),
88 | name: result.get("name"),
89 | description: result.get("description"),
90 | status: result.get("status"),
91 | urls: JSON.parse(result.get("urls") || "[]"), // Deserialize from JSON string
92 | completionRequirements: result.get("completionRequirements"),
93 | outputFormat: result.get("outputFormat"),
94 | taskType: result.get("taskType"),
95 | createdAt: result.get("createdAt"),
96 | updatedAt: result.get("updatedAt"),
97 | };
98 |
99 | // Now createdProjectData has the correct type before this line
100 | const reqContext_create = requestContextService.createRequestContext({
101 | operation: "createProject",
102 | projectId: createdProjectData.id,
103 | });
104 | logger.info("Project created successfully", reqContext_create);
105 | return createdProjectData; // No need for 'as Neo4jProject' here anymore
106 | } catch (error) {
107 | const errorMessage =
108 | error instanceof Error ? error.message : String(error);
109 | const errorContext = requestContextService.createRequestContext({
110 | operation: "createProject.error",
111 | projectInput: project,
112 | });
113 | logger.error("Error creating project", error as Error, {
114 | ...errorContext,
115 | detail: errorMessage,
116 | });
117 | throw error;
118 | } finally {
119 | await session.close();
120 | }
121 | }
122 |
123 | /**
124 | * Get a project by ID
125 | * @param id Project ID
126 | * @returns The project or null if not found
127 | */
128 | static async getProjectById(id: string): Promise<Neo4jProject | null> {
129 | const session = await neo4jDriver.getSession();
130 |
131 | try {
132 | // Retrieve urls as JSON string and deserialize later
133 | const query = `
134 | MATCH (p:${NodeLabels.Project} {id: $id})
135 | RETURN p.id as id,
136 | p.name as name,
137 | p.description as description,
138 | p.status as status,
139 | p.urls as urls,
140 | p.completionRequirements as completionRequirements,
141 | p.outputFormat as outputFormat,
142 | p.taskType as taskType,
143 | p.createdAt as createdAt,
144 | p.updatedAt as updatedAt
145 | `;
146 |
147 | const result = await session.executeRead(async (tx) => {
148 | const result = await tx.run(query, { id });
149 | return result.records;
150 | });
151 |
152 | if (result.length === 0) {
153 | return null;
154 | }
155 |
156 | const record = result[0];
157 | // Explicitly construct the object and deserialize urls from JSON string
158 | const projectData: Neo4jProject = {
159 | id: record.get("id"),
160 | name: record.get("name"),
161 | description: record.get("description"),
162 | status: record.get("status"),
163 | urls: JSON.parse(record.get("urls") || "[]"), // Deserialize from JSON string
164 | completionRequirements: record.get("completionRequirements"),
165 | outputFormat: record.get("outputFormat"),
166 | taskType: record.get("taskType"),
167 | createdAt: record.get("createdAt"),
168 | updatedAt: record.get("updatedAt"),
169 | };
170 |
171 | return projectData;
172 | } catch (error) {
173 | const errorMessage =
174 | error instanceof Error ? error.message : String(error);
175 | const errorContext = requestContextService.createRequestContext({
176 | operation: "getProjectById.error",
177 | projectId: id,
178 | });
179 | logger.error("Error getting project by ID", error as Error, {
180 | ...errorContext,
181 | detail: errorMessage,
182 | });
183 | throw error;
184 | } finally {
185 | await session.close();
186 | }
187 | }
188 |
189 | /**
190 | * Check if all dependencies of a project are completed
191 | * @param projectId Project ID to check dependencies for
192 | * @returns True if all dependencies are completed, false otherwise
193 | */
194 | static async areAllDependenciesCompleted(
195 | projectId: string,
196 | ): Promise<boolean> {
197 | const session = await neo4jDriver.getSession();
198 |
199 | try {
200 | // Query remains the same
201 | const query = `
202 | MATCH (p:${NodeLabels.Project} {id: $projectId})-[:${RelationshipTypes.DEPENDS_ON}]->(dep:${NodeLabels.Project})
203 | WHERE dep.status <> 'completed'
204 | RETURN count(dep) AS incompleteCount
205 | `;
206 |
207 | const result = await session.executeRead(async (tx) => {
208 | const result = await tx.run(query, { projectId });
209 | // Use .get() for each field and check existence before calling toNumber()
210 | const record = result.records[0];
211 | const countField = record ? record.get("incompleteCount") : null;
212 | // Neo4j count() usually returns a standard JS number or a Neo4j Integer
213 | // Handle both cases: if it has toNumber, use it; otherwise, assume it's a number or 0.
214 | return countField && typeof countField.toNumber === "function"
215 | ? countField.toNumber()
216 | : countField || 0;
217 | });
218 |
219 | // Check if the count is exactly 0
220 | return result === 0;
221 | } catch (error) {
222 | const errorMessage =
223 | error instanceof Error ? error.message : String(error);
224 | const errorContext = requestContextService.createRequestContext({
225 | operation: "areAllDependenciesCompleted.error",
226 | projectId,
227 | });
228 | logger.error(
229 | "Error checking project dependencies completion status",
230 | error as Error,
231 | { ...errorContext, detail: errorMessage },
232 | );
233 | throw error;
234 | } finally {
235 | await session.close();
236 | }
237 | }
238 |
239 | /**
240 | * Update a project
241 | * @param id Project ID
242 | * @param updates Project updates
243 | * @returns The updated project
244 | */
245 | static async updateProject(
246 | id: string,
247 | updates: Partial<Omit<Neo4jProject, "id" | "createdAt" | "updatedAt">>,
248 | ): Promise<Neo4jProject> {
249 | const session = await neo4jDriver.getSession();
250 |
251 | try {
252 | const exists = await Neo4jUtils.nodeExists(NodeLabels.Project, "id", id);
253 | if (!exists) {
254 | throw new Error(`Project with ID ${id} not found`);
255 | }
256 |
257 | if (updates.status === "in-progress" || updates.status === "completed") {
258 | const depsCompleted = await this.areAllDependenciesCompleted(id);
259 | if (!depsCompleted) {
260 | throw new Error(
261 | `Cannot mark project as ${updates.status} because not all dependencies are completed`,
262 | );
263 | }
264 | }
265 |
266 | const updateParams: Record<string, any> = {
267 | id,
268 | updatedAt: Neo4jUtils.getCurrentTimestamp(),
269 | };
270 | let setClauses = ["p.updatedAt = $updatedAt"];
271 |
272 | // Serialize urls to JSON string if it's part of the updates
273 | for (const [key, value] of Object.entries(updates)) {
274 | if (value !== undefined) {
275 | // Serialize urls array to JSON string if it's the key being updated
276 | updateParams[key] =
277 | key === "urls" ? JSON.stringify(value || []) : value;
278 | setClauses.push(`p.${key} = $${key}`);
279 | }
280 | }
281 |
282 | // Retrieve urls as JSON string and deserialize later
283 | const query = `
284 | MATCH (p:${NodeLabels.Project} {id: $id})
285 | SET ${setClauses.join(", ")}
286 | RETURN p.id as id,
287 | p.name as name,
288 | p.description as description,
289 | p.status as status,
290 | p.urls as urls,
291 | p.completionRequirements as completionRequirements,
292 | p.outputFormat as outputFormat,
293 | p.taskType as taskType,
294 | p.createdAt as createdAt,
295 | p.updatedAt as updatedAt
296 | `;
297 |
298 | const result = await session.executeWrite(async (tx) => {
299 | const result = await tx.run(query, updateParams);
300 | // Use .get() for each field
301 | return result.records.length > 0 ? result.records[0] : null;
302 | });
303 |
304 | if (!result) {
305 | throw new Error("Failed to update project or retrieve its properties");
306 | }
307 |
308 | // Explicitly construct the object and deserialize urls from JSON string
309 | const updatedProjectData: Neo4jProject = {
310 | id: result.get("id"),
311 | name: result.get("name"),
312 | description: result.get("description"),
313 | status: result.get("status"),
314 | urls: JSON.parse(result.get("urls") || "[]"), // Deserialize from JSON string
315 | completionRequirements: result.get("completionRequirements"),
316 | outputFormat: result.get("outputFormat"),
317 | taskType: result.get("taskType"),
318 | createdAt: result.get("createdAt"),
319 | updatedAt: result.get("updatedAt"),
320 | };
321 | const reqContext_update = requestContextService.createRequestContext({
322 | operation: "updateProject",
323 | projectId: id,
324 | });
325 | logger.info("Project updated successfully", reqContext_update);
326 | return updatedProjectData;
327 | } catch (error) {
328 | const errorMessage =
329 | error instanceof Error ? error.message : String(error);
330 | const errorContext = requestContextService.createRequestContext({
331 | operation: "updateProject.error",
332 | projectId: id,
333 | updatesApplied: updates,
334 | });
335 | logger.error("Error updating project", error as Error, {
336 | ...errorContext,
337 | detail: errorMessage,
338 | });
339 | throw error;
340 | } finally {
341 | await session.close();
342 | }
343 | }
344 |
345 | /**
346 | * Delete a project and all its associated tasks and knowledge items
347 | * @param id Project ID
348 | * @returns True if deleted, false if not found
349 | */
350 | static async deleteProject(id: string): Promise<boolean> {
351 | const session = await neo4jDriver.getSession();
352 |
353 | try {
354 | const exists = await Neo4jUtils.nodeExists(NodeLabels.Project, "id", id);
355 | if (!exists) {
356 | return false;
357 | }
358 |
359 | // DETACH DELETE remains the same
360 | const query = `
361 | MATCH (p:${NodeLabels.Project} {id: $id})
362 | DETACH DELETE p
363 | `;
364 |
365 | await session.executeWrite(async (tx) => {
366 | await tx.run(query, { id });
367 | });
368 | const reqContext_delete = requestContextService.createRequestContext({
369 | operation: "deleteProject",
370 | projectId: id,
371 | });
372 | logger.info("Project deleted successfully", reqContext_delete);
373 | return true;
374 | } catch (error) {
375 | const errorMessage =
376 | error instanceof Error ? error.message : String(error);
377 | const errorContext = requestContextService.createRequestContext({
378 | operation: "deleteProject.error",
379 | projectId: id,
380 | });
381 | logger.error("Error deleting project", error as Error, {
382 | ...errorContext,
383 | detail: errorMessage,
384 | });
385 | throw error;
386 | } finally {
387 | await session.close();
388 | }
389 | }
390 |
391 | /**
392 | * Get all projects with optional filtering and pagination
393 | * @param options Filter and pagination options
394 | * @returns Paginated list of projects
395 | */
396 | static async getProjects(
397 | options: ProjectFilterOptions = {},
398 | ): Promise<PaginatedResult<Neo4jProject>> {
399 | const session = await neo4jDriver.getSession();
400 |
401 | try {
402 | const nodeAlias = "p";
403 |
404 | // Define the properties to return
405 | const returnProperties = [
406 | `${nodeAlias}.id as id`,
407 | `${nodeAlias}.name as name`,
408 | `${nodeAlias}.description as description`,
409 | `${nodeAlias}.status as status`,
410 | `${nodeAlias}.urls as urls`,
411 | `${nodeAlias}.completionRequirements as completionRequirements`,
412 | `${nodeAlias}.outputFormat as outputFormat`,
413 | `${nodeAlias}.taskType as taskType`,
414 | `${nodeAlias}.createdAt as createdAt`,
415 | `${nodeAlias}.updatedAt as updatedAt`,
416 | ];
417 |
418 | // Use buildListQuery helper
419 | // Note: searchTerm filter is not currently supported by buildListQuery
420 | if (options.searchTerm) {
421 | logger.warning(
422 | "searchTerm filter is not currently supported in getProjects when using buildListQuery helper.",
423 | );
424 | }
425 |
426 | const { countQuery, dataQuery, params } = buildListQuery(
427 | NodeLabels.Project,
428 | returnProperties,
429 | {
430 | // Filters
431 | status: options.status,
432 | taskType: options.taskType,
433 | // searchTerm is omitted here
434 | },
435 | {
436 | // Pagination
437 | sortBy: "createdAt", // Default sort for projects
438 | sortDirection: "desc",
439 | page: options.page,
440 | limit: options.limit,
441 | },
442 | nodeAlias, // Primary node alias
443 | // No additional MATCH clauses needed for basic project listing
444 | );
445 |
446 | // Execute count query
447 | const reqContext_list = requestContextService.createRequestContext({
448 | operation: "getProjects",
449 | filterOptions: options,
450 | });
451 | const totalResult = await session.executeRead(async (tx) => {
452 | const countParams = { ...params };
453 | delete countParams.skip;
454 | delete countParams.limit;
455 | logger.debug("Executing Project Count Query (using buildListQuery):", {
456 | ...reqContext_list,
457 | query: countQuery,
458 | params: countParams,
459 | });
460 | const result = await tx.run(countQuery, countParams);
461 | return result.records[0]?.get("total") ?? 0;
462 | });
463 | const total = totalResult;
464 |
465 | logger.debug("Calculated total projects", { ...reqContext_list, total });
466 |
467 | // Execute data query
468 | const dataResult = await session.executeRead(async (tx) => {
469 | logger.debug("Executing Project Data Query (using buildListQuery):", {
470 | ...reqContext_list,
471 | query: dataQuery,
472 | params: params,
473 | });
474 | const result = await tx.run(dataQuery, params);
475 | return result.records;
476 | });
477 |
478 | // Map results - deserialize urls from JSON string
479 | const projects: Neo4jProject[] = dataResult.map((record) => {
480 | // Explicitly construct the object and deserialize urls
481 | const projectData: Neo4jProject = {
482 | id: record.get("id"),
483 | name: record.get("name"),
484 | description: record.get("description"),
485 | status: record.get("status"),
486 | urls: JSON.parse(record.get("urls") || "[]"), // Deserialize from JSON string
487 | completionRequirements: record.get("completionRequirements"),
488 | outputFormat: record.get("outputFormat"),
489 | taskType: record.get("taskType"),
490 | createdAt: record.get("createdAt"),
491 | updatedAt: record.get("updatedAt"),
492 | };
493 | return projectData;
494 | });
495 |
496 | const page = Math.max(options.page || 1, 1);
497 | const limit = Math.min(Math.max(options.limit || 20, 1), 100);
498 | const totalPages = Math.ceil(total / limit);
499 |
500 | return {
501 | data: projects,
502 | total,
503 | page,
504 | limit,
505 | totalPages,
506 | };
507 | } catch (error) {
508 | const errorMessage =
509 | error instanceof Error ? error.message : String(error);
510 | const errorContext = requestContextService.createRequestContext({
511 | operation: "getProjects.error",
512 | filterOptions: options,
513 | });
514 | logger.error("Error getting projects", error as Error, {
515 | ...errorContext,
516 | detail: errorMessage,
517 | });
518 | throw error;
519 | } finally {
520 | await session.close();
521 | }
522 | }
523 |
524 | /**
525 | * Add a dependency relationship between projects
526 | * @param sourceProjectId ID of the dependent project (source)
527 | * @param targetProjectId ID of the dependency project (target)
528 | * @param type Type of dependency relationship - TODO: Use enum/constant
529 | * @param description Description of the dependency
530 | * @returns The IDs of the two projects and the relationship type
531 | */
532 | static async addProjectDependency(
533 | sourceProjectId: string,
534 | targetProjectId: string,
535 | type: ProjectDependencyType, // Use the enum
536 | description: string,
537 | ): Promise<{
538 | id: string;
539 | sourceProjectId: string;
540 | targetProjectId: string;
541 | type: string;
542 | description: string;
543 | }> {
544 | const session = await neo4jDriver.getSession();
545 |
546 | try {
547 | // Logic remains the same
548 | const sourceExists = await Neo4jUtils.nodeExists(
549 | NodeLabels.Project,
550 | "id",
551 | sourceProjectId,
552 | );
553 | const targetExists = await Neo4jUtils.nodeExists(
554 | NodeLabels.Project,
555 | "id",
556 | targetProjectId,
557 | );
558 |
559 | if (!sourceExists)
560 | throw new Error(`Source project with ID ${sourceProjectId} not found`);
561 | if (!targetExists)
562 | throw new Error(`Target project with ID ${targetProjectId} not found`);
563 |
564 | const dependencyExists = await Neo4jUtils.relationshipExists(
565 | NodeLabels.Project,
566 | "id",
567 | sourceProjectId,
568 | NodeLabels.Project,
569 | "id",
570 | targetProjectId,
571 | RelationshipTypes.DEPENDS_ON,
572 | );
573 |
574 | if (dependencyExists) {
575 | throw new Error(
576 | `Dependency relationship already exists between projects ${sourceProjectId} and ${targetProjectId}`,
577 | );
578 | }
579 |
580 | const circularDependencyQuery = `
581 | MATCH path = (target:${NodeLabels.Project} {id: $targetProjectId})-[:${RelationshipTypes.DEPENDS_ON}*]->(source:${NodeLabels.Project} {id: $sourceProjectId})
582 | RETURN count(path) > 0 AS hasCycle
583 | `;
584 |
585 | const cycleCheckResult = await session.executeRead(async (tx) => {
586 | const result = await tx.run(circularDependencyQuery, {
587 | sourceProjectId,
588 | targetProjectId,
589 | });
590 | return result.records[0]?.get("hasCycle");
591 | });
592 |
593 | if (cycleCheckResult) {
594 | throw new Error(
595 | "Adding this dependency would create a circular dependency chain",
596 | );
597 | }
598 |
599 | const dependencyId = `pdep_${generateId()}`;
600 | const query = `
601 | MATCH (source:${NodeLabels.Project} {id: $sourceProjectId}),
602 | (target:${NodeLabels.Project} {id: $targetProjectId})
603 | CREATE (source)-[r:${RelationshipTypes.DEPENDS_ON} {
604 | id: $dependencyId,
605 | type: $type,
606 | description: $description,
607 | createdAt: $createdAt
608 | }]->(target)
609 | RETURN r.id as id, source.id as sourceProjectId, target.id as targetProjectId, r.type as type, r.description as description
610 | `;
611 |
612 | const params = {
613 | sourceProjectId,
614 | targetProjectId,
615 | dependencyId,
616 | type,
617 | description,
618 | createdAt: Neo4jUtils.getCurrentTimestamp(),
619 | };
620 |
621 | const result = await session.executeWrite(async (tx) => {
622 | const result = await tx.run(query, params);
623 | return result.records;
624 | });
625 |
626 | if (!result || result.length === 0) {
627 | throw new Error("Failed to create project dependency relationship");
628 | }
629 |
630 | const record = result[0];
631 | const dependency = {
632 | id: record.get("id"),
633 | sourceProjectId: record.get("sourceProjectId"),
634 | targetProjectId: record.get("targetProjectId"),
635 | type: record.get("type"),
636 | description: record.get("description"),
637 | };
638 | const reqContext_addDep = requestContextService.createRequestContext({
639 | operation: "addProjectDependency",
640 | sourceProjectId,
641 | targetProjectId,
642 | dependencyType: type,
643 | });
644 | logger.info("Project dependency added successfully", reqContext_addDep);
645 |
646 | return dependency;
647 | } catch (error) {
648 | const errorMessage =
649 | error instanceof Error ? error.message : String(error);
650 | const errorContext = requestContextService.createRequestContext({
651 | operation: "addProjectDependency.error",
652 | sourceProjectId,
653 | targetProjectId,
654 | dependencyType: type,
655 | });
656 | logger.error("Error adding project dependency", error as Error, {
657 | ...errorContext,
658 | detail: errorMessage,
659 | });
660 | throw error;
661 | } finally {
662 | await session.close();
663 | }
664 | }
665 |
666 | /**
667 | * Remove a dependency relationship between projects
668 | * @param dependencyId The ID of the dependency relationship to remove
669 | * @returns True if removed, false if not found
670 | */
671 | static async removeProjectDependency(dependencyId: string): Promise<boolean> {
672 | const session = await neo4jDriver.getSession();
673 |
674 | try {
675 | // Query remains the same
676 | const query = `
677 | MATCH (source:${NodeLabels.Project})-[r:${RelationshipTypes.DEPENDS_ON} {id: $dependencyId}]->(target:${NodeLabels.Project})
678 | DELETE r
679 | `;
680 |
681 | const result = await session.executeWrite(async (tx) => {
682 | const res = await tx.run(query, { dependencyId });
683 | return res.summary.counters.updates().relationshipsDeleted > 0;
684 | });
685 |
686 | const reqContext_removeDep = requestContextService.createRequestContext({
687 | operation: "removeProjectDependency",
688 | dependencyId,
689 | });
690 | if (result) {
691 | logger.info(
692 | "Project dependency removed successfully",
693 | reqContext_removeDep,
694 | );
695 | } else {
696 | logger.warning(
697 | "Dependency not found or not removed",
698 | reqContext_removeDep,
699 | );
700 | }
701 |
702 | return result;
703 | } catch (error) {
704 | const errorMessage =
705 | error instanceof Error ? error.message : String(error);
706 | const errorContext = requestContextService.createRequestContext({
707 | operation: "removeProjectDependency.error",
708 | dependencyId,
709 | });
710 | logger.error("Error removing project dependency", error as Error, {
711 | ...errorContext,
712 | detail: errorMessage,
713 | });
714 | throw error;
715 | } finally {
716 | await session.close();
717 | }
718 | }
719 |
720 | /**
721 | * Get all dependencies for a project (both dependencies and dependents)
722 | * @param projectId Project ID
723 | * @returns Object containing dependencies and dependents
724 | */
725 | static async getProjectDependencies(projectId: string): Promise<{
726 | dependencies: {
727 | id: string;
728 | sourceProjectId: string;
729 | targetProjectId: string;
730 | type: string;
731 | description: string;
732 | targetProject: {
733 | id: string;
734 | name: string;
735 | status: string;
736 | };
737 | }[];
738 | dependents: {
739 | id: string;
740 | sourceProjectId: string;
741 | targetProjectId: string;
742 | type: string;
743 | description: string;
744 | sourceProject: {
745 | id: string;
746 | name: string;
747 | status: string;
748 | };
749 | }[];
750 | }> {
751 | const session = await neo4jDriver.getSession();
752 |
753 | try {
754 | // Logic remains the same
755 | const exists = await Neo4jUtils.nodeExists(
756 | NodeLabels.Project,
757 | "id",
758 | projectId,
759 | );
760 | if (!exists) {
761 | throw new Error(`Project with ID ${projectId} not found`);
762 | }
763 |
764 | const dependenciesQuery = `
765 | MATCH (source:${NodeLabels.Project} {id: $projectId})-[r:${RelationshipTypes.DEPENDS_ON}]->(target:${NodeLabels.Project})
766 | RETURN r.id AS id,
767 | source.id AS sourceProjectId,
768 | target.id AS targetProjectId,
769 | r.type AS type,
770 | r.description AS description,
771 | target.name AS targetName,
772 | target.status AS targetStatus
773 | ORDER BY r.type, target.name
774 | `;
775 |
776 | const dependentsQuery = `
777 | MATCH (source:${NodeLabels.Project})-[r:${RelationshipTypes.DEPENDS_ON}]->(target:${NodeLabels.Project} {id: $projectId})
778 | RETURN r.id AS id,
779 | source.id AS sourceProjectId,
780 | target.id AS targetProjectId,
781 | r.type AS type,
782 | r.description AS description,
783 | source.name AS sourceName,
784 | source.status AS sourceStatus
785 | ORDER BY r.type, source.name
786 | `;
787 |
788 | const [dependenciesResult, dependentsResult] = await Promise.all([
789 | session.executeRead(
790 | async (tx) =>
791 | (await tx.run(dependenciesQuery, { projectId })).records,
792 | ),
793 | session.executeRead(
794 | async (tx) => (await tx.run(dependentsQuery, { projectId })).records,
795 | ),
796 | ]);
797 |
798 | const dependencies = dependenciesResult.map((record) => ({
799 | id: record.get("id"),
800 | sourceProjectId: record.get("sourceProjectId"),
801 | targetProjectId: record.get("targetProjectId"),
802 | type: record.get("type"),
803 | description: record.get("description"),
804 | targetProject: {
805 | id: record.get("targetProjectId"),
806 | name: record.get("targetName"),
807 | status: record.get("targetStatus"),
808 | },
809 | }));
810 |
811 | const dependents = dependentsResult.map((record) => ({
812 | id: record.get("id"),
813 | sourceProjectId: record.get("sourceProjectId"),
814 | targetProjectId: record.get("targetProjectId"),
815 | type: record.get("type"),
816 | description: record.get("description"),
817 | sourceProject: {
818 | id: record.get("sourceProjectId"),
819 | name: record.get("sourceName"),
820 | status: record.get("sourceStatus"),
821 | },
822 | }));
823 |
824 | return { dependencies, dependents };
825 | } catch (error) {
826 | const errorMessage =
827 | error instanceof Error ? error.message : String(error);
828 | const errorContext = requestContextService.createRequestContext({
829 | operation: "getProjectDependencies.error",
830 | projectId,
831 | });
832 | logger.error("Error getting project dependencies", error as Error, {
833 | ...errorContext,
834 | detail: errorMessage,
835 | });
836 | throw error;
837 | } finally {
838 | await session.close();
839 | }
840 | }
841 | }
842 |
```
--------------------------------------------------------------------------------
/src/services/neo4j/knowledgeService.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { logger, requestContextService } from "../../utils/index.js"; // Updated import path
2 | import { neo4jDriver } from "./driver.js";
3 | import { generateId, escapeRelationshipType } from "./helpers.js";
4 | import {
5 | KnowledgeFilterOptions,
6 | Neo4jKnowledge, // This type no longer has domain/citations
7 | NodeLabels,
8 | PaginatedResult,
9 | RelationshipTypes,
10 | } from "./types.js";
11 | import { Neo4jUtils } from "./utils.js";
12 | import { int } from "neo4j-driver"; // Import 'int' for pagination
13 |
14 | /**
15 | * Service for managing Knowledge entities in Neo4j
16 | */
17 | export class KnowledgeService {
18 | /**
19 | * Add a new knowledge item
20 | * @param knowledge Input data, potentially including domain and citations for relationship creation
21 | * @returns The created knowledge item, including its domain and citations.
22 | */
23 | static async addKnowledge(
24 | knowledge: Omit<Neo4jKnowledge, "id" | "createdAt" | "updatedAt"> & {
25 | id?: string;
26 | domain?: string;
27 | citations?: string[];
28 | },
29 | ): Promise<Neo4jKnowledge & { domain: string | null; citations: string[] }> {
30 | const session = await neo4jDriver.getSession();
31 |
32 | try {
33 | const projectExists = await Neo4jUtils.nodeExists(
34 | NodeLabels.Project,
35 | "id",
36 | knowledge.projectId,
37 | );
38 | if (!projectExists) {
39 | throw new Error(`Project with ID ${knowledge.projectId} not found`);
40 | }
41 |
42 | const knowledgeId = knowledge.id || `know_${generateId()}`;
43 | const now = Neo4jUtils.getCurrentTimestamp();
44 |
45 | // Input validation for domain
46 | if (
47 | !knowledge.domain ||
48 | typeof knowledge.domain !== "string" ||
49 | knowledge.domain.trim() === ""
50 | ) {
51 | throw new Error("Domain is required to create a knowledge item.");
52 | }
53 |
54 | // Create knowledge node and relationship to project
55 | // Removed domain and citations properties from CREATE
56 | const query = `
57 | MATCH (p:${NodeLabels.Project} {id: $projectId})
58 | CREATE (k:${NodeLabels.Knowledge} {
59 | id: $id,
60 | projectId: $projectId,
61 | text: $text,
62 | tags: $tags,
63 | createdAt: $createdAt,
64 | updatedAt: $updatedAt
65 | })
66 | CREATE (p)-[r:${RelationshipTypes.CONTAINS_KNOWLEDGE}]->(k)
67 |
68 | // Create domain relationship
69 | MERGE (d:${NodeLabels.Domain} {name: $domain})
70 | ON CREATE SET d.createdAt = $createdAt
71 | CREATE (k)-[:${RelationshipTypes.BELONGS_TO_DOMAIN}]->(d)
72 |
73 | // Return properties including domain name
74 | WITH k, d
75 | RETURN k.id as id, k.projectId as projectId, k.text as text, k.tags as tags, k.createdAt as createdAt, k.updatedAt as updatedAt, d.name as domainName
76 | `;
77 |
78 | const params = {
79 | id: knowledgeId,
80 | projectId: knowledge.projectId,
81 | text: knowledge.text,
82 | tags: knowledge.tags || [],
83 | domain: knowledge.domain, // Domain needed for MERGE Domain node
84 | createdAt: now,
85 | updatedAt: now,
86 | };
87 |
88 | const result = await session.executeWrite(async (tx) => {
89 | const result = await tx.run(query, params);
90 | return result.records;
91 | });
92 |
93 | const createdKnowledgeRecord = result[0];
94 |
95 | if (!createdKnowledgeRecord) {
96 | throw new Error(
97 | "Failed to create knowledge item or retrieve its properties",
98 | );
99 | }
100 |
101 | // Construct the Neo4jKnowledge object from the returned record
102 | const baseKnowledge: Neo4jKnowledge = {
103 | id: createdKnowledgeRecord.get("id"),
104 | projectId: createdKnowledgeRecord.get("projectId"),
105 | text: createdKnowledgeRecord.get("text"),
106 | tags: createdKnowledgeRecord.get("tags") || [],
107 | createdAt: createdKnowledgeRecord.get("createdAt"),
108 | updatedAt: createdKnowledgeRecord.get("updatedAt"),
109 | };
110 |
111 | const domainName = createdKnowledgeRecord.get("domainName") as
112 | | string
113 | | null;
114 | let actualCitations: string[] = [];
115 |
116 | // Process citations using the input 'knowledge' object
117 | const inputCitations = knowledge.citations;
118 | if (
119 | inputCitations &&
120 | Array.isArray(inputCitations) &&
121 | inputCitations.length > 0
122 | ) {
123 | await this.addCitations(knowledgeId, inputCitations);
124 | actualCitations = inputCitations; // Assume these are the citations for the response
125 | }
126 | const reqContext = requestContextService.createRequestContext({
127 | operation: "addKnowledge",
128 | knowledgeId: baseKnowledge.id,
129 | projectId: knowledge.projectId,
130 | });
131 | logger.info("Knowledge item created successfully", reqContext);
132 |
133 | // Return the extended object with domain and citations
134 | return {
135 | ...baseKnowledge,
136 | domain: domainName || knowledge.domain || null, // Fallback to input domain if query somehow misses it
137 | citations: actualCitations,
138 | };
139 | } catch (error) {
140 | const errorMessage =
141 | error instanceof Error ? error.message : String(error);
142 | const errorContext = requestContextService.createRequestContext({
143 | operation: "addKnowledge.error",
144 | knowledgeInput: knowledge,
145 | });
146 | logger.error("Error creating knowledge item", error as Error, {
147 | ...errorContext,
148 | detail: errorMessage,
149 | });
150 | throw error;
151 | } finally {
152 | await session.close();
153 | }
154 | }
155 |
156 | /**
157 | * Link two knowledge items with a specified relationship type.
158 | * @param sourceId ID of the source knowledge item
159 | * @param targetId ID of the target knowledge item
160 | * @param relationshipType The type of relationship to create (e.g., 'RELATED_TO', 'IS_SUBTOPIC_OF') - Validation needed
161 | * @returns True if the link was created successfully, false otherwise
162 | */
163 | static async linkKnowledgeToKnowledge(
164 | sourceId: string,
165 | targetId: string,
166 | relationshipType: string,
167 | ): Promise<boolean> {
168 | // TODO: Validate relationshipType against allowed types or RelationshipTypes enum
169 | const session = await neo4jDriver.getSession();
170 | const reqContext = requestContextService.createRequestContext({
171 | operation: "linkKnowledgeToKnowledge",
172 | sourceId,
173 | targetId,
174 | relationshipType,
175 | });
176 | logger.debug(
177 | `Attempting to link knowledge ${sourceId} to ${targetId} with type ${relationshipType}`,
178 | reqContext,
179 | );
180 |
181 | try {
182 | const sourceExists = await Neo4jUtils.nodeExists(
183 | NodeLabels.Knowledge,
184 | "id",
185 | sourceId,
186 | );
187 | const targetExists = await Neo4jUtils.nodeExists(
188 | NodeLabels.Knowledge,
189 | "id",
190 | targetId,
191 | );
192 |
193 | if (!sourceExists || !targetExists) {
194 | logger.warning(
195 | `Cannot link knowledge: Source (${sourceId} exists: ${sourceExists}) or Target (${targetId} exists: ${targetExists}) not found.`,
196 | { ...reqContext, sourceExists, targetExists },
197 | );
198 | return false;
199 | }
200 |
201 | // Escape relationship type for safety
202 | const escapedType = escapeRelationshipType(relationshipType);
203 |
204 | const query = `
205 | MATCH (source:${NodeLabels.Knowledge} {id: $sourceId})
206 | MATCH (target:${NodeLabels.Knowledge} {id: $targetId})
207 | MERGE (source)-[r:${escapedType}]->(target)
208 | RETURN r
209 | `;
210 |
211 | const result = await session.executeWrite(async (tx) => {
212 | const runResult = await tx.run(query, { sourceId, targetId });
213 | return runResult.records;
214 | });
215 |
216 | const linkCreated = result.length > 0;
217 |
218 | if (linkCreated) {
219 | logger.info(
220 | `Successfully linked knowledge ${sourceId} to ${targetId} with type ${relationshipType}`,
221 | reqContext,
222 | );
223 | } else {
224 | logger.warning(
225 | `Failed to link knowledge ${sourceId} to ${targetId} (MERGE returned no relationship)`,
226 | reqContext,
227 | );
228 | }
229 |
230 | return linkCreated;
231 | } catch (error) {
232 | const errorMessage =
233 | error instanceof Error ? error.message : String(error);
234 | logger.error("Error linking knowledge items", error as Error, {
235 | ...reqContext,
236 | detail: errorMessage,
237 | });
238 | throw error;
239 | } finally {
240 | await session.close();
241 | }
242 | }
243 |
244 | /**
245 | * Get a knowledge item by ID, including its domain and citations via relationships.
246 | * @param id Knowledge ID
247 | * @returns The knowledge item with domain and citations added, or null if not found.
248 | */
249 | static async getKnowledgeById(
250 | id: string,
251 | ): Promise<
252 | (Neo4jKnowledge & { domain: string | null; citations: string[] }) | null
253 | > {
254 | const session = await neo4jDriver.getSession();
255 |
256 | try {
257 | // Fetch domain and citations via relationships
258 | const query = `
259 | MATCH (k:${NodeLabels.Knowledge} {id: $id})
260 | OPTIONAL MATCH (k)-[:${RelationshipTypes.BELONGS_TO_DOMAIN}]->(d:${NodeLabels.Domain})
261 | OPTIONAL MATCH (k)-[:${RelationshipTypes.CITES}]->(c:${NodeLabels.Citation})
262 | RETURN k.id as id,
263 | k.projectId as projectId,
264 | k.text as text,
265 | k.tags as tags,
266 | d.name as domainName, // Fetch domain name
267 | collect(DISTINCT c.source) as citationSources, // Collect distinct citation sources
268 | k.createdAt as createdAt,
269 | k.updatedAt as updatedAt
270 | `;
271 |
272 | const result = await session.executeRead(async (tx) => {
273 | const result = await tx.run(query, { id });
274 | return result.records;
275 | });
276 |
277 | if (result.length === 0) {
278 | return null;
279 | }
280 | const record = result[0];
281 |
282 | // Construct the base Neo4jKnowledge object
283 | const knowledge: Neo4jKnowledge = {
284 | id: record.get("id"),
285 | projectId: record.get("projectId"),
286 | text: record.get("text"),
287 | tags: record.get("tags") || [],
288 | createdAt: record.get("createdAt"),
289 | updatedAt: record.get("updatedAt"),
290 | };
291 |
292 | // Add domain and citations fetched via relationships
293 | const domain = record.get("domainName");
294 | const citations = record
295 | .get("citationSources")
296 | .filter((c: string | null): c is string => c !== null); // Filter nulls if no citations found
297 |
298 | return {
299 | ...knowledge,
300 | domain: domain, // Can be null if no domain relationship exists
301 | citations: citations,
302 | };
303 | } catch (error) {
304 | const errorMessage =
305 | error instanceof Error ? error.message : String(error);
306 | const reqContext = requestContextService.createRequestContext({
307 | operation: "getKnowledgeById.error",
308 | knowledgeId: id,
309 | });
310 | logger.error("Error getting knowledge by ID", error as Error, {
311 | ...reqContext,
312 | detail: errorMessage,
313 | });
314 | throw error;
315 | } finally {
316 | await session.close();
317 | }
318 | }
319 |
320 | /**
321 | * Update a knowledge item, including domain and citation relationships.
322 | * @param id Knowledge ID
323 | * @param updates Updates including optional domain and citations
324 | * @returns The updated knowledge item (without domain/citations properties)
325 | */
326 | static async updateKnowledge(
327 | id: string,
328 | updates: Partial<
329 | Omit<Neo4jKnowledge, "id" | "projectId" | "createdAt" | "updatedAt">
330 | > & { domain?: string; citations?: string[] },
331 | ): Promise<Neo4jKnowledge> {
332 | const session = await neo4jDriver.getSession();
333 |
334 | try {
335 | const exists = await Neo4jUtils.nodeExists(
336 | NodeLabels.Knowledge,
337 | "id",
338 | id,
339 | );
340 | if (!exists) {
341 | throw new Error(`Knowledge with ID ${id} not found`);
342 | }
343 |
344 | const updateParams: Record<string, any> = {
345 | id,
346 | updatedAt: Neo4jUtils.getCurrentTimestamp(),
347 | };
348 |
349 | let setClauses = ["k.updatedAt = $updatedAt"];
350 | const allowedProperties: (keyof Neo4jKnowledge)[] = [
351 | "projectId",
352 | "text",
353 | "tags",
354 | ]; // Define properties that can be updated
355 |
356 | // Add update clauses for allowed properties defined in Neo4jKnowledge
357 | for (const [key, value] of Object.entries(updates)) {
358 | // Check if the key is one of the allowed properties and value is defined
359 | if (
360 | value !== undefined &&
361 | allowedProperties.includes(key as keyof Neo4jKnowledge)
362 | ) {
363 | updateParams[key] = value;
364 | setClauses.push(`k.${key} = $${key}`);
365 | }
366 | }
367 |
368 | // Handle domain update using relationships
369 | let domainUpdateClause = "";
370 | const domainUpdateValue = updates.domain;
371 | if (domainUpdateValue) {
372 | if (
373 | typeof domainUpdateValue !== "string" ||
374 | domainUpdateValue.trim() === ""
375 | ) {
376 | throw new Error("Domain update value cannot be empty.");
377 | }
378 | updateParams.domain = domainUpdateValue;
379 | domainUpdateClause = `
380 | // Update domain relationship
381 | WITH k // Ensure k is in scope
382 | OPTIONAL MATCH (k)-[oldDomainRel:${RelationshipTypes.BELONGS_TO_DOMAIN}]->(:${NodeLabels.Domain})
383 | DELETE oldDomainRel
384 | MERGE (newDomain:${NodeLabels.Domain} {name: $domain})
385 | ON CREATE SET newDomain.createdAt = $updatedAt // Set timestamp if domain is new
386 | CREATE (k)-[:${RelationshipTypes.BELONGS_TO_DOMAIN}]->(newDomain)
387 | `;
388 | }
389 |
390 | // Construct the main update query
391 | const query = `
392 | MATCH (k:${NodeLabels.Knowledge} {id: $id})
393 | ${setClauses.length > 0 ? `SET ${setClauses.join(", ")}` : ""}
394 | ${domainUpdateClause}
395 | // Return basic properties defined in Neo4jKnowledge
396 | RETURN k.id as id, k.projectId as projectId, k.text as text, k.tags as tags, k.createdAt as createdAt, k.updatedAt as updatedAt
397 | `;
398 |
399 | const result = await session.executeWrite(async (tx) => {
400 | const result = await tx.run(query, updateParams);
401 | return result.records;
402 | });
403 |
404 | const updatedKnowledgeRecord = result[0];
405 |
406 | if (!updatedKnowledgeRecord) {
407 | throw new Error("Failed to update knowledge item or retrieve result");
408 | }
409 |
410 | // Update citations if provided in the input 'updates' object
411 | const inputCitations = updates.citations;
412 | if (inputCitations && Array.isArray(inputCitations)) {
413 | // Remove existing CITES relationships first
414 | await session.executeWrite(async (tx) => {
415 | await tx.run(
416 | `
417 | MATCH (k:${NodeLabels.Knowledge} {id: $id})-[r:${RelationshipTypes.CITES}]->(:${NodeLabels.Citation})
418 | DELETE r
419 | `,
420 | { id },
421 | );
422 | });
423 |
424 | // Add new CITES relationships
425 | if (inputCitations.length > 0) {
426 | await this.addCitations(id, inputCitations);
427 | }
428 | }
429 |
430 | // Construct the final return object matching Neo4jKnowledge
431 | const finalUpdatedKnowledge: Neo4jKnowledge = {
432 | id: updatedKnowledgeRecord.get("id"),
433 | projectId: updatedKnowledgeRecord.get("projectId"),
434 | text: updatedKnowledgeRecord.get("text"),
435 | tags: updatedKnowledgeRecord.get("tags") || [],
436 | createdAt: updatedKnowledgeRecord.get("createdAt"),
437 | updatedAt: updatedKnowledgeRecord.get("updatedAt"),
438 | };
439 | const reqContext_update = requestContextService.createRequestContext({
440 | operation: "updateKnowledge",
441 | knowledgeId: id,
442 | });
443 | logger.info("Knowledge item updated successfully", reqContext_update);
444 | return finalUpdatedKnowledge;
445 | } catch (error) {
446 | const errorMessage =
447 | error instanceof Error ? error.message : String(error);
448 | const errorContext = requestContextService.createRequestContext({
449 | operation: "updateKnowledge.error",
450 | knowledgeId: id,
451 | updatesApplied: updates,
452 | });
453 | logger.error("Error updating knowledge item", error as Error, {
454 | ...errorContext,
455 | detail: errorMessage,
456 | });
457 | throw error;
458 | } finally {
459 | await session.close();
460 | }
461 | }
462 |
463 | /**
464 | * Delete a knowledge item
465 | * @param id Knowledge ID
466 | * @returns True if deleted, false if not found
467 | */
468 | static async deleteKnowledge(id: string): Promise<boolean> {
469 | const session = await neo4jDriver.getSession();
470 |
471 | try {
472 | const exists = await Neo4jUtils.nodeExists(
473 | NodeLabels.Knowledge,
474 | "id",
475 | id,
476 | );
477 | if (!exists) {
478 | return false;
479 | }
480 |
481 | // Use DETACH DELETE to remove the node and all its relationships
482 | const query = `
483 | MATCH (k:${NodeLabels.Knowledge} {id: $id})
484 | DETACH DELETE k
485 | `;
486 |
487 | await session.executeWrite(async (tx) => {
488 | await tx.run(query, { id });
489 | });
490 | const reqContext_delete = requestContextService.createRequestContext({
491 | operation: "deleteKnowledge",
492 | knowledgeId: id,
493 | });
494 | logger.info("Knowledge item deleted successfully", reqContext_delete);
495 | return true;
496 | } catch (error) {
497 | const errorMessage =
498 | error instanceof Error ? error.message : String(error);
499 | const errorContext = requestContextService.createRequestContext({
500 | operation: "deleteKnowledge.error",
501 | knowledgeId: id,
502 | });
503 | logger.error("Error deleting knowledge item", error as Error, {
504 | ...errorContext,
505 | detail: errorMessage,
506 | });
507 | throw error;
508 | } finally {
509 | await session.close();
510 | }
511 | }
512 |
513 | /**
514 | * Get knowledge items for a project with optional filtering and server-side pagination.
515 | * Returns domain and citations via relationships.
516 | * @param options Filter and pagination options
517 | * @returns Paginated list of knowledge items including domain and citations
518 | */
519 | static async getKnowledge(
520 | options: KnowledgeFilterOptions,
521 | ): Promise<
522 | PaginatedResult<
523 | Neo4jKnowledge & { domain: string | null; citations: string[] }
524 | >
525 | > {
526 | const session = await neo4jDriver.getSession();
527 |
528 | try {
529 | let conditions: string[] = [];
530 | const params: Record<string, any> = {}; // Initialize empty params
531 |
532 | // Conditionally add projectId to params if it's not '*'
533 | if (options.projectId && options.projectId !== "*") {
534 | params.projectId = options.projectId;
535 | }
536 |
537 | let domainMatchClause = "";
538 | if (options.domain) {
539 | params.domain = options.domain;
540 | // Match the relationship for filtering
541 | domainMatchClause = `MATCH (k)-[:${RelationshipTypes.BELONGS_TO_DOMAIN}]->(d:${NodeLabels.Domain} {name: $domain})`;
542 | } else {
543 | // Optionally match domain to return it
544 | domainMatchClause = `OPTIONAL MATCH (k)-[:${RelationshipTypes.BELONGS_TO_DOMAIN}]->(d:${NodeLabels.Domain})`;
545 | }
546 |
547 | // Handle tags filtering
548 | if (options.tags && options.tags.length > 0) {
549 | const tagQuery = Neo4jUtils.generateArrayInListQuery(
550 | "k",
551 | "tags",
552 | "tagsList",
553 | options.tags,
554 | );
555 | if (tagQuery.cypher) {
556 | conditions.push(tagQuery.cypher);
557 | Object.assign(params, tagQuery.params);
558 | }
559 | }
560 |
561 | // Handle text search (using regex - consider full-text index later)
562 | if (options.search) {
563 | // Use case-insensitive regex
564 | params.search = `(?i).*${options.search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}.*`;
565 | conditions.push("k.text =~ $search");
566 | }
567 |
568 | const whereClause =
569 | conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
570 |
571 | // Calculate pagination parameters
572 | const page = Math.max(options.page || 1, 1);
573 | const limit = Math.min(Math.max(options.limit || 20, 1), 100);
574 | const skip = (page - 1) * limit;
575 |
576 | // Add pagination params using neo4j.int
577 | params.skip = int(skip);
578 | params.limit = int(limit);
579 |
580 | // Construct the base MATCH clause conditionally
581 | const projectIdMatchFilter =
582 | options.projectId && options.projectId !== "*"
583 | ? "{projectId: $projectId}"
584 | : "";
585 | const baseMatch = `MATCH (k:${NodeLabels.Knowledge} ${projectIdMatchFilter})`;
586 |
587 | // Query for total count matching filters
588 | const countQuery = `
589 | ${baseMatch} // Use conditional base match
590 | ${whereClause} // Apply filters to the knowledge node 'k' first
591 | WITH k // Pass the filtered knowledge nodes
592 | ${domainMatchClause} // Now match domain relationship if needed for filtering
593 | RETURN count(DISTINCT k) as total // Count distinct knowledge nodes
594 | `;
595 |
596 | // Query for paginated data
597 | const dataQuery = `
598 | ${baseMatch} // Use conditional base match
599 | ${whereClause} // Apply filters to the knowledge node 'k' first
600 | WITH k // Pass the filtered knowledge nodes
601 | ${domainMatchClause} // Match domain relationship
602 | OPTIONAL MATCH (k)-[:${RelationshipTypes.CITES}]->(c:${NodeLabels.Citation}) // Match citations
603 | WITH k, d, collect(DISTINCT c.source) as citationSources // Collect citations
604 | RETURN k.id as id,
605 | k.projectId as projectId,
606 | k.text as text,
607 | k.tags as tags,
608 | d.name as domainName, // Return domain name from relationship
609 | citationSources, // Return collected citations
610 | k.createdAt as createdAt,
611 | k.updatedAt as updatedAt
612 | ORDER BY k.createdAt DESC
613 | SKIP $skip
614 | LIMIT $limit
615 | `;
616 |
617 | // Execute count query
618 | const totalResult = await session.executeRead(async (tx) => {
619 | // Need to remove skip/limit from params for count query
620 | const countParams = { ...params };
621 | delete countParams.skip;
622 | delete countParams.limit;
623 | const result = await tx.run(countQuery, countParams);
624 | // The driver seems to return a standard number for count(), use ?? 0 for safety
625 | return result.records[0]?.get("total") ?? 0;
626 | });
627 | // totalResult is now the standard number returned by executeRead
628 | const total = totalResult;
629 |
630 | // Execute data query
631 | const dataResult = await session.executeRead(async (tx) => {
632 | const result = await tx.run(dataQuery, params); // Use params with skip/limit
633 | return result.records;
634 | });
635 |
636 | // Map results including domain and citations
637 | const knowledgeItems = dataResult.map((record) => {
638 | const baseKnowledge: Neo4jKnowledge = {
639 | id: record.get("id"),
640 | projectId: record.get("projectId"),
641 | text: record.get("text"),
642 | tags: record.get("tags") || [],
643 | createdAt: record.get("createdAt"),
644 | updatedAt: record.get("updatedAt"),
645 | };
646 | const domain = record.get("domainName");
647 | const citations = record
648 | .get("citationSources")
649 | .filter((c: string | null): c is string => c !== null);
650 |
651 | return {
652 | ...baseKnowledge,
653 | domain: domain,
654 | citations: citations,
655 | };
656 | });
657 |
658 | // Return paginated result structure
659 | const totalPages = Math.ceil(total / limit);
660 | return {
661 | data: knowledgeItems,
662 | total,
663 | page,
664 | limit,
665 | totalPages,
666 | };
667 | } catch (error) {
668 | const errorMessage =
669 | error instanceof Error ? error.message : String(error);
670 | const reqContext_get = requestContextService.createRequestContext({
671 | operation: "getKnowledge.error",
672 | filterOptions: options,
673 | });
674 | logger.error("Error getting knowledge items", error as Error, {
675 | ...reqContext_get,
676 | detail: errorMessage,
677 | });
678 | throw error;
679 | } finally {
680 | await session.close();
681 | }
682 | }
683 |
684 | /**
685 | * Get all available domains with item counts
686 | * @returns Array of domains with counts
687 | */
688 | static async getDomains(): Promise<Array<{ name: string; count: number }>> {
689 | const session = await neo4jDriver.getSession();
690 |
691 | try {
692 | // This query correctly uses the relationship already
693 | const query = `
694 | MATCH (d:${NodeLabels.Domain})<-[:${RelationshipTypes.BELONGS_TO_DOMAIN}]-(k:${NodeLabels.Knowledge})
695 | RETURN d.name AS name, count(k) AS count
696 | ORDER BY count DESC, name
697 | `;
698 |
699 | const result = await session.executeRead(async (tx) => {
700 | const result = await tx.run(query);
701 | return result.records;
702 | });
703 |
704 | return result.map((record) => ({
705 | name: record.get("name"),
706 | count: record.get("count").toNumber(), // Convert Neo4j int
707 | }));
708 | } catch (error) {
709 | const errorMessage =
710 | error instanceof Error ? error.message : String(error);
711 | const reqContext_domains = requestContextService.createRequestContext({
712 | operation: "getDomains.error",
713 | });
714 | logger.error("Error getting domains", error as Error, {
715 | ...reqContext_domains,
716 | detail: errorMessage,
717 | });
718 | throw error;
719 | } finally {
720 | await session.close();
721 | }
722 | }
723 |
724 | /**
725 | * Get all unique tags used across knowledge items with counts
726 | * @param projectId Optional project ID to filter tags
727 | * @returns Array of tags with counts
728 | */
729 | static async getTags(
730 | projectId?: string,
731 | ): Promise<Array<{ tag: string; count: number }>> {
732 | const session = await neo4jDriver.getSession();
733 |
734 | try {
735 | let whereClause = "";
736 | const params: Record<string, any> = {};
737 |
738 | if (projectId) {
739 | whereClause = "WHERE k.projectId = $projectId";
740 | params.projectId = projectId;
741 | }
742 |
743 | // This query is fine as it only reads the tags property
744 | const query = `
745 | MATCH (k:${NodeLabels.Knowledge})
746 | ${whereClause}
747 | UNWIND k.tags AS tag
748 | RETURN tag, count(*) AS count
749 | ORDER BY count DESC, tag
750 | `;
751 |
752 | const result = await session.executeRead(async (tx) => {
753 | const result = await tx.run(query, params);
754 | return result.records;
755 | });
756 |
757 | return result.map((record) => ({
758 | tag: record.get("tag"),
759 | count: record.get("count").toNumber(), // Convert Neo4j int
760 | }));
761 | } catch (error) {
762 | const errorMessage =
763 | error instanceof Error ? error.message : String(error);
764 | const reqContext_tags = requestContextService.createRequestContext({
765 | operation: "getTags.error",
766 | projectId,
767 | });
768 | logger.error("Error getting tags", error as Error, {
769 | ...reqContext_tags,
770 | detail: errorMessage,
771 | });
772 | throw error;
773 | } finally {
774 | await session.close();
775 | }
776 | }
777 |
778 | /**
779 | * Add CITES relationships from a knowledge item to new Citation nodes.
780 | * @param knowledgeId Knowledge ID
781 | * @param citations Array of citation source strings
782 | * @returns The IDs of the created Citation nodes
783 | * @private
784 | */
785 | private static async addCitations(
786 | knowledgeId: string,
787 | citations: string[],
788 | ): Promise<string[]> {
789 | if (!citations || citations.length === 0) {
790 | return [];
791 | }
792 | const session = await neo4jDriver.getSession();
793 |
794 | try {
795 | const citationData = citations.map((source) => ({
796 | id: `cite_${generateId()}`,
797 | source: source,
798 | createdAt: Neo4jUtils.getCurrentTimestamp(),
799 | }));
800 |
801 | const query = `
802 | MATCH (k:${NodeLabels.Knowledge} {id: $knowledgeId})
803 | UNWIND $citationData as citationProps
804 | CREATE (c:${NodeLabels.Citation})
805 | SET c = citationProps
806 | CREATE (k)-[:${RelationshipTypes.CITES}]->(c)
807 | RETURN c.id as citationId
808 | `;
809 |
810 | const result = await session.executeWrite(async (tx) => {
811 | const res = await tx.run(query, { knowledgeId, citationData });
812 | return res.records.map((r) => r.get("citationId"));
813 | });
814 | const reqContext_addCite = requestContextService.createRequestContext({
815 | operation: "addCitations",
816 | knowledgeId,
817 | citationCount: result.length,
818 | });
819 | logger.debug(
820 | `Added ${result.length} citations for knowledge ${knowledgeId}`,
821 | reqContext_addCite,
822 | );
823 | return result;
824 | } catch (error) {
825 | const errorMessage =
826 | error instanceof Error ? error.message : String(error);
827 | const errorContext = requestContextService.createRequestContext({
828 | operation: "addCitations.error",
829 | knowledgeId,
830 | citations,
831 | });
832 | logger.error("Error adding citations", error as Error, {
833 | ...errorContext,
834 | detail: errorMessage,
835 | });
836 | throw error;
837 | } finally {
838 | await session.close();
839 | }
840 | }
841 | }
842 |
```
--------------------------------------------------------------------------------
/src/services/neo4j/taskService.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { int } from "neo4j-driver"; // Import 'int' for pagination
2 | import { logger, requestContextService } from "../../utils/index.js"; // Updated import path
3 | import { neo4jDriver } from "./driver.js";
4 | import { generateId, buildListQuery } from "./helpers.js"; // Import buildListQuery
5 | import {
6 | Neo4jTask, // This type no longer has assignedTo
7 | NodeLabels,
8 | PaginatedResult,
9 | RelationshipTypes,
10 | TaskFilterOptions,
11 | } from "./types.js";
12 | import { Neo4jUtils } from "./utils.js";
13 |
14 | /**
15 | * Service for managing Task entities in Neo4j
16 | */
17 | export class TaskService {
18 | /**
19 | * Create a new task and optionally assign it to a user.
20 | * @param task Task data, including optional assignedTo for relationship creation
21 | * @returns The created task
22 | */
23 | static async createTask(
24 | task: Omit<Neo4jTask, "id" | "createdAt" | "updatedAt"> & {
25 | id?: string;
26 | assignedTo?: string;
27 | },
28 | ): Promise<Neo4jTask & { assignedToUserId?: string | null }> {
29 | const session = await neo4jDriver.getSession();
30 |
31 | try {
32 | const projectExists = await Neo4jUtils.nodeExists(
33 | NodeLabels.Project,
34 | "id",
35 | task.projectId,
36 | );
37 | if (!projectExists) {
38 | throw new Error(`Project with ID ${task.projectId} not found`);
39 | }
40 |
41 | const taskId = task.id || `task_${generateId()}`;
42 | const now = Neo4jUtils.getCurrentTimestamp();
43 | const assignedToUserId = task.assignedTo; // Get assignee from input
44 |
45 | // No longer check if user exists here, will use MERGE later
46 |
47 | // Serialize urls to JSON string
48 | const query = `
49 | MATCH (p:${NodeLabels.Project} {id: $projectId})
50 | CREATE (t:${NodeLabels.Task} {
51 | id: $id,
52 | projectId: $projectId,
53 | title: $title,
54 | description: $description,
55 | priority: $priority,
56 | status: $status,
57 | // assignedTo removed
58 | urls: $urls,
59 | tags: $tags,
60 | completionRequirements: $completionRequirements,
61 | outputFormat: $outputFormat,
62 | taskType: $taskType,
63 | createdAt: $createdAt,
64 | updatedAt: $updatedAt
65 | })
66 | CREATE (p)-[:${RelationshipTypes.CONTAINS_TASK}]->(t)
67 |
68 | // Optionally create ASSIGNED_TO relationship using MERGE for the User node
69 | WITH t
70 | ${assignedToUserId ? `MERGE (u:${NodeLabels.User} {id: $assignedToUserId}) ON CREATE SET u.createdAt = $createdAt CREATE (t)-[:${RelationshipTypes.ASSIGNED_TO}]->(u)` : ""}
71 |
72 | // Return properties defined in Neo4jTask
73 | WITH t // Ensure t is in scope before optional match
74 | OPTIONAL MATCH (t)-[:${RelationshipTypes.ASSIGNED_TO}]->(assigned_user:${NodeLabels.User}) // Match to get assigned user's ID
75 | RETURN t.id as id,
76 | t.projectId as projectId,
77 | t.title as title,
78 | t.description as description,
79 | t.priority as priority,
80 | t.status as status,
81 | assigned_user.id as assignedToUserId, // Add this
82 | t.urls as urls,
83 | t.tags as tags,
84 | t.completionRequirements as completionRequirements,
85 | t.outputFormat as outputFormat,
86 | t.taskType as taskType,
87 | t.createdAt as createdAt,
88 | t.updatedAt as updatedAt
89 | `;
90 |
91 | // Serialize urls to JSON string
92 | const params: Record<string, any> = {
93 | id: taskId,
94 | projectId: task.projectId,
95 | title: task.title,
96 | description: task.description,
97 | priority: task.priority,
98 | status: task.status,
99 | // assignedTo removed from params
100 | urls: JSON.stringify(task.urls || []), // Serialize urls
101 | tags: task.tags || [],
102 | completionRequirements: task.completionRequirements,
103 | outputFormat: task.outputFormat,
104 | taskType: task.taskType,
105 | createdAt: now,
106 | updatedAt: now,
107 | };
108 |
109 | if (assignedToUserId) {
110 | params.assignedToUserId = assignedToUserId;
111 | }
112 |
113 | const result = await session.executeWrite(async (tx) => {
114 | const result = await tx.run(query, params);
115 | // Use .get() for each field
116 | return result.records.length > 0 ? result.records[0] : null;
117 | });
118 |
119 | if (!result) {
120 | throw new Error("Failed to create task or retrieve its properties");
121 | }
122 |
123 | // Construct the Neo4jTask object - deserialize urls
124 | const createdTaskData: Neo4jTask & { assignedToUserId?: string | null } =
125 | {
126 | id: result.get("id"),
127 | projectId: result.get("projectId"),
128 | title: result.get("title"),
129 | description: result.get("description"),
130 | priority: result.get("priority"),
131 | status: result.get("status"),
132 | urls: JSON.parse(result.get("urls") || "[]"), // Deserialize urls
133 | tags: result.get("tags") || [],
134 | completionRequirements: result.get("completionRequirements"),
135 | outputFormat: result.get("outputFormat"),
136 | taskType: result.get("taskType"),
137 | createdAt: result.get("createdAt"),
138 | updatedAt: result.get("updatedAt"),
139 | assignedToUserId: result.get("assignedToUserId") || null,
140 | };
141 |
142 | const reqContext_create = requestContextService.createRequestContext({
143 | operation: "createTask",
144 | taskId: createdTaskData.id,
145 | projectId: task.projectId,
146 | assignedToUserId,
147 | });
148 | logger.info("Task created successfully", reqContext_create);
149 |
150 | return createdTaskData;
151 | } catch (error) {
152 | const errorMessage =
153 | error instanceof Error ? error.message : String(error);
154 | const errorContext = requestContextService.createRequestContext({
155 | operation: "createTask.error",
156 | taskInput: task,
157 | });
158 | logger.error("Error creating task", error as Error, {
159 | ...errorContext,
160 | detail: errorMessage,
161 | });
162 | throw error;
163 | } finally {
164 | await session.close();
165 | }
166 | }
167 |
168 | /**
169 | * Link a Task to a Knowledge item with a specified relationship type.
170 | * @param taskId ID of the source Task item
171 | * @param knowledgeId ID of the target Knowledge item
172 | * @param relationshipType The type of relationship to create (e.g., 'ADDRESSES', 'REFERENCES') - Validation needed
173 | * @returns True if the link was created successfully, false otherwise
174 | */
175 | static async linkTaskToKnowledge(
176 | taskId: string,
177 | knowledgeId: string,
178 | relationshipType: string,
179 | ): Promise<boolean> {
180 | // TODO: Validate relationshipType against allowed types or RelationshipTypes enum
181 | const session = await neo4jDriver.getSession();
182 | const reqContext_link = requestContextService.createRequestContext({
183 | operation: "linkTaskToKnowledge",
184 | taskId,
185 | knowledgeId,
186 | relationshipType,
187 | });
188 | logger.debug(
189 | `Attempting to link task ${taskId} to knowledge ${knowledgeId} with type ${relationshipType}`,
190 | reqContext_link,
191 | );
192 |
193 | try {
194 | const taskExists = await Neo4jUtils.nodeExists(
195 | NodeLabels.Task,
196 | "id",
197 | taskId,
198 | );
199 | const knowledgeExists = await Neo4jUtils.nodeExists(
200 | NodeLabels.Knowledge,
201 | "id",
202 | knowledgeId,
203 | );
204 |
205 | if (!taskExists || !knowledgeExists) {
206 | logger.warning(
207 | `Cannot link: Task (${taskId} exists: ${taskExists}) or Knowledge (${knowledgeId} exists: ${knowledgeExists}) not found.`,
208 | { ...reqContext_link, taskExists, knowledgeExists },
209 | );
210 | return false;
211 | }
212 |
213 | const escapedType = `\`${relationshipType.replace(/`/g, "``")}\``;
214 |
215 | const query = `
216 | MATCH (task:${NodeLabels.Task} {id: $taskId})
217 | MATCH (knowledge:${NodeLabels.Knowledge} {id: $knowledgeId})
218 | MERGE (task)-[r:${escapedType}]->(knowledge)
219 | RETURN r
220 | `;
221 |
222 | const result = await session.executeWrite(async (tx) => {
223 | const runResult = await tx.run(query, { taskId, knowledgeId });
224 | return runResult.records;
225 | });
226 |
227 | const linkCreated = result.length > 0;
228 |
229 | if (linkCreated) {
230 | logger.info(
231 | `Successfully linked task ${taskId} to knowledge ${knowledgeId} with type ${relationshipType}`,
232 | reqContext_link,
233 | );
234 | } else {
235 | logger.warning(
236 | `Failed to link task ${taskId} to knowledge ${knowledgeId} (MERGE returned no relationship)`,
237 | reqContext_link,
238 | );
239 | }
240 |
241 | return linkCreated;
242 | } catch (error) {
243 | const errorMessage =
244 | error instanceof Error ? error.message : String(error);
245 | logger.error("Error linking task to knowledge item", error as Error, {
246 | ...reqContext_link,
247 | detail: errorMessage,
248 | });
249 | throw error;
250 | } finally {
251 | await session.close();
252 | }
253 | }
254 |
255 | /**
256 | * Get a task by ID, including the assigned user ID via relationship.
257 | * @param id Task ID
258 | * @returns The task with assignedToUserId property, or null if not found.
259 | */
260 | static async getTaskById(
261 | id: string,
262 | ): Promise<(Neo4jTask & { assignedToUserId: string | null }) | null> {
263 | const session = await neo4jDriver.getSession();
264 |
265 | try {
266 | // Retrieve urls as JSON string
267 | const query = `
268 | MATCH (t:${NodeLabels.Task} {id: $id})
269 | OPTIONAL MATCH (t)-[:${RelationshipTypes.ASSIGNED_TO}]->(u:${NodeLabels.User})
270 | RETURN t.id as id,
271 | t.projectId as projectId,
272 | t.title as title,
273 | t.description as description,
274 | t.priority as priority,
275 | t.status as status,
276 | u.id as assignedToUserId,
277 | t.urls as urls,
278 | t.tags as tags,
279 | t.completionRequirements as completionRequirements,
280 | t.outputFormat as outputFormat,
281 | t.taskType as taskType,
282 | t.createdAt as createdAt,
283 | t.updatedAt as updatedAt
284 | `;
285 |
286 | const result = await session.executeRead(async (tx) => {
287 | const result = await tx.run(query, { id });
288 | return result.records;
289 | });
290 |
291 | if (result.length === 0) {
292 | return null;
293 | }
294 |
295 | const record = result[0];
296 |
297 | // Construct the base Neo4jTask object - deserialize urls
298 | const taskData: Neo4jTask = {
299 | id: record.get("id"),
300 | projectId: record.get("projectId"),
301 | title: record.get("title"),
302 | description: record.get("description"),
303 | priority: record.get("priority"),
304 | status: record.get("status"),
305 | urls: JSON.parse(record.get("urls") || "[]"), // Deserialize urls
306 | tags: record.get("tags") || [],
307 | completionRequirements: record.get("completionRequirements"),
308 | outputFormat: record.get("outputFormat"),
309 | taskType: record.get("taskType"),
310 | createdAt: record.get("createdAt"),
311 | updatedAt: record.get("updatedAt"),
312 | };
313 |
314 | const assignedToUserId = record.get("assignedToUserId");
315 |
316 | return {
317 | ...taskData,
318 | assignedToUserId: assignedToUserId,
319 | };
320 | } catch (error) {
321 | const errorMessage =
322 | error instanceof Error ? error.message : String(error);
323 | const errorContext = requestContextService.createRequestContext({
324 | operation: "getTaskById.error",
325 | taskId: id,
326 | });
327 | logger.error("Error getting task by ID", error as Error, {
328 | ...errorContext,
329 | detail: errorMessage,
330 | });
331 | throw error;
332 | } finally {
333 | await session.close();
334 | }
335 | }
336 |
337 | /**
338 | * Check if all dependencies of a task are completed
339 | * @param taskId Task ID to check dependencies for
340 | * @returns True if all dependencies are completed, false otherwise
341 | */
342 | static async areAllDependenciesCompleted(taskId: string): Promise<boolean> {
343 | const session = await neo4jDriver.getSession();
344 |
345 | try {
346 | const query = `
347 | MATCH (t:${NodeLabels.Task} {id: $taskId})-[:${RelationshipTypes.DEPENDS_ON}]->(dep:${NodeLabels.Task})
348 | WHERE dep.status <> 'completed'
349 | RETURN count(dep) AS incompleteCount
350 | `;
351 |
352 | const result = await session.executeRead(async (tx) => {
353 | const result = await tx.run(query, { taskId });
354 | // Use standard number directly, Neo4j count() returns a number, not an Integer object
355 | return result.records[0]?.get("incompleteCount") || 0;
356 | });
357 |
358 | return result === 0;
359 | } catch (error) {
360 | const errorMessage =
361 | error instanceof Error ? error.message : String(error);
362 | const errorContext = requestContextService.createRequestContext({
363 | operation: "areAllDependenciesCompleted.error",
364 | taskId,
365 | });
366 | logger.error(
367 | "Error checking task dependencies completion status",
368 | error as Error,
369 | { ...errorContext, detail: errorMessage },
370 | );
371 | throw error;
372 | } finally {
373 | await session.close();
374 | }
375 | }
376 |
377 | /**
378 | * Update a task's properties and handle assignment changes via relationships.
379 | * @param id Task ID
380 | * @param updates Task updates, including optional assignedTo for relationship changes
381 | * @returns The updated task (without assignedTo property)
382 | */
383 | static async updateTask(
384 | id: string,
385 | updates: Partial<
386 | Omit<Neo4jTask, "id" | "projectId" | "createdAt" | "updatedAt">
387 | > & { assignedTo?: string | null },
388 | ): Promise<Neo4jTask> {
389 | const session = await neo4jDriver.getSession();
390 |
391 | try {
392 | const exists = await Neo4jUtils.nodeExists(NodeLabels.Task, "id", id);
393 | if (!exists) {
394 | throw new Error(`Task with ID ${id} not found`);
395 | }
396 |
397 | if (updates.status === "in-progress" || updates.status === "completed") {
398 | const depsCompleted = await this.areAllDependenciesCompleted(id);
399 | if (!depsCompleted) {
400 | throw new Error(
401 | `Cannot mark task as ${updates.status} because not all dependencies are completed`,
402 | );
403 | }
404 | }
405 |
406 | const updateParams: Record<string, any> = {
407 | id,
408 | updatedAt: Neo4jUtils.getCurrentTimestamp(),
409 | };
410 | let setClauses = ["t.updatedAt = $updatedAt"];
411 | const allowedProperties: (keyof Neo4jTask)[] = [
412 | "projectId",
413 | "title",
414 | "description",
415 | "priority",
416 | "status",
417 | "urls",
418 | "tags",
419 | "completionRequirements",
420 | "outputFormat",
421 | "taskType",
422 | ];
423 |
424 | // Handle property updates - serialize urls if present
425 | for (const [key, value] of Object.entries(updates)) {
426 | if (
427 | value !== undefined &&
428 | key !== "assignedTo" &&
429 | allowedProperties.includes(key as keyof Neo4jTask)
430 | ) {
431 | // Serialize urls array to JSON string if it's the key being updated
432 | updateParams[key] =
433 | key === "urls" ? JSON.stringify(value || []) : value;
434 | setClauses.push(`t.${key} = $${key}`);
435 | }
436 | }
437 |
438 | // Handle assignment change (logic remains the same)
439 | let assignmentClause = "";
440 | const newAssigneeId = updates.assignedTo;
441 | if (newAssigneeId !== undefined) {
442 | // Check if assignedTo is part of the update
443 | if (newAssigneeId === null) {
444 | // Unassign: Delete existing relationship
445 | assignmentClause = `
446 | WITH t
447 | OPTIONAL MATCH (t)-[oldRel:${RelationshipTypes.ASSIGNED_TO}]->(:${NodeLabels.User})
448 | DELETE oldRel
449 | `;
450 | } else {
451 | // Assign/Reassign: Use MERGE for the user node
452 | updateParams.newAssigneeId = newAssigneeId;
453 | assignmentClause = `
454 | WITH t
455 | OPTIONAL MATCH (t)-[oldRel:${RelationshipTypes.ASSIGNED_TO}]->(:${NodeLabels.User})
456 | DELETE oldRel
457 | WITH t
458 | MERGE (newUser:${NodeLabels.User} {id: $newAssigneeId})
459 | ON CREATE SET newUser.createdAt = $updatedAt
460 | CREATE (t)-[:${RelationshipTypes.ASSIGNED_TO}]->(newUser)
461 | `;
462 | }
463 | }
464 |
465 | // Retrieve urls as JSON string
466 | const query = `
467 | MATCH (t:${NodeLabels.Task} {id: $id})
468 | ${setClauses.length > 0 ? `SET ${setClauses.join(", ")}` : ""}
469 | ${assignmentClause}
470 | // Return properties defined in Neo4jTask
471 | RETURN t.id as id,
472 | t.projectId as projectId,
473 | t.title as title,
474 | t.description as description,
475 | t.priority as priority,
476 | t.status as status,
477 | t.urls as urls,
478 | t.tags as tags,
479 | t.completionRequirements as completionRequirements,
480 | t.outputFormat as outputFormat,
481 | t.taskType as taskType,
482 | t.createdAt as createdAt,
483 | t.updatedAt as updatedAt
484 | `;
485 |
486 | const result = await session.executeWrite(async (tx) => {
487 | const result = await tx.run(query, updateParams);
488 | // Use .get() for each field
489 | return result.records.length > 0 ? result.records[0] : null;
490 | });
491 |
492 | if (!result) {
493 | throw new Error("Failed to update task or retrieve its properties");
494 | }
495 |
496 | // Construct the Neo4jTask object - deserialize urls
497 | const updatedTaskData: Neo4jTask = {
498 | id: result.get("id"),
499 | projectId: result.get("projectId"),
500 | title: result.get("title"),
501 | description: result.get("description"),
502 | priority: result.get("priority"),
503 | status: result.get("status"),
504 | urls: JSON.parse(result.get("urls") || "[]"), // Deserialize urls
505 | tags: result.get("tags") || [],
506 | completionRequirements: result.get("completionRequirements"),
507 | outputFormat: result.get("outputFormat"),
508 | taskType: result.get("taskType"),
509 | createdAt: result.get("createdAt"),
510 | updatedAt: result.get("updatedAt"),
511 | };
512 | const reqContext_update = requestContextService.createRequestContext({
513 | operation: "updateTask",
514 | taskId: id,
515 | });
516 | logger.info("Task updated successfully", reqContext_update);
517 | return updatedTaskData;
518 | } catch (error) {
519 | const errorMessage =
520 | error instanceof Error ? error.message : String(error);
521 | const errorContext = requestContextService.createRequestContext({
522 | operation: "updateTask.error",
523 | taskId: id,
524 | updatesApplied: updates,
525 | });
526 | logger.error("Error updating task", error as Error, {
527 | ...errorContext,
528 | detail: errorMessage,
529 | });
530 | throw error;
531 | } finally {
532 | await session.close();
533 | }
534 | }
535 |
536 | /**
537 | * Delete a task
538 | * @param id Task ID
539 | * @returns True if deleted, false if not found
540 | */
541 | static async deleteTask(id: string): Promise<boolean> {
542 | const session = await neo4jDriver.getSession();
543 |
544 | try {
545 | const exists = await Neo4jUtils.nodeExists(NodeLabels.Task, "id", id);
546 | if (!exists) {
547 | return false;
548 | }
549 |
550 | const query = `
551 | MATCH (t:${NodeLabels.Task} {id: $id})
552 | DETACH DELETE t
553 | `;
554 |
555 | await session.executeWrite(async (tx) => {
556 | await tx.run(query, { id });
557 | });
558 | const reqContext_delete = requestContextService.createRequestContext({
559 | operation: "deleteTask",
560 | taskId: id,
561 | });
562 | logger.info("Task deleted successfully", reqContext_delete);
563 | return true;
564 | } catch (error) {
565 | const errorMessage =
566 | error instanceof Error ? error.message : String(error);
567 | const errorContext = requestContextService.createRequestContext({
568 | operation: "deleteTask.error",
569 | taskId: id,
570 | });
571 | logger.error("Error deleting task", error as Error, {
572 | ...errorContext,
573 | detail: errorMessage,
574 | });
575 | throw error;
576 | } finally {
577 | await session.close();
578 | }
579 | }
580 |
581 | /**
582 | * Get tasks for a project with optional filtering and server-side pagination.
583 | * Includes assigned user ID via relationship.
584 | * @param options Filter and pagination options
585 | * @returns Paginated list of tasks including assignedToUserId
586 | */
587 | static async getTasks(
588 | options: TaskFilterOptions,
589 | ): Promise<PaginatedResult<Neo4jTask & { assignedToUserId: string | null }>> {
590 | const session = await neo4jDriver.getSession();
591 |
592 | try {
593 | const nodeAlias = "t";
594 | const userAlias = "u"; // Alias for the User node
595 |
596 | // Define how to match the assigned user relationship
597 | let assignmentMatchClause = `OPTIONAL MATCH (${nodeAlias})-[:${RelationshipTypes.ASSIGNED_TO}]->(${userAlias}:${NodeLabels.User})`;
598 | if (options.assignedTo) {
599 | // If filtering by assignee, make the MATCH non-optional and filter by user ID
600 | assignmentMatchClause = `MATCH (${nodeAlias})-[:${RelationshipTypes.ASSIGNED_TO}]->(${userAlias}:${NodeLabels.User} {id: $assignedTo})`;
601 | }
602 |
603 | // Define the properties to return from the query
604 | const returnProperties = [
605 | `${nodeAlias}.id as id`,
606 | `${nodeAlias}.projectId as projectId`,
607 | `${nodeAlias}.title as title`,
608 | `${nodeAlias}.description as description`,
609 | `${nodeAlias}.priority as priority`,
610 | `${nodeAlias}.status as status`,
611 | `${userAlias}.id as assignedToUserId`, // Get user ID from the relationship
612 | `${nodeAlias}.urls as urls`,
613 | `${nodeAlias}.tags as tags`,
614 | `${nodeAlias}.completionRequirements as completionRequirements`,
615 | `${nodeAlias}.outputFormat as outputFormat`,
616 | `${nodeAlias}.taskType as taskType`,
617 | `${nodeAlias}.createdAt as createdAt`,
618 | `${nodeAlias}.updatedAt as updatedAt`,
619 | ];
620 |
621 | // Use the buildListQuery helper
622 | const { countQuery, dataQuery, params } = buildListQuery(
623 | NodeLabels.Task,
624 | returnProperties,
625 | {
626 | // Filters
627 | projectId: options.projectId, // Pass projectId filter
628 | status: options.status,
629 | priority: options.priority,
630 | assignedTo: options.assignedTo, // Pass assignedTo for potential filtering in helper/match clause
631 | tags: options.tags,
632 | taskType: options.taskType,
633 | },
634 | {
635 | // Pagination
636 | sortBy: options.sortBy,
637 | sortDirection: options.sortDirection,
638 | page: options.page,
639 | limit: options.limit,
640 | },
641 | nodeAlias, // Primary node alias
642 | assignmentMatchClause, // Additional MATCH clause for assignment
643 | );
644 |
645 | const reqContext_list = requestContextService.createRequestContext({
646 | operation: "getTasks",
647 | filterOptions: options,
648 | });
649 | // Execute count query
650 | const totalResult = await session.executeRead(async (tx) => {
651 | // buildListQuery returns params including skip/limit, remove them for count
652 | const countParams = { ...params };
653 | delete countParams.skip;
654 | delete countParams.limit;
655 | logger.debug("Executing Task Count Query (using buildListQuery):", {
656 | ...reqContext_list,
657 | query: countQuery,
658 | params: countParams,
659 | });
660 | const result = await tx.run(countQuery, countParams);
661 | return result.records[0]?.get("total") ?? 0;
662 | });
663 | const total = totalResult;
664 |
665 | // Execute data query
666 | const dataResult = await session.executeRead(async (tx) => {
667 | logger.debug("Executing Task Data Query (using buildListQuery):", {
668 | ...reqContext_list,
669 | query: dataQuery,
670 | params: params,
671 | });
672 | const result = await tx.run(dataQuery, params);
673 | return result.records;
674 | });
675 |
676 | // Map results - deserialize urls
677 | const tasks = dataResult.map((record) => {
678 | // Construct the base Neo4jTask object
679 | const taskData: Neo4jTask = {
680 | id: record.get("id"),
681 | projectId: record.get("projectId"),
682 | title: record.get("title"),
683 | description: record.get("description"),
684 | priority: record.get("priority"),
685 | status: record.get("status"),
686 | urls: JSON.parse(record.get("urls") || "[]"), // Deserialize urls
687 | tags: record.get("tags") || [],
688 | completionRequirements: record.get("completionRequirements"),
689 | outputFormat: record.get("outputFormat"),
690 | taskType: record.get("taskType"),
691 | createdAt: record.get("createdAt"),
692 | updatedAt: record.get("updatedAt"),
693 | };
694 | // Get the assigned user ID from the record
695 | const assignedToUserId = record.get("assignedToUserId");
696 | // Combine base task data with the user ID
697 | return {
698 | ...taskData,
699 | assignedToUserId: assignedToUserId,
700 | };
701 | });
702 |
703 | const page = Math.max(options.page || 1, 1);
704 | const limit = Math.min(Math.max(options.limit || 20, 1), 100);
705 | const totalPages = Math.ceil(total / limit);
706 |
707 | return {
708 | data: tasks,
709 | total,
710 | page,
711 | limit,
712 | totalPages,
713 | };
714 | } catch (error) {
715 | const errorMessage =
716 | error instanceof Error ? error.message : String(error);
717 | const errorContext = requestContextService.createRequestContext({
718 | operation: "getTasks.error",
719 | filterOptions: options,
720 | });
721 | logger.error("Error getting tasks", error as Error, {
722 | ...errorContext,
723 | detail: errorMessage,
724 | });
725 | throw error;
726 | } finally {
727 | await session.close();
728 | }
729 | }
730 |
731 | /**
732 | * Add a dependency relationship between tasks
733 | * @param sourceTaskId ID of the dependent task (source)
734 | * @param targetTaskId ID of the dependency task (target)
735 | * @returns The IDs of the two tasks and the relationship ID
736 | */
737 | static async addTaskDependency(
738 | sourceTaskId: string,
739 | targetTaskId: string,
740 | ): Promise<{ id: string; sourceTaskId: string; targetTaskId: string }> {
741 | const session = await neo4jDriver.getSession();
742 |
743 | try {
744 | // Logic remains the same
745 | const sourceExists = await Neo4jUtils.nodeExists(
746 | NodeLabels.Task,
747 | "id",
748 | sourceTaskId,
749 | );
750 | const targetExists = await Neo4jUtils.nodeExists(
751 | NodeLabels.Task,
752 | "id",
753 | targetTaskId,
754 | );
755 |
756 | if (!sourceExists)
757 | throw new Error(`Source task with ID ${sourceTaskId} not found`);
758 | if (!targetExists)
759 | throw new Error(`Target task with ID ${targetTaskId} not found`);
760 |
761 | const dependencyExists = await Neo4jUtils.relationshipExists(
762 | NodeLabels.Task,
763 | "id",
764 | sourceTaskId,
765 | NodeLabels.Task,
766 | "id",
767 | targetTaskId,
768 | RelationshipTypes.DEPENDS_ON,
769 | );
770 |
771 | if (dependencyExists) {
772 | throw new Error(
773 | `Dependency relationship already exists between tasks ${sourceTaskId} and ${targetTaskId}`,
774 | );
775 | }
776 |
777 | const circularDependencyQuery = `
778 | MATCH path = (target:${NodeLabels.Task} {id: $targetTaskId})-[:${RelationshipTypes.DEPENDS_ON}*]->(source:${NodeLabels.Task} {id: $sourceTaskId})
779 | RETURN count(path) > 0 AS hasCycle
780 | `;
781 |
782 | const cycleCheckResult = await session.executeRead(async (tx) => {
783 | const result = await tx.run(circularDependencyQuery, {
784 | sourceTaskId,
785 | targetTaskId,
786 | });
787 | return result.records[0]?.get("hasCycle");
788 | });
789 |
790 | if (cycleCheckResult) {
791 | throw new Error(
792 | "Adding this dependency would create a circular dependency chain",
793 | );
794 | }
795 |
796 | const dependencyId = `tdep_${generateId()}`;
797 | const query = `
798 | MATCH (source:${NodeLabels.Task} {id: $sourceTaskId}),
799 | (target:${NodeLabels.Task} {id: $targetTaskId})
800 | CREATE (source)-[r:${RelationshipTypes.DEPENDS_ON} {
801 | id: $dependencyId,
802 | createdAt: $createdAt
803 | }]->(target)
804 | RETURN r.id as id, source.id as sourceTaskId, target.id as targetTaskId
805 | `;
806 |
807 | const params = {
808 | sourceTaskId,
809 | targetTaskId,
810 | dependencyId,
811 | createdAt: Neo4jUtils.getCurrentTimestamp(),
812 | };
813 |
814 | const result = await session.executeWrite(async (tx) => {
815 | const result = await tx.run(query, params);
816 | return result.records;
817 | });
818 |
819 | if (!result || result.length === 0) {
820 | throw new Error("Failed to create task dependency relationship");
821 | }
822 |
823 | const record = result[0];
824 | const dependency = {
825 | id: record.get("id"),
826 | sourceTaskId: record.get("sourceTaskId"),
827 | targetTaskId: record.get("targetTaskId"),
828 | };
829 | const reqContext_addDep = requestContextService.createRequestContext({
830 | operation: "addTaskDependency",
831 | sourceTaskId,
832 | targetTaskId,
833 | });
834 | logger.info("Task dependency added successfully", reqContext_addDep);
835 | return dependency;
836 | } catch (error) {
837 | const errorMessage =
838 | error instanceof Error ? error.message : String(error);
839 | const errorContext = requestContextService.createRequestContext({
840 | operation: "addTaskDependency.error",
841 | sourceTaskId,
842 | targetTaskId,
843 | });
844 | logger.error("Error adding task dependency", error as Error, {
845 | ...errorContext,
846 | detail: errorMessage,
847 | });
848 | throw error;
849 | } finally {
850 | await session.close();
851 | }
852 | }
853 |
854 | /**
855 | * Remove a dependency relationship between tasks
856 | * @param dependencyId The ID of the dependency relationship to remove
857 | * @returns True if removed, false if not found
858 | */
859 | static async removeTaskDependency(dependencyId: string): Promise<boolean> {
860 | const session = await neo4jDriver.getSession();
861 |
862 | try {
863 | const query = `
864 | MATCH (source:${NodeLabels.Task})-[r:${RelationshipTypes.DEPENDS_ON} {id: $dependencyId}]->(target:${NodeLabels.Task})
865 | DELETE r
866 | `;
867 |
868 | const result = await session.executeWrite(async (tx) => {
869 | const res = await tx.run(query, { dependencyId });
870 | return res.summary.counters.updates().relationshipsDeleted > 0;
871 | });
872 |
873 | const reqContext_removeDep = requestContextService.createRequestContext({
874 | operation: "removeTaskDependency",
875 | dependencyId,
876 | });
877 | if (result) {
878 | logger.info(
879 | "Task dependency removed successfully",
880 | reqContext_removeDep,
881 | );
882 | } else {
883 | logger.warning(
884 | "Task dependency not found or not removed",
885 | reqContext_removeDep,
886 | );
887 | }
888 |
889 | return result;
890 | } catch (error) {
891 | const errorMessage =
892 | error instanceof Error ? error.message : String(error);
893 | const errorContext = requestContextService.createRequestContext({
894 | operation: "removeTaskDependency.error",
895 | dependencyId,
896 | });
897 | logger.error("Error removing task dependency", error as Error, {
898 | ...errorContext,
899 | detail: errorMessage,
900 | });
901 | throw error;
902 | } finally {
903 | await session.close();
904 | }
905 | }
906 |
907 | /**
908 | * Get task dependencies (both dependencies and dependents)
909 | * @param taskId Task ID
910 | * @returns Object containing dependencies and dependents
911 | */
912 | static async getTaskDependencies(taskId: string): Promise<{
913 | dependencies: {
914 | id: string; // Relationship ID
915 | taskId: string; // Target Task ID
916 | title: string;
917 | status: string;
918 | priority: string;
919 | }[];
920 | dependents: {
921 | id: string; // Relationship ID
922 | taskId: string; // Source Task ID
923 | title: string;
924 | status: string;
925 | priority: string;
926 | }[];
927 | }> {
928 | const session = await neo4jDriver.getSession();
929 |
930 | try {
931 | // Logic remains the same
932 | const exists = await Neo4jUtils.nodeExists(NodeLabels.Task, "id", taskId);
933 | if (!exists) {
934 | throw new Error(`Task with ID ${taskId} not found`);
935 | }
936 |
937 | const dependenciesQuery = `
938 | MATCH (source:${NodeLabels.Task} {id: $taskId})-[r:${RelationshipTypes.DEPENDS_ON}]->(target:${NodeLabels.Task})
939 | RETURN r.id as id,
940 | target.id AS taskId,
941 | target.title AS title,
942 | target.status AS status,
943 | target.priority AS priority
944 | ORDER BY target.priority DESC, target.title
945 | `;
946 |
947 | const dependentsQuery = `
948 | MATCH (source:${NodeLabels.Task})-[r:${RelationshipTypes.DEPENDS_ON}]->(target:${NodeLabels.Task} {id: $taskId})
949 | RETURN r.id as id,
950 | source.id AS taskId,
951 | source.title AS title,
952 | source.status AS status,
953 | source.priority AS priority
954 | ORDER BY source.priority DESC, source.title
955 | `;
956 |
957 | const [dependenciesResult, dependentsResult] = await Promise.all([
958 | session.executeRead(
959 | async (tx) => (await tx.run(dependenciesQuery, { taskId })).records,
960 | ),
961 | session.executeRead(
962 | async (tx) => (await tx.run(dependentsQuery, { taskId })).records,
963 | ),
964 | ]);
965 |
966 | const dependencies = dependenciesResult.map((record) => ({
967 | id: record.get("id"),
968 | taskId: record.get("taskId"),
969 | title: record.get("title"),
970 | status: record.get("status"),
971 | priority: record.get("priority"),
972 | }));
973 |
974 | const dependents = dependentsResult.map((record) => ({
975 | id: record.get("id"),
976 | taskId: record.get("taskId"),
977 | title: record.get("title"),
978 | status: record.get("status"),
979 | priority: record.get("priority"),
980 | }));
981 |
982 | return { dependencies, dependents };
983 | } catch (error) {
984 | const errorMessage =
985 | error instanceof Error ? error.message : String(error);
986 | const errorContext = requestContextService.createRequestContext({
987 | operation: "getTaskDependencies.error",
988 | taskId,
989 | });
990 | logger.error("Error getting task dependencies", error as Error, {
991 | ...errorContext,
992 | detail: errorMessage,
993 | });
994 | throw error;
995 | } finally {
996 | await session.close();
997 | }
998 | }
999 |
1000 | /**
1001 | * Assign a task to a user by creating an ASSIGNED_TO relationship.
1002 | * @param taskId Task ID
1003 | * @param userId User ID
1004 | * @returns The updated task (without assignedTo property)
1005 | */
1006 | static async assignTask(taskId: string, userId: string): Promise<Neo4jTask> {
1007 | const session = await neo4jDriver.getSession();
1008 |
1009 | try {
1010 | // Logic remains the same
1011 | const taskExists = await Neo4jUtils.nodeExists(
1012 | NodeLabels.Task,
1013 | "id",
1014 | taskId,
1015 | );
1016 | if (!taskExists) throw new Error(`Task with ID ${taskId} not found`);
1017 |
1018 | const userExists = await Neo4jUtils.nodeExists(
1019 | NodeLabels.User,
1020 | "id",
1021 | userId,
1022 | );
1023 | if (!userExists) throw new Error(`User with ID ${userId} not found`);
1024 |
1025 | const query = `
1026 | MATCH (t:${NodeLabels.Task} {id: $taskId}), (u:${NodeLabels.User} {id: $userId})
1027 |
1028 | OPTIONAL MATCH (t)-[r:${RelationshipTypes.ASSIGNED_TO}]->(:${NodeLabels.User})
1029 | DELETE r
1030 |
1031 | CREATE (t)-[:${RelationshipTypes.ASSIGNED_TO}]->(u)
1032 |
1033 | SET t.updatedAt = $updatedAt
1034 |
1035 | // Return properties defined in Neo4jTask
1036 | RETURN t.id as id,
1037 | t.projectId as projectId,
1038 | t.title as title,
1039 | t.description as description,
1040 | t.priority as priority,
1041 | t.status as status,
1042 | // assignedTo removed
1043 | t.urls as urls,
1044 | t.tags as tags,
1045 | t.completionRequirements as completionRequirements,
1046 | t.outputFormat as outputFormat,
1047 | t.taskType as taskType,
1048 | t.createdAt as createdAt,
1049 | t.updatedAt as updatedAt
1050 | `;
1051 |
1052 | const params = {
1053 | taskId,
1054 | userId,
1055 | updatedAt: Neo4jUtils.getCurrentTimestamp(),
1056 | };
1057 |
1058 | const result = await session.executeWrite(async (tx) => {
1059 | const result = await tx.run(query, params);
1060 | // Use .get() for each field
1061 | return result.records.length > 0 ? result.records[0] : null;
1062 | });
1063 |
1064 | if (!result) {
1065 | throw new Error("Failed to assign task or retrieve its properties");
1066 | }
1067 |
1068 | // Construct the Neo4jTask object - deserialize urls
1069 | const updatedTaskData: Neo4jTask = {
1070 | id: result.get("id"),
1071 | projectId: result.get("projectId"),
1072 | title: result.get("title"),
1073 | description: result.get("description"),
1074 | priority: result.get("priority"),
1075 | status: result.get("status"),
1076 | urls: JSON.parse(result.get("urls") || "[]"), // Deserialize urls
1077 | tags: result.get("tags") || [],
1078 | completionRequirements: result.get("completionRequirements"),
1079 | outputFormat: result.get("outputFormat"),
1080 | taskType: result.get("taskType"),
1081 | createdAt: result.get("createdAt"),
1082 | updatedAt: result.get("updatedAt"),
1083 | };
1084 | const reqContext_assign = requestContextService.createRequestContext({
1085 | operation: "assignTask",
1086 | taskId,
1087 | userId,
1088 | });
1089 | logger.info("Task assigned successfully", reqContext_assign);
1090 | return updatedTaskData;
1091 | } catch (error) {
1092 | const errorMessage =
1093 | error instanceof Error ? error.message : String(error);
1094 | const errorContext = requestContextService.createRequestContext({
1095 | operation: "assignTask.error",
1096 | taskId,
1097 | userId,
1098 | });
1099 | logger.error("Error assigning task", error as Error, {
1100 | ...errorContext,
1101 | detail: errorMessage,
1102 | });
1103 | throw error;
1104 | } finally {
1105 | await session.close();
1106 | }
1107 | }
1108 |
1109 | /**
1110 | * Unassign a task by deleting the ASSIGNED_TO relationship.
1111 | * @param taskId Task ID
1112 | * @returns The updated task (without assignedTo property)
1113 | */
1114 | static async unassignTask(taskId: string): Promise<Neo4jTask> {
1115 | const session = await neo4jDriver.getSession();
1116 |
1117 | try {
1118 | // Logic remains the same
1119 | const taskExists = await Neo4jUtils.nodeExists(
1120 | NodeLabels.Task,
1121 | "id",
1122 | taskId,
1123 | );
1124 | if (!taskExists) throw new Error(`Task with ID ${taskId} not found`);
1125 |
1126 | const query = `
1127 | MATCH (t:${NodeLabels.Task} {id: $taskId})
1128 |
1129 | OPTIONAL MATCH (t)-[r:${RelationshipTypes.ASSIGNED_TO}]->(:${NodeLabels.User})
1130 | DELETE r
1131 |
1132 | SET t.updatedAt = $updatedAt
1133 |
1134 | // Return properties defined in Neo4jTask
1135 | RETURN t.id as id,
1136 | t.projectId as projectId,
1137 | t.title as title,
1138 | t.description as description,
1139 | t.priority as priority,
1140 | t.status as status,
1141 | // assignedTo removed
1142 | t.urls as urls,
1143 | t.tags as tags,
1144 | t.completionRequirements as completionRequirements,
1145 | t.outputFormat as outputFormat,
1146 | t.taskType as taskType,
1147 | t.createdAt as createdAt,
1148 | t.updatedAt as updatedAt
1149 | `;
1150 |
1151 | const params = {
1152 | taskId,
1153 | updatedAt: Neo4jUtils.getCurrentTimestamp(),
1154 | };
1155 |
1156 | const result = await session.executeWrite(async (tx) => {
1157 | const result = await tx.run(query, params);
1158 | // Use .get() for each field
1159 | return result.records.length > 0 ? result.records[0] : null;
1160 | });
1161 |
1162 | if (!result) {
1163 | throw new Error("Failed to unassign task or retrieve its properties");
1164 | }
1165 |
1166 | // Construct the Neo4jTask object - deserialize urls
1167 | const updatedTaskData: Neo4jTask = {
1168 | id: result.get("id"),
1169 | projectId: result.get("projectId"),
1170 | title: result.get("title"),
1171 | description: result.get("description"),
1172 | priority: result.get("priority"),
1173 | status: result.get("status"),
1174 | urls: JSON.parse(result.get("urls") || "[]"), // Deserialize urls
1175 | tags: result.get("tags") || [],
1176 | completionRequirements: result.get("completionRequirements"),
1177 | outputFormat: result.get("outputFormat"),
1178 | taskType: result.get("taskType"),
1179 | createdAt: result.get("createdAt"),
1180 | updatedAt: result.get("updatedAt"),
1181 | };
1182 | const reqContext_unassign = requestContextService.createRequestContext({
1183 | operation: "unassignTask",
1184 | taskId,
1185 | });
1186 | logger.info("Task unassigned successfully", reqContext_unassign);
1187 | return updatedTaskData;
1188 | } catch (error) {
1189 | const errorMessage =
1190 | error instanceof Error ? error.message : String(error);
1191 | const errorContext = requestContextService.createRequestContext({
1192 | operation: "unassignTask.error",
1193 | taskId,
1194 | });
1195 | logger.error("Error unassigning task", error as Error, {
1196 | ...errorContext,
1197 | detail: errorMessage,
1198 | });
1199 | throw error;
1200 | } finally {
1201 | await session.close();
1202 | }
1203 | }
1204 | }
1205 |
```