Codex-generated pull request - #2
Conversation
📝 WalkthroughWalkthroughThis pull request introduces a complete Node.js TypeScript CRM skeleton application with Neo4j database backend, including domain entities, repository patterns, API controllers with JWT authentication, services, CLI utilities, and test scaffolding. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant HttpServer as HTTP Server
participant Controller as CRM Controller
participant Service as CRM Service
participant Guard as Tenant Guard
participant Repo as Repository
participant Neo4j as Neo4j Client
participant Database as Neo4j DB
Client->>HttpServer: POST /contacts + JWT token
HttpServer->>Controller: handleCreateContact(req, payload)
Controller->>Controller: auth.parseContext(req)
Controller->>Service: createContact(ctx, input)
Service->>Guard: verify(ctx)
Guard-->>Service: ✓ Valid
Service->>Repo: create(ctx, input)
Repo->>Neo4j: getSession()
Neo4j-->>Repo: Session
Repo->>Database: CREATE Contact node + MERGE Tenant
Database-->>Repo: Created Contact
Repo-->>Service: Contact entity
Service-->>Controller: Contact entity
Controller-->>HttpServer: ApiResponse<Contact>
HttpServer-->>Client: 200 + JSON response
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 914021ca91
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| `MATCH (p:Pipeline {id: $pipelineId, tenantId: $tenantId}) | ||
| CREATE (d:Deal { |
There was a problem hiding this comment.
Fail deal creation when pipeline lookup matches no tenant row
The create path returns a Deal object even when no Pipeline node matches (id, tenantId), because the Cypher starts with MATCH and does not check whether any rows were produced before returning success. In that case Neo4j performs no CREATE, but the API still reports a created deal, which can silently drop writes for invalid or cross-tenant pipelineId values.
Useful? React with 👍 / 👎.
| } catch (error) { | ||
| sendError(res, 500, error as Error); |
There was a problem hiding this comment.
Return client error codes for auth and request parsing failures
This handler converts every thrown error into HTTP 500, including expected client-side failures from JwtAuth.parseContext (missing/invalid bearer token) and parseJson (malformed JSON). That makes normal bad-request/unauthorized scenarios look like server outages to callers and monitoring, and can trigger incorrect retry behavior instead of a 400/401 response.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 16
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🟡 Minor comments (6)
src/main.ts-15-18 (1)
15-18:⚠️ Potential issue | 🟡 MinorNo error handling around
verifyConnectivitybefore the rest of bootstrap.
verifyConnectivitywill throw if Neo4j is unreachable, which is caught by the outer.catch. However, theNeo4jClient(driver) is already created at that point and won't be closed, leaking the driver connection pool. Consider wrapping or closing the client on failure.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main.ts` around lines 15 - 18, Wrap the call to Neo4jClient.verifyConnectivity inside a try/catch in bootstrap so that if verifyConnectivity throws you call the Neo4jClient's cleanup method (e.g., neo4j.close() or neo4j.closeDriver() on the created Neo4jClient instance) to release the driver/connection pool, then rethrow the error so the outer .catch still handles it; locate the neo4j variable and the verifyConnectivity invocation in bootstrap and ensure the client is always closed on failure before propagating the error.src/cli/createTenant.ts-9-21 (1)
9-21:⚠️ Potential issue | 🟡 MinorSession and driver are leaked if
executeWritethrows.Wrap the write + close in
try/finallyto ensure cleanup on error.Proposed fix
const neo4j = new Neo4jClient(loadConfig()); const session = neo4j.getSession(); - - await session.executeWrite(tx => tx.run( - `MERGE (t:Tenant {id: $tenantId}) - SET t.name = $tenantName, t.createdAt = datetime().toString()`, - { tenantId, tenantName } - )); - - console.log(`Tenant ready: ${tenantName} (${tenantId})`); - await session.close(); - await neo4j.close(); + try { + await session.executeWrite(tx => tx.run( + `MERGE (t:Tenant {id: $tenantId}) + ON CREATE SET t.name = $tenantName, t.createdAt = datetime().toString() + ON MATCH SET t.name = $tenantName`, + { tenantId, tenantName } + )); + console.log(`Tenant ready: ${tenantName} (${tenantId})`); + } finally { + await session.close(); + await neo4j.close(); + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/cli/createTenant.ts` around lines 9 - 21, The current flow using Neo4jClient -> getSession() then session.executeWrite(...) leaks the session and driver if executeWrite throws; modify the create tenant logic to acquire the session (via getSession) and then wrap the call to session.executeWrite(...) and the subsequent console.log in a try block and put session.close() and neo4j.close() in a finally block so both session.close() and neo4j.close() are always executed even on error; ensure you still await session.close() and await neo4j.close() in the finally and preserve the MERGE/parameters usage in the executeWrite call.src/data/repositories/ActivityRepository.ts-19-32 (1)
19-32:⚠️ Potential issue | 🟡 MinorActivity node is created without a relationship to the Tenant graph.
Unlike
PipelineRepositoryandDealRepository,ActivityRepository.createuses a bareCREATEwithout linking the Activity to a Tenant node (noMERGE (t:Tenant)...MERGE (t)-[:OWNS]->(a)). This inconsistency means Activity nodes will be orphaned in the graph — they can only be found via thetenantIdproperty, not via graph traversal.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/data/repositories/ActivityRepository.ts` around lines 19 - 32, Activity nodes are created without linking to the Tenant graph in ActivityRepository.create; update the session.executeWrite call that runs the CREATE query to instead MERGE the tenant node by tenantId (e.g., MERGE (t:Tenant {id: $tenantId}) or MATCH existing) and create the ownership relationship (e.g., MERGE (t)-[:OWNS]->(a)) so the Activity (label Activity) is connected to Tenant; modify the Cypher used in session.executeWrite (and keep passing activity with $tenantId) to include MERGE (t:Tenant {id: $tenantId}) and a MERGE (t)-[:OWNS]->(a) after creating the Activity node.src/api/server.ts-24-80 (1)
24-80:⚠️ Potential issue | 🟡 MinorUnhandled async rejection risk in the
createServercallback.Node's
http.createServerdoes not handle rejected promises from anasynccallback. IfsendErroritself throws inside thecatchblock (e.g., ifresis already destroyed), the rejection goes unhandled and may crash the process. Consider wrapping the outer catch to ensureresis ended safely.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/api/server.ts` around lines 24 - 80, The async callback passed to createServer in createApiServer can produce unhandled promise rejections if sendError throws; change the server callback from an async function to a synchronous function that launches an async IIFE and handles rejections explicitly: wrap the existing body (all uses of send, sendError, parseJson and controller.handle* methods) inside (async () => { ... })().catch(err => { try { sendError(res, 500, err as Error); } catch (finalErr) { if (!res.writableEnded && !res.destroyed) { res.statusCode = 500; res.end(); } } }); this ensures any rejection from controller.handle* or sendError is caught and the response is ended safely.src/debug/DebugService.ts-16-24 (1)
16-24:⚠️ Potential issue | 🟡 MinorExecution continues after connectivity failure, producing a misleading second error.
If the ping at line 17 fails, the method falls through to the audit query at line 27, which will also fail on the same broken session. This adds a noisy
CYPHERissue that masks the realCONFIGproblem. Short-circuit after a connectivity failure.Proposed fix
try { await session.run('RETURN 1 AS ping'); } catch (err) { issues.push({ category: 'CONFIG', severity: 'HIGH', message: `Neo4j connectivity failed: ${(err as Error).message}` }); + await session.close(); + return issues; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/debug/DebugService.ts` around lines 16 - 24, The connectivity ping failure is logged as a CONFIG issue but execution continues and runs the subsequent audit query (producing a misleading CYPHER error); modify the method so that after catching the error from session.run('RETURN 1 AS ping') and pushing the CONFIG issue (issues.push({...})), you short-circuit (e.g., return early or otherwise skip the audit query) to avoid running the audit against a broken session; locate the try/catch around session.run and ensure execution does not fall through to the audit query or any later Neo4j calls when a connectivity error is detected.src/api/models/requests.ts-29-29 (1)
29-29:⚠️ Potential issue | 🟡 Minor
CreateActivityRequestrequiresidwhile all other create requests make it optional.
Omit<Activity, 'tenantId' | 'createdAt'>retainsid: stringas required, whereas the hand-written interfaces (CreateContactRequest,CreateCompanyRequest, etc.) declareid?: string. This means API callers must supply anidfor activities but not for other entities — likely unintentional.Proposed fix
-export type CreateActivityRequest = Omit<Activity, 'tenantId' | 'createdAt'>; +export interface CreateActivityRequest { + id?: string; + type: 'CALL' | 'EMAIL' | 'MEETING' | 'NOTE'; + subject: string; + notes?: string; + dueAt?: string; + relatedContactId?: string; + relatedDealId?: string; +}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/api/models/requests.ts` at line 29, CreateActivityRequest currently keeps id required because it uses Omit<Activity, 'tenantId' | 'createdAt'>; change its definition so id is optional like the other create requests. Locate CreateActivityRequest and redefine it by removing id from the omitted keys and reintroducing it as optional (e.g., combine Omit<Activity, 'tenantId' | 'createdAt' | 'id'> with an optional id property or use Partial<Pick<Activity, 'id'>> with the existing Omit) so callers are not forced to supply an id.
🧹 Nitpick comments (10)
src/analytics/GraphAnalyticsService.ts (1)
1-5: Looks good for a skeleton — minor suggestions for hardening.The weighted-score logic and two-decimal rounding are correct. A couple of optional observations:
- The magic numbers
0.6and0.4could be extracted into named constants (e.g.,SIGNAL_WEIGHT,RECENCY_WEIGHT) for clarity and easier tuning later.- No guard against
NaN/Infinityinputs — if either argument is non-finite the result silently propagatesNaN. For a scoring function consumed downstream this could cause subtle issues.Both are fine to defer for a scaffold.
♻️ Optional: extract weights and add a basic guard
+const SIGNAL_WEIGHT = 0.6; +const RECENCY_WEIGHT = 0.4; + export class GraphAnalyticsService { scoreLeadRelationship(signalCount: number, recencyScore: number): number { - return Math.round((signalCount * 0.6 + recencyScore * 0.4) * 100) / 100; + if (!Number.isFinite(signalCount) || !Number.isFinite(recencyScore)) { + throw new RangeError('signalCount and recencyScore must be finite numbers'); + } + return Math.round((signalCount * SIGNAL_WEIGHT + recencyScore * RECENCY_WEIGHT) * 100) / 100; } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/analytics/GraphAnalyticsService.ts` around lines 1 - 5, Extract the magic weights in GraphAnalyticsService.scoreLeadRelationship into named constants (e.g., SIGNAL_WEIGHT = 0.6 and RECENCY_WEIGHT = 0.4) and use them in the formula for clarity and easy tuning; additionally add a basic guard at the start of scoreLeadRelationship to handle non-finite inputs (signalCount or recencyScore) by returning a safe default (e.g., 0) or clamping/validating inputs before computing, and ensure the final result is still rounded to two decimals.tests/api.test.js (1)
1-6: Placeholder test — consider tracking follow-up for real coverage.This is a no-op test. As the skeleton fills out, actual API integration tests (e.g., testing controller routing, JWT middleware rejection of unsigned tokens, tenant isolation) should replace this.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/api.test.js` around lines 1 - 6, The test file tests/api.test.js currently contains a no-op placeholder test ('test("api placeholder")') that should not remain as-is; replace it with either a skipped test (e.g., using test.skip) or remove it and create a tracked follow-up (issue/PR/TODO) to add real API integration tests covering controller routing, JWT middleware rejection of unsigned/invalid tokens, and tenant isolation; ensure the follow-up references the placeholder test so it can be implemented later and include which behaviors need asserting (routing, auth middleware, multi-tenant separation).package.json (1)
12-12: Test script glob may not work cross-platform.The glob
tests/**/*.test.jsrelies on shell expansion, which doesn't work on Windows (cmd/PowerShell). Also, since the project is TypeScript, these.jstest files won't be able to import.tssource modules without a build step or a loader.Consider using
tsxfor tests too, or compiling tests as part of the build:- "test": "node --test tests/**/*.test.js" + "test": "tsx --test tests/**/*.test.ts"🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@package.json` at line 12, The package.json "test" script uses a shell glob and .js test extension which fails on Windows and with TypeScript sources; update the "test" script (the "test" npm script) to use a cross-platform approach and run TypeScript tests (e.g., use a Node/runner that supports globs and TS like tsx or a Node test runner with a TS loader) so tests can run without a separate build and Windows shell expansion; ensure the pattern targets .test.ts files (or use a runner option that accepts globs) and document the change in package.json scripts.src/data/neo4j/schema.cypher (1)
17-22: Consider adding indexes ontenantIdfor tenant-scoped queries.All repository queries will likely filter by
tenantId. Without a dedicated index ontenantIdper label, these queries will require full label scans. The composite unique constraints help with lookups by(tenantId, id)but a standalonetenantIdindex would optimize list-all-for-tenant queries.+CREATE INDEX contact_tenant IF NOT EXISTS +FOR (c:Contact) ON (c.tenantId); + +CREATE INDEX company_tenant IF NOT EXISTS +FOR (co:Company) ON (co.tenantId); + +CREATE INDEX deal_tenant IF NOT EXISTS +FOR (d:Deal) ON (d.tenantId); + +CREATE INDEX pipeline_tenant IF NOT EXISTS +FOR (p:Pipeline) ON (p.tenantId); + // Core indexes CREATE INDEX contact_email IF NOT EXISTS FOR (c:Contact) ON (c.email);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/data/neo4j/schema.cypher` around lines 17 - 22, Add standalone indexes on tenantId for tenant-scoped queries: create index entries for the Contact and Deal labels (referencing symbols Contact, Deal and property tenantId) so list-all-for-tenant queries use the index rather than full label scans; update the schema where the existing CREATE INDEX statements are defined (near CREATE INDEX contact_email and CREATE INDEX deal_stage) to include corresponding CREATE INDEX contact_tenantId FOR (c:Contact) ON (c.tenantId) and CREATE INDEX deal_tenantId FOR (d:Deal) ON (d.tenantId), and apply the same pattern for any other tenant-scoped labels in the file.src/main.ts (1)
33-37: Debug diagnostics should not block server startup.If
DebugService.run()hangs or takes a long time (e.g., slow Neo4j queries), the server never starts listening. Consider running diagnostics afterserver.listenor with a timeout.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/main.ts` around lines 33 - 37, Move diagnostic invocation off the critical startup path so it cannot delay server.listen: do not await DebugService.run() before starting the server. Instead call debug.run() after server.listen (or start it concurrently with a bounded timeout using Promise.race), referencing DebugService and its run() method; if using a timeout, wrap debug.run() in a cancellable/timeout helper and log any results or timeout error but never block server startup. Ensure any existing handling of "issues" still logs them when/if run() completes.src/api/models/responses.ts (1)
1-11:ErrorResponseis already being used; focus on unusedApiResponse<T>.
ErrorResponseis imported and properly typed insrc/api/server.ts:18, so it's not dead code. However,ApiResponse<T>remains unused—thesend()function sends objects matching its structure but doesn't apply the type. Consider either usingApiResponse<T>to type the response payload (e.g., wrapping in the send function) or removing it to avoid confusion.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/api/models/responses.ts` around lines 1 - 11, The ApiResponse<T> type is declared but unused; update the response typing or remove it: either apply ApiResponse<T> to the payloads returned by your send() function (e.g., change the send() signature/return to use ApiResponse<T> and ensure objects conform to { data, meta? }) or delete the ApiResponse<T> declaration to avoid dead types; keep ErrorResponse as-is. Locate the ApiResponse<T> declaration and the send() function and make the types consistent (or remove ApiResponse<T> if you prefer not to type send() responses).src/domain/repositories/IContactRepository.ts (1)
4-7: Consider whetheridshould be included in thecreateinput type.The
createinput requires callers to supplyidsince onlytenantId | createdAt | updatedAtare omitted. If IDs are meant to be generated by the repository/service layer (which is the more common pattern), also omitid:- create(ctx: TenantContext, input: Omit<Contact, 'tenantId' | 'createdAt' | 'updatedAt'>): Promise<Contact>; + create(ctx: TenantContext, input: Omit<Contact, 'id' | 'tenantId' | 'createdAt' | 'updatedAt'>): Promise<Contact>;This applies consistently to all repository interfaces (
ICompanyRepository,IDealRepository,IPipelineRepository).If client-generated UUIDs are intentional, disregard.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/domain/repositories/IContactRepository.ts` around lines 4 - 7, The create signature on IContactRepository currently requires callers to provide id because the Omit only excludes 'tenantId'|'createdAt'|'updatedAt'; update the create input type to also omit 'id' so the repository generates IDs (change the Omit to exclude 'id' as well) and apply the same change to ICompanyRepository, IDealRepository and IPipelineRepository so their create methods likewise do not require caller-supplied IDs.src/domain/repositories/IActivityRepository.ts (1)
4-7: Interface looks good and follows the established repository pattern.Clean, minimal interface consistent with the other repository contracts (
IContactRepository,ICompanyRepository, etc.).One minor note: the
Omit<Activity, 'tenantId' | 'createdAt'>type still requiresidas a mandatory field, but theActivityRepositoryimplementation treats it as optional (input.id || randomUUID()). Consider usingOmit<Activity, 'id' | 'tenantId' | 'createdAt'> & { id?: string }if the intent is foridto be caller-optional. This applies to all repository interfaces in this PR.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/domain/repositories/IActivityRepository.ts` around lines 4 - 7, The create signature in IActivityRepository currently requires id but the implementation treats id as optional; update the input type for create to reflect caller-optional id by changing Omit<Activity, 'id' | 'tenantId' | 'createdAt'> to Omit<Activity, 'id' | 'tenantId' | 'createdAt'> & { id?: string } (modify the create method on IActivityRepository and apply the same pattern to other repository interfaces like IContactRepository/ICompanyRepository so implementations that do input.id || randomUUID() match the declared types).src/data/repositories/CompanyRepository.ts (1)
7-53: Repository implementations are nearly identical across entities — consider a generic base.
ContactRepositoryandCompanyRepositoryshare the same session lifecycle, MERGE-tenant + CREATE-entity pattern, and list logic. A genericBaseNeo4jRepository<T>could reduce duplication. This likely applies toDealRepository,ActivityRepository, andPipelineRepositoryas well.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/data/repositories/CompanyRepository.ts` around lines 7 - 53, The CompanyRepository and ContactRepository duplicate session lifecycle and CRUD patterns; factor them into a generic BaseNeo4jRepository<T> that encapsulates getSession/close, a createWithTenant(txPayload) helper implementing the MERGE(tenant)+CREATE(entity) pattern, and a listByTenant method returning mapped properties; then have CompanyRepository (and other repositories like ContactRepository, DealRepository, ActivityRepository, PipelineRepository) extend BaseNeo4jRepository<T> and implement only entity-specific mapping/shape (e.g., override entity label/name and provide the payload transform used by create and list), reusing Neo4jClient, TenantContext, create and list methods to remove duplication while preserving existing method signatures (create, list) on CompanyRepository.src/api/middleware/JwtAuth.ts (1)
31-31:replace('Bearer ', '')may strip an occurrence mid-string.If a maliciously crafted
Authorizationheader contains"Bearer "inside the token value,String.replaceonly strips the first occurrence, which is the prefix here — so it's fine in practice. However,authHeader.slice(7)is more explicit and avoids any ambiguity.Proposed fix
- const token = authHeader.replace('Bearer ', ''); + const token = authHeader.slice(7);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/api/middleware/JwtAuth.ts` at line 31, The current token extraction uses authHeader.replace('Bearer ', '') which can be ambiguous; change it to explicitly slice the bearer prefix by using authHeader.slice(7) (after confirming authHeader startsWith('Bearer ')) so the prefix is removed reliably; update the token assignment in JwtAuth.ts (the authHeader and token handling code) to use slice(7) and keep the existing validation/path that checks the header format.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/api/controllers/CRMController.ts`:
- Around line 20-23: The controller currently forwards unvalidated JSON payloads
(e.g., CreateContactRequest) from handleCreateContact to
this.service.createContact; add runtime validation/sanitization in CRMController
before calling service methods: define or import a schema (zod/ajv or a shared
assert helper) for CreateContactRequest, parse/validate the incoming payload in
handleCreateContact and return a 400 error on failure, then pass the
validated/sanitized value to this.service.createContact; apply the same pattern
to all other handleCreate* methods so only validated objects reach the
service/repository layer.
In `@src/api/middleware/JwtAuth.ts`:
- Around line 15-59: The JwtAuth implementation should enforce token expiration
to prevent minting tokens without exp and to ensure parseContext properly
validates it; modify the sign(payload: Omit<JwtPayload, 'iss'>) and
parseContext(req: IncomingMessage) logic so that sign requires an exp value
(reject or throw if payload.exp is missing or not a valid numeric timestamp) and
parseContext verifies payload.exp is present and is a number before comparing to
Date.now()/1000, using JwtAuth.sign and JwtAuth.parseContext (and the JwtPayload
type) as the locations to change; alternatively replace this custom logic with a
vetted JWT library (e.g., jose) to handle exp and other edge cases.
In `@src/api/server.ts`:
- Around line 5-9: parseJson currently reads the entire request body into memory
with no limit; add a configurable size cap (e.g., const MAX_BODY_SIZE = 1 * 1024
* 1024) and enforce it while streaming chunks: track total bytes read during the
for-await loop, and if total exceeds the cap, stop reading, optionally destroy
the IncomingMessage, and throw a clear error (e.g., new Error('Payload too
large') or a custom HttpError) so callers can return 413; update
parseJson<T>(req: IncomingMessage) to perform this check before
buffering/concatenating and accept an optional limit parameter if desired.
- Around line 77-79: The catch-all currently calls sendError(res, 500, error as
Error) and may leak sensitive internals; change the handler around the try/catch
in server.ts to log the full error server-side (e.g., using logger.error(error)
or similar) and call sendError with a sanitized Error or a generic message
(e.g., new Error("Internal server error") or a message constant) so only
non-sensitive information is returned to clients; update any tests or callers of
sendError if they expect the original message.
- Around line 71-74: The GET /deals route matching is fragile; replace the
startsWith check with parsing via the URL class: first construct new
URL(req.url, `http://${req.headers.host}`) (or equivalent), ensure req.method
=== 'GET' and parsedUrl.pathname === '/deals', then read pipelineId =
parsedUrl.searchParams.get('pipelineId') and call
controller.handleListDeals(req, pipelineId) via send(res, 200, ...); if
pipelineId is missing return a 400 explaining it is required. Update the block
referencing req.url, controller.handleListDeals, and send to use parsedUrl and
proper validation.
In `@src/cli/createTenant.ts`:
- Around line 12-16: The MERGE + SET in the tx.run call (inside
session.executeWrite) overwrites immutable createdAt on every run; change the
query to use ON CREATE SET for t.createdAt (and set t.name on both create and
match as appropriate), e.g. keep MERGE (t:Tenant {id: $tenantId}) then use ON
CREATE SET t.createdAt = datetime().toString(), t.name = $tenantName and ON
MATCH SET t.name = $tenantName so createdAt is only set once while name remains
updatable; update the tx.run invocation accordingly.
In `@src/config/env.ts`:
- Around line 11-21: loadConfig currently supplies insecure defaults for
jwtSecret and neo4jPassword and uses Number(process.env.PORT ?? 8080) which can
yield NaN; update loadConfig to (1) require an explicit JWT secret when nodeEnv
=== 'production' (throw an error if process.env.JWT_SECRET is missing or equals
the insecure default) instead of falling back to 'change_me' and similarly
require process.env.NEO4J_PASSWORD in production (throw if missing), (2) stop
using Number(...) directly—parse and validate process.env.PORT (e.g., parseInt
and isFinite check) and fall back to 8080 or throw on invalid values, and (3)
remove the hardcoded insecure defaults for jwtSecret and neo4jPassword so
production safety is enforced while allowing sensible non-production fallbacks.
In `@src/data/repositories/ActivityRepository.ts`:
- Around line 10-36: The Cypher parameters ($notes, $dueAt, $relatedContactId,
$relatedDealId) are missing when optional properties are omitted, causing
neo4j-driver errors; update ActivityRepository.create to ensure the activity
params always include those keys by defaulting notes, dueAt, relatedContactId,
and relatedDealId to null (or explicit values) when building the activity object
so the params passed to session.executeWrite always contain the referenced keys,
and apply the same fix in DealRepository.create for companyId and
ownerContactId.
In `@src/data/repositories/CompanyRepository.ts`:
- Around line 22-35: The CompanyRepository uses a CREATE for the Company node
inside the transaction (see the session.executeWrite / tx.run block that
currently runs `CREATE (co:Company { ... })`), which permits duplicate Company
nodes on retries; change this to use MERGE on the unique key (e.g., MERGE
(co:Company {id: $id}) and then use ON CREATE SET to populate the other
properties and ON MATCH SET to update updatedAt as needed) or ensure a
uniqueness constraint exists for Company.id before relying on CREATE; update the
tx.run Cypher accordingly and keep the MERGE relationship from Tenant unchanged
(MERGE (t)-[:OWNS]->(co)).
In `@src/data/repositories/ContactRepository.ts`:
- Around line 22-37: The current write in ContactRepository uses CREATE for the
Contact node which allows duplicates; change the tx.run Cypher to MERGE the
contact by id (e.g., MERGE (c:Contact {id: $id})) and move initial property
assignments into ON CREATE SET (and update mutable fields with ON MATCH SET or
separate SET) instead of CREATE; update the MERGE that links tenant to contact
to reuse the merged c node; additionally ensure a database uniqueness constraint
exists for Contact.id (CREATE CONSTRAINT FOR (c:Contact) REQUIRE c.id IS UNIQUE)
to prevent duplicates at the DB level.
In `@src/data/repositories/DealRepository.ts`:
- Around line 10-19: The create method builds a Deal object but does not ensure
optional keys companyId and ownerContactId exist, causing missing-parameter
errors when the Cypher query references $companyId/$ownerContactId; update the
create(ctx, input) implementation (the create function in DealRepository) to set
companyId and ownerContactId to null when absent (e.g., include companyId:
input.companyId ?? null and ownerContactId: input.ownerContactId ?? null in the
deal/params object) and pass that params object (not the raw deal) into
tx.run(...) so the driver always receives those parameters.
- Around line 21-42: The current create flow in DealRepository uses
session.executeWrite(tx.run(...)) but ignores the query result so if the MATCH
for Pipeline finds no node the CREATE and MERGE never run and the method returns
a non-persisted deal; change the code to capture the result from tx.run inside
session.executeWrite, then after the write completes inspect the result summary
(e.g., result.summary.counters.nodesCreated() / relationshipsCreated() or other
counters) or check result.records to detect zero updates, and if no
nodes/relationships were created throw a specific error like "Pipeline not
found" instead of returning the deal; keep the session.close() in the finally
block and reference the existing session.executeWrite and tx.run call sites when
making this change.
- Around line 45-57: The repository currently returns raw Neo4j records in
DealRepository.listByPipeline causing numeric fields (e.g., Deal.value) to
remain neo4j.Integer objects; update listByPipeline to convert numeric fields to
native JS numbers before returning (e.g., detect neo4j.Integer via
neo4j.integer.isInteger(...) and call .toNumber() on Deal.value and any other
integer fields), or alternatively configure the Neo4j driver at creation with {
disableLosslessIntegers: true } so integers are returned as native numbers;
apply the chosen fix in listByPipeline (and other repository methods that map
record.get('d').properties) to ensure Deal.value is a true number.
In `@src/debug/DebugService.ts`:
- Around line 26-37: The Cypher count is returned as a neo4j Integer, so update
DebugService to import neo4j from 'neo4j-driver' and convert the count using
neo4j.integer.toNumber (with a safe fallback) instead of Number(...);
specifically, when handling the result of session.run in the method that calls
`MATCH (d:Deal) ... RETURN count(d) AS broken`, retrieve the record value via
`result.records[0]?.get('broken')`, convert it with
`neo4j.integer.toNumber(...)` (or check `neo4j.integer.inSafeRange` then
toNumber) and fall back to 0 if undefined/unsafe, then use that numeric `broken`
in the `if (broken > 0)` check and issue push.
In `@src/debug/syntheticDebugPass.ts`:
- Around line 5-21: The IIFE around loadConfig(), new Neo4jClient(config),
DebugService and debug.run currently has no error handling so a thrown error
will skip neo4j.close() and leak resources; wrap the await debug.run() and
subsequent logging in a try/catch/finally (or attach a .catch() and .finally()
to the async IIFE) so that any error from debug.run() is caught and logged
(include the error), and ensure neo4j.close() is always invoked in the finally
block; reference the functions/classes loadConfig, Neo4jClient, DebugService,
debug.run and neo4j.close when making the change.
In `@src/main.ts`:
- Around line 43-49: Wrap the callback-based server.close into a Promise (e.g.,
create a helper like closeServerPromise that calls server.close and
resolves/rejects on its callback), then await that promise before calling
neo4j.close() inside the existing async shutdown function so in-flight requests
finish first; ensure the shutdown handler attached to
process.on('SIGINT'|'SIGTERM') invokes shutdown() and handles rejections (call
shutdown().catch(err => { log error; process.exit(1); })) and finally call
process.exit(0) on success (and use a forced fallback timer — e.g., setTimeout
to process.exit(1) after a short timeout — cleared on successful shutdown) so
the process cannot hang.
---
Nitpick comments:
In `@package.json`:
- Line 12: The package.json "test" script uses a shell glob and .js test
extension which fails on Windows and with TypeScript sources; update the "test"
script (the "test" npm script) to use a cross-platform approach and run
TypeScript tests (e.g., use a Node/runner that supports globs and TS like tsx or
a Node test runner with a TS loader) so tests can run without a separate build
and Windows shell expansion; ensure the pattern targets .test.ts files (or use a
runner option that accepts globs) and document the change in package.json
scripts.
In `@src/analytics/GraphAnalyticsService.ts`:
- Around line 1-5: Extract the magic weights in
GraphAnalyticsService.scoreLeadRelationship into named constants (e.g.,
SIGNAL_WEIGHT = 0.6 and RECENCY_WEIGHT = 0.4) and use them in the formula for
clarity and easy tuning; additionally add a basic guard at the start of
scoreLeadRelationship to handle non-finite inputs (signalCount or recencyScore)
by returning a safe default (e.g., 0) or clamping/validating inputs before
computing, and ensure the final result is still rounded to two decimals.
In `@src/api/middleware/JwtAuth.ts`:
- Line 31: The current token extraction uses authHeader.replace('Bearer ', '')
which can be ambiguous; change it to explicitly slice the bearer prefix by using
authHeader.slice(7) (after confirming authHeader startsWith('Bearer ')) so the
prefix is removed reliably; update the token assignment in JwtAuth.ts (the
authHeader and token handling code) to use slice(7) and keep the existing
validation/path that checks the header format.
In `@src/api/models/responses.ts`:
- Around line 1-11: The ApiResponse<T> type is declared but unused; update the
response typing or remove it: either apply ApiResponse<T> to the payloads
returned by your send() function (e.g., change the send() signature/return to
use ApiResponse<T> and ensure objects conform to { data, meta? }) or delete the
ApiResponse<T> declaration to avoid dead types; keep ErrorResponse as-is. Locate
the ApiResponse<T> declaration and the send() function and make the types
consistent (or remove ApiResponse<T> if you prefer not to type send()
responses).
In `@src/data/neo4j/schema.cypher`:
- Around line 17-22: Add standalone indexes on tenantId for tenant-scoped
queries: create index entries for the Contact and Deal labels (referencing
symbols Contact, Deal and property tenantId) so list-all-for-tenant queries use
the index rather than full label scans; update the schema where the existing
CREATE INDEX statements are defined (near CREATE INDEX contact_email and CREATE
INDEX deal_stage) to include corresponding CREATE INDEX contact_tenantId FOR
(c:Contact) ON (c.tenantId) and CREATE INDEX deal_tenantId FOR (d:Deal) ON
(d.tenantId), and apply the same pattern for any other tenant-scoped labels in
the file.
In `@src/data/repositories/CompanyRepository.ts`:
- Around line 7-53: The CompanyRepository and ContactRepository duplicate
session lifecycle and CRUD patterns; factor them into a generic
BaseNeo4jRepository<T> that encapsulates getSession/close, a
createWithTenant(txPayload) helper implementing the MERGE(tenant)+CREATE(entity)
pattern, and a listByTenant method returning mapped properties; then have
CompanyRepository (and other repositories like ContactRepository,
DealRepository, ActivityRepository, PipelineRepository) extend
BaseNeo4jRepository<T> and implement only entity-specific mapping/shape (e.g.,
override entity label/name and provide the payload transform used by create and
list), reusing Neo4jClient, TenantContext, create and list methods to remove
duplication while preserving existing method signatures (create, list) on
CompanyRepository.
In `@src/domain/repositories/IActivityRepository.ts`:
- Around line 4-7: The create signature in IActivityRepository currently
requires id but the implementation treats id as optional; update the input type
for create to reflect caller-optional id by changing Omit<Activity, 'id' |
'tenantId' | 'createdAt'> to Omit<Activity, 'id' | 'tenantId' | 'createdAt'> & {
id?: string } (modify the create method on IActivityRepository and apply the
same pattern to other repository interfaces like
IContactRepository/ICompanyRepository so implementations that do input.id ||
randomUUID() match the declared types).
In `@src/domain/repositories/IContactRepository.ts`:
- Around line 4-7: The create signature on IContactRepository currently requires
callers to provide id because the Omit only excludes
'tenantId'|'createdAt'|'updatedAt'; update the create input type to also omit
'id' so the repository generates IDs (change the Omit to exclude 'id' as well)
and apply the same change to ICompanyRepository, IDealRepository and
IPipelineRepository so their create methods likewise do not require
caller-supplied IDs.
In `@src/main.ts`:
- Around line 33-37: Move diagnostic invocation off the critical startup path so
it cannot delay server.listen: do not await DebugService.run() before starting
the server. Instead call debug.run() after server.listen (or start it
concurrently with a bounded timeout using Promise.race), referencing
DebugService and its run() method; if using a timeout, wrap debug.run() in a
cancellable/timeout helper and log any results or timeout error but never block
server startup. Ensure any existing handling of "issues" still logs them when/if
run() completes.
In `@tests/api.test.js`:
- Around line 1-6: The test file tests/api.test.js currently contains a no-op
placeholder test ('test("api placeholder")') that should not remain as-is;
replace it with either a skipped test (e.g., using test.skip) or remove it and
create a tracked follow-up (issue/PR/TODO) to add real API integration tests
covering controller routing, JWT middleware rejection of unsigned/invalid
tokens, and tenant isolation; ensure the follow-up references the placeholder
test so it can be implemented later and include which behaviors need asserting
(routing, auth middleware, multi-tenant separation).
| async handleCreateContact(req: IncomingMessage, payload: CreateContactRequest) { | ||
| const ctx = this.auth.parseContext(req); | ||
| return this.service.createContact(ctx, payload); | ||
| } |
There was a problem hiding this comment.
No runtime validation on request payloads before passing to the service/repository layer.
TypeScript interfaces are erased at runtime. The payload parameters (e.g., CreateContactRequest) coming from HTTP request bodies are untrusted JSON — any extra or missing fields, wrong types, or injection payloads pass straight through to the Cypher queries. Add a validation layer (e.g., zod, ajv, or a simple assertion helper) to sanitize inputs before they reach the service.
This applies to all handleCreate* methods.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/api/controllers/CRMController.ts` around lines 20 - 23, The controller
currently forwards unvalidated JSON payloads (e.g., CreateContactRequest) from
handleCreateContact to this.service.createContact; add runtime
validation/sanitization in CRMController before calling service methods: define
or import a schema (zod/ajv or a shared assert helper) for CreateContactRequest,
parse/validate the incoming payload in handleCreateContact and return a 400
error on failure, then pass the validated/sanitized value to
this.service.createContact; apply the same pattern to all other handleCreate*
methods so only validated objects reach the service/repository layer.
| export class JwtAuth { | ||
| constructor(private readonly issuer: string, private readonly secret: string) {} | ||
|
|
||
| sign(payload: Omit<JwtPayload, 'iss'>): string { | ||
| const header = toBase64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); | ||
| const body = toBase64Url(JSON.stringify({ ...payload, iss: this.issuer })); | ||
| const signature = this.signPart(`${header}.${body}`); | ||
| return `${header}.${body}.${signature}`; | ||
| } | ||
|
|
||
| parseContext(req: IncomingMessage): TenantContext { | ||
| const authHeader = req.headers.authorization; | ||
| if (!authHeader?.startsWith('Bearer ')) { | ||
| throw new Error('Missing Bearer token'); | ||
| } | ||
|
|
||
| const token = authHeader.replace('Bearer ', ''); | ||
| const [header, body, signature] = token.split('.'); | ||
| if (!header || !body || !signature) { | ||
| throw new Error('Invalid JWT format'); | ||
| } | ||
|
|
||
| const expected = this.signPart(`${header}.${body}`); | ||
| if (signature.length !== expected.length) { | ||
| throw new Error('JWT signature mismatch'); | ||
| } | ||
|
|
||
| if (!timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { | ||
| throw new Error('JWT signature mismatch'); | ||
| } | ||
|
|
||
| const payload = JSON.parse(Buffer.from(body, 'base64url').toString()) as JwtPayload; | ||
| if (payload.iss !== this.issuer) { | ||
| throw new Error('Unexpected token issuer'); | ||
| } | ||
| if (payload.exp < Math.floor(Date.now() / 1000)) { | ||
| throw new Error('Token expired'); | ||
| } | ||
|
|
||
| return { tenantId: payload.tenantId, userId: payload.sub, roles: payload.roles }; | ||
| } | ||
|
|
||
| private signPart(value: string): string { | ||
| return createHmac('sha256', this.secret).update(value).digest('base64url'); | ||
| } |
There was a problem hiding this comment.
Consider using a well-tested JWT library instead of rolling your own.
This hand-rolled JWT implementation is functional, but custom crypto code is a common source of subtle security bugs. A library like jose (zero-dependency, maintained) handles edge cases such as algorithm confusion attacks, clock skew tolerance, and JWK support out of the box.
If you keep the custom implementation, note that the sign method's return type doesn't enforce that callers set exp, so it's possible to mint tokens that never expire if exp is omitted (since 0 < Date.now()/1000 is always true, an exp: 0 would be "expired", but undefined < number is false so an omitted exp would bypass the expiration check on line 50).
Proposed minimal fix for the exp bypass
const payload = JSON.parse(Buffer.from(body, 'base64url').toString()) as JwtPayload;
if (payload.iss !== this.issuer) {
throw new Error('Unexpected token issuer');
}
- if (payload.exp < Math.floor(Date.now() / 1000)) {
+ if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error('Token expired');
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export class JwtAuth { | |
| constructor(private readonly issuer: string, private readonly secret: string) {} | |
| sign(payload: Omit<JwtPayload, 'iss'>): string { | |
| const header = toBase64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); | |
| const body = toBase64Url(JSON.stringify({ ...payload, iss: this.issuer })); | |
| const signature = this.signPart(`${header}.${body}`); | |
| return `${header}.${body}.${signature}`; | |
| } | |
| parseContext(req: IncomingMessage): TenantContext { | |
| const authHeader = req.headers.authorization; | |
| if (!authHeader?.startsWith('Bearer ')) { | |
| throw new Error('Missing Bearer token'); | |
| } | |
| const token = authHeader.replace('Bearer ', ''); | |
| const [header, body, signature] = token.split('.'); | |
| if (!header || !body || !signature) { | |
| throw new Error('Invalid JWT format'); | |
| } | |
| const expected = this.signPart(`${header}.${body}`); | |
| if (signature.length !== expected.length) { | |
| throw new Error('JWT signature mismatch'); | |
| } | |
| if (!timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { | |
| throw new Error('JWT signature mismatch'); | |
| } | |
| const payload = JSON.parse(Buffer.from(body, 'base64url').toString()) as JwtPayload; | |
| if (payload.iss !== this.issuer) { | |
| throw new Error('Unexpected token issuer'); | |
| } | |
| if (payload.exp < Math.floor(Date.now() / 1000)) { | |
| throw new Error('Token expired'); | |
| } | |
| return { tenantId: payload.tenantId, userId: payload.sub, roles: payload.roles }; | |
| } | |
| private signPart(value: string): string { | |
| return createHmac('sha256', this.secret).update(value).digest('base64url'); | |
| } | |
| export class JwtAuth { | |
| constructor(private readonly issuer: string, private readonly secret: string) {} | |
| sign(payload: Omit<JwtPayload, 'iss'>): string { | |
| const header = toBase64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); | |
| const body = toBase64Url(JSON.stringify({ ...payload, iss: this.issuer })); | |
| const signature = this.signPart(`${header}.${body}`); | |
| return `${header}.${body}.${signature}`; | |
| } | |
| parseContext(req: IncomingMessage): TenantContext { | |
| const authHeader = req.headers.authorization; | |
| if (!authHeader?.startsWith('Bearer ')) { | |
| throw new Error('Missing Bearer token'); | |
| } | |
| const token = authHeader.replace('Bearer ', ''); | |
| const [header, body, signature] = token.split('.'); | |
| if (!header || !body || !signature) { | |
| throw new Error('Invalid JWT format'); | |
| } | |
| const expected = this.signPart(`${header}.${body}`); | |
| if (signature.length !== expected.length) { | |
| throw new Error('JWT signature mismatch'); | |
| } | |
| if (!timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { | |
| throw new Error('JWT signature mismatch'); | |
| } | |
| const payload = JSON.parse(Buffer.from(body, 'base64url').toString()) as JwtPayload; | |
| if (payload.iss !== this.issuer) { | |
| throw new Error('Unexpected token issuer'); | |
| } | |
| if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) { | |
| throw new Error('Token expired'); | |
| } | |
| return { tenantId: payload.tenantId, userId: payload.sub, roles: payload.roles }; | |
| } | |
| private signPart(value: string): string { | |
| return createHmac('sha256', this.secret).update(value).digest('base64url'); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/api/middleware/JwtAuth.ts` around lines 15 - 59, The JwtAuth
implementation should enforce token expiration to prevent minting tokens without
exp and to ensure parseContext properly validates it; modify the sign(payload:
Omit<JwtPayload, 'iss'>) and parseContext(req: IncomingMessage) logic so that
sign requires an exp value (reject or throw if payload.exp is missing or not a
valid numeric timestamp) and parseContext verifies payload.exp is present and is
a number before comparing to Date.now()/1000, using JwtAuth.sign and
JwtAuth.parseContext (and the JwtPayload type) as the locations to change;
alternatively replace this custom logic with a vetted JWT library (e.g., jose)
to handle exp and other edge cases.
| async function parseJson<T>(req: IncomingMessage): Promise<T> { | ||
| const chunks: Buffer[] = []; | ||
| for await (const chunk of req) chunks.push(Buffer.from(chunk)); | ||
| return JSON.parse(Buffer.concat(chunks).toString() || '{}') as T; | ||
| } |
There was a problem hiding this comment.
No body size limit — potential denial-of-service vector.
parseJson reads the entire request body into memory with no upper bound. A malicious client can send an arbitrarily large payload to exhaust server memory.
Proposed fix: add a size cap
-async function parseJson<T>(req: IncomingMessage): Promise<T> {
- const chunks: Buffer[] = [];
- for await (const chunk of req) chunks.push(Buffer.from(chunk));
- return JSON.parse(Buffer.concat(chunks).toString() || '{}') as T;
+const MAX_BODY_BYTES = 1_048_576; // 1 MB
+
+async function parseJson<T>(req: IncomingMessage): Promise<T> {
+ const chunks: Buffer[] = [];
+ let totalLength = 0;
+ for await (const chunk of req) {
+ totalLength += chunk.length;
+ if (totalLength > MAX_BODY_BYTES) throw new Error('Request body too large');
+ chunks.push(Buffer.from(chunk));
+ }
+ return JSON.parse(Buffer.concat(chunks).toString() || '{}') as T;
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/api/server.ts` around lines 5 - 9, parseJson currently reads the entire
request body into memory with no limit; add a configurable size cap (e.g., const
MAX_BODY_SIZE = 1 * 1024 * 1024) and enforce it while streaming chunks: track
total bytes read during the for-await loop, and if total exceeds the cap, stop
reading, optionally destroy the IncomingMessage, and throw a clear error (e.g.,
new Error('Payload too large') or a custom HttpError) so callers can return 413;
update parseJson<T>(req: IncomingMessage) to perform this check before
buffering/concatenating and accept an optional limit parameter if desired.
| if (req.method === 'GET' && req.url.startsWith('/deals?pipelineId=')) { | ||
| const pipelineId = decodeURIComponent(req.url.replace('/deals?pipelineId=', '')); | ||
| return send(res, 200, await controller.handleListDeals(req, pipelineId)); | ||
| } |
There was a problem hiding this comment.
Fragile URL matching — use URL parsing instead of startsWith.
req.url.startsWith('/deals?pipelineId=') will fail if query parameters arrive in a different order (e.g., /deals?foo=1&pipelineId=abc) or if pipelineId is not the first parameter. Use the built-in URL class to reliably extract query parameters.
Additionally, this route is checked after the POST /deals route but there's no GET /deals route — a GET /deals request without ?pipelineId= will fall through to 404. If that's intentional, consider returning a 400 explaining that pipelineId is required.
Proposed fix using URL parsing
- if (req.method === 'GET' && req.url.startsWith('/deals?pipelineId=')) {
- const pipelineId = decodeURIComponent(req.url.replace('/deals?pipelineId=', ''));
- return send(res, 200, await controller.handleListDeals(req, pipelineId));
+ const parsedUrl = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
+
+ if (req.method === 'GET' && parsedUrl.pathname === '/deals') {
+ const pipelineId = parsedUrl.searchParams.get('pipelineId');
+ if (!pipelineId) return sendError(res, 400, new Error('pipelineId query parameter is required'));
+ return send(res, 200, await controller.handleListDeals(req, pipelineId));
}Note: If you move URL parsing up to the top of the handler, you can also replace the exact req.url === '/contacts' checks with parsedUrl.pathname === '/contacts' for consistency and to correctly handle cases where query strings are appended.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (req.method === 'GET' && req.url.startsWith('/deals?pipelineId=')) { | |
| const pipelineId = decodeURIComponent(req.url.replace('/deals?pipelineId=', '')); | |
| return send(res, 200, await controller.handleListDeals(req, pipelineId)); | |
| } | |
| const parsedUrl = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`); | |
| if (req.method === 'GET' && parsedUrl.pathname === '/deals') { | |
| const pipelineId = parsedUrl.searchParams.get('pipelineId'); | |
| if (!pipelineId) return sendError(res, 400, new Error('pipelineId query parameter is required')); | |
| return send(res, 200, await controller.handleListDeals(req, pipelineId)); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/api/server.ts` around lines 71 - 74, The GET /deals route matching is
fragile; replace the startsWith check with parsing via the URL class: first
construct new URL(req.url, `http://${req.headers.host}`) (or equivalent), ensure
req.method === 'GET' and parsedUrl.pathname === '/deals', then read pipelineId =
parsedUrl.searchParams.get('pipelineId') and call
controller.handleListDeals(req, pipelineId) via send(res, 200, ...); if
pipelineId is missing return a 400 explaining it is required. Update the block
referencing req.url, controller.handleListDeals, and send to use parsedUrl and
proper validation.
| } catch (error) { | ||
| sendError(res, 500, error as Error); | ||
| } |
There was a problem hiding this comment.
Error details leaked to clients in 500 responses.
The catch-all sends error.name and error.message (which can include Neo4j connection strings, Cypher details, or stack information) directly to the client. In production, internal error details should be masked.
Proposed fix
} catch (error) {
- sendError(res, 500, error as Error);
+ // Log the real error server-side
+ console.error('Unhandled request error', error);
+ sendError(res, 500, new Error('Internal server error'));
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| } catch (error) { | |
| sendError(res, 500, error as Error); | |
| } | |
| } catch (error) { | |
| // Log the real error server-side | |
| console.error('Unhandled request error', error); | |
| sendError(res, 500, new Error('Internal server error')); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/api/server.ts` around lines 77 - 79, The catch-all currently calls
sendError(res, 500, error as Error) and may leak sensitive internals; change the
handler around the try/catch in server.ts to log the full error server-side
(e.g., using logger.error(error) or similar) and call sendError with a sanitized
Error or a generic message (e.g., new Error("Internal server error") or a
message constant) so only non-sensitive information is returned to clients;
update any tests or callers of sendError if they expect the original message.
| try { | ||
| await session.executeWrite(tx => tx.run( | ||
| `MATCH (p:Pipeline {id: $pipelineId, tenantId: $tenantId}) | ||
| CREATE (d:Deal { | ||
| id: $id, | ||
| tenantId: $tenantId, | ||
| title: $title, | ||
| value: $value, | ||
| pipelineId: $pipelineId, | ||
| stage: $stage, | ||
| companyId: $companyId, | ||
| ownerContactId: $ownerContactId, | ||
| createdAt: $createdAt, | ||
| updatedAt: $updatedAt | ||
| }) | ||
| MERGE (p)-[:CONTAINS]->(d)`, | ||
| deal | ||
| )); | ||
| return deal; | ||
| } finally { | ||
| await session.close(); | ||
| } |
There was a problem hiding this comment.
Silent failure when the referenced Pipeline does not exist.
The MATCH (p:Pipeline {id: $pipelineId, tenantId: $tenantId}) will return zero rows if the pipeline doesn't exist, which means the CREATE (d:Deal ...) and MERGE (p)-[:CONTAINS]->(d) are never executed. The method then returns the deal object at line 39 as if it were persisted — a phantom record that doesn't exist in the database.
Either check the query result and throw if no rows were affected, or use a pattern that fails explicitly.
Proposed fix: verify the write actually occurred
try {
- await session.executeWrite(tx => tx.run(
+ const result = await session.executeWrite(tx => tx.run(
`MATCH (p:Pipeline {id: $pipelineId, tenantId: $tenantId})
CREATE (d:Deal {
id: $id,
tenantId: $tenantId,
title: $title,
value: $value,
pipelineId: $pipelineId,
stage: $stage,
companyId: $companyId,
ownerContactId: $ownerContactId,
createdAt: $createdAt,
updatedAt: $updatedAt
})
MERGE (p)-[:CONTAINS]->(d)`,
deal
));
+ if (result.summary.counters.updates().nodesCreated === 0) {
+ throw new Error(`Pipeline ${deal.pipelineId} not found for tenant ${deal.tenantId}`);
+ }
return deal;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| try { | |
| await session.executeWrite(tx => tx.run( | |
| `MATCH (p:Pipeline {id: $pipelineId, tenantId: $tenantId}) | |
| CREATE (d:Deal { | |
| id: $id, | |
| tenantId: $tenantId, | |
| title: $title, | |
| value: $value, | |
| pipelineId: $pipelineId, | |
| stage: $stage, | |
| companyId: $companyId, | |
| ownerContactId: $ownerContactId, | |
| createdAt: $createdAt, | |
| updatedAt: $updatedAt | |
| }) | |
| MERGE (p)-[:CONTAINS]->(d)`, | |
| deal | |
| )); | |
| return deal; | |
| } finally { | |
| await session.close(); | |
| } | |
| try { | |
| const result = await session.executeWrite(tx => tx.run( | |
| `MATCH (p:Pipeline {id: $pipelineId, tenantId: $tenantId}) | |
| CREATE (d:Deal { | |
| id: $id, | |
| tenantId: $tenantId, | |
| title: $title, | |
| value: $value, | |
| pipelineId: $pipelineId, | |
| stage: $stage, | |
| companyId: $companyId, | |
| ownerContactId: $ownerContactId, | |
| createdAt: $createdAt, | |
| updatedAt: $updatedAt | |
| }) | |
| MERGE (p)-[:CONTAINS]->(d)`, | |
| deal | |
| )); | |
| if (result.summary.counters.nodesCreated() === 0) { | |
| throw new Error(`Pipeline ${deal.pipelineId} not found for tenant ${deal.tenantId}`); | |
| } | |
| return deal; | |
| } finally { | |
| await session.close(); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/data/repositories/DealRepository.ts` around lines 21 - 42, The current
create flow in DealRepository uses session.executeWrite(tx.run(...)) but ignores
the query result so if the MATCH for Pipeline finds no node the CREATE and MERGE
never run and the method returns a non-persisted deal; change the code to
capture the result from tx.run inside session.executeWrite, then after the write
completes inspect the result summary (e.g.,
result.summary.counters.nodesCreated() / relationshipsCreated() or other
counters) or check result.records to detect zero updates, and if no
nodes/relationships were created throw a specific error like "Pipeline not
found" instead of returning the deal; keep the session.close() in the finally
block and reference the existing session.executeWrite and tx.run call sites when
making this change.
| async listByPipeline(ctx: TenantContext, pipelineId: string): Promise<Deal[]> { | ||
| const session = this.neo4j.getSession(); | ||
| try { | ||
| const result = await session.executeRead(tx => tx.run( | ||
| `MATCH (d:Deal {tenantId: $tenantId, pipelineId: $pipelineId}) | ||
| RETURN d ORDER BY d.createdAt DESC`, | ||
| { tenantId: ctx.tenantId, pipelineId } | ||
| )); | ||
| return result.records.map(record => record.get('d').properties as Deal); | ||
| } finally { | ||
| await session.close(); | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Check if the driver is configured with disableLosslessIntegers anywhere
rg -n 'disableLosslessIntegers' --type=tsRepository: PicoPlus/PicoCRM
Length of output: 42
🏁 Script executed:
# Check for integer conversion patterns
rg -n 'toNumber|toInt|\.toNumber()' --type=ts -A 2Repository: PicoPlus/PicoCRM
Length of output: 42
🏁 Script executed:
# Find the Neo4j driver initialization/configuration
rg -n 'neo4j\.driver|createDriver|getSession' --type=ts -B 2 -A 2Repository: PicoPlus/PicoCRM
Length of output: 5344
🏁 Script executed:
# Check the Deal type definition
fd -e ts | xargs rg -l 'interface Deal|type Deal' | head -5Repository: PicoPlus/PicoCRM
Length of output: 87
🏁 Script executed:
# Look at DealRepository.ts to understand the full context
cat -n src/data/repositories/DealRepository.ts | head -80Repository: PicoPlus/PicoCRM
Length of output: 2359
🏁 Script executed:
# Read the Deal type definition
cat -n src/domain/entities/Deal.tsRepository: PicoPlus/PicoCRM
Length of output: 367
🏁 Script executed:
# Check other repository methods for similar patterns
cat -n src/data/repositories/ContactRepository.ts | head -60Repository: PicoPlus/PicoCRM
Length of output: 2248
🏁 Script executed:
# Check Neo4jClient complete file
cat -n src/data/neo4j/Neo4jClient.tsRepository: PicoPlus/PicoCRM
Length of output: 801
🏁 Script executed:
# Check if there's any other Neo4j configuration or driver setup
rg -n 'neo4j\.' --type=ts | grep -E 'config|Config|driver|Driver|session|Session' | head -20Repository: PicoPlus/PicoCRM
Length of output: 1270
Neo4j Integer type returns non-native numbers without conversion.
The Deal.value field is typed as number, but neo4j-driver returns neo4j.Integer objects for numeric values stored in Neo4j. The type cast as Deal at line 53 is compile-time only and doesn't perform runtime conversion—deal.value will be an Integer object, not a JS number, breaking downstream arithmetic, serialization, or strict equality checks.
The Neo4jClient is initialized without disableLosslessIntegers (lines 8–10), which means all numeric fields across the repository layer are affected.
Either configure the driver with { disableLosslessIntegers: true } when creating the driver, or explicitly convert integer fields using .toNumber() on retrieval.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/data/repositories/DealRepository.ts` around lines 45 - 57, The repository
currently returns raw Neo4j records in DealRepository.listByPipeline causing
numeric fields (e.g., Deal.value) to remain neo4j.Integer objects; update
listByPipeline to convert numeric fields to native JS numbers before returning
(e.g., detect neo4j.Integer via neo4j.integer.isInteger(...) and call
.toNumber() on Deal.value and any other integer fields), or alternatively
configure the Neo4j driver at creation with { disableLosslessIntegers: true } so
integers are returned as native numbers; apply the chosen fix in listByPipeline
(and other repository methods that map record.get('d').properties) to ensure
Deal.value is a true number.
| try { | ||
| const result = await session.run( | ||
| `MATCH (d:Deal) WHERE d.pipelineId IS NULL RETURN count(d) AS broken` | ||
| ); | ||
| const broken = Number(result.records[0]?.get('broken') ?? 0); | ||
| if (broken > 0) { | ||
| issues.push({ | ||
| category: 'RELATIONSHIP', | ||
| severity: 'MEDIUM', | ||
| message: `${broken} deals are missing pipeline relationships.` | ||
| }); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
neo4j-driver javascript Integer Number() conversion NaN
💡 Result:
NaN happens because Neo4j’s JavaScript driver returns Cypher INTEGER values as a custom Integer object (not a native JS number), so Number(x)/+x may not coerce it correctly (depending on driver version). Use the driver’s conversion helpers instead. (neo4j.com)
import neo4j from "neo4j-driver";
const v = record.get("age"); // Cypher INTEGER -> neo4j Integer
if (neo4j.integer.inSafeRange(v)) {
const n = v.toNumber(); // or: neo4j.integer.toNumber(v)
} else {
const s = v.toString(); // or: v.toBigInt() (newer drivers)
}- Use
toInt()only if you know it fits a 32‑bit integer. (neo4j.com) - If you want the driver to return native JS numbers everywhere (with possible precision loss), create the driver with
disableLosslessIntegers: true. (neo4j.com)
Citations:
- 1: https://neo4j.com/docs/api/javascript-driver/current/?utm_source=openai
- 2: https://neo4j.com/docs/api/javascript-driver/current/class/lib6/integer.js~Integer.html?utm_source=openai
- 3: https://neo4j.com/docs/api/javascript-driver/current/?utm_source=openai
🏁 Script executed:
# First, check if the file exists and view the relevant section
if [ -f "src/debug/DebugService.ts" ]; then
echo "=== Lines 20-40 of DebugService.ts ==="
sed -n '20,40p' src/debug/DebugService.ts | cat -n
fiRepository: PicoPlus/PicoCRM
Length of output: 866
🏁 Script executed:
# Search for how neo4j Integer values are handled elsewhere in the codebase
rg "neo4j\." --type ts --type tsx -A 2 -B 2 | head -100Repository: PicoPlus/PicoCRM
Length of output: 87
🏁 Script executed:
# Search specifically for patterns with result.records and .get()
rg "result\.records\[" --type ts --type tsx -B 2 -A 2Repository: PicoPlus/PicoCRM
Length of output: 87
🏁 Script executed:
# Check imports at the top of DebugService.ts
head -20 src/debug/DebugService.ts | cat -nRepository: PicoPlus/PicoCRM
Length of output: 746
🏁 Script executed:
# Search for neo4j-related patterns in .ts files
rg "neo4j" --type ts -B 2 -A 2Repository: PicoPlus/PicoCRM
Length of output: 12217
🏁 Script executed:
# Check for other uses of result.records and .get()
rg "records\[" --type ts -B 1 -A 1Repository: PicoPlus/PicoCRM
Length of output: 238
🏁 Script executed:
# Check how other repositories handle numeric results from Neo4j
rg "result\.records" --type ts -A 5 | head -80Repository: PicoPlus/PicoCRM
Length of output: 2462
🏁 Script executed:
# Look at the Neo4jClient to understand how it's structured
cat src/data/neo4j/Neo4jClient.tsRepository: PicoPlus/PicoCRM
Length of output: 626
🏁 Script executed:
# Search for any existing Integer handling patterns
rg "toNumber|Integer" --type tsRepository: PicoPlus/PicoCRM
Length of output: 42
Add neo4j import and use proper Integer conversion for the count result.
neo4j-driver returns Cypher INTEGER values as custom Integer objects (not native JS numbers). Passing this to Number() can result in NaN. Additionally, NaN ?? 0 evaluates to NaN (not 0), so the fallback doesn't protect against this. The broken > 0 check then compares NaN > 0, which is always false, preventing broken relationships from being reported.
Use the driver's conversion utility instead:
Proposed fix
+import neo4j from 'neo4j-driver';
import { Neo4jClient } from '../data/neo4j/Neo4jClient.js';
export interface DebugIssue {
- const broken = Number(result.records[0]?.get('broken') ?? 0);
+ const raw = result.records[0]?.get('broken');
+ const broken = neo4j.integer.toNumber(raw ?? neo4j.int(0));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/debug/DebugService.ts` around lines 26 - 37, The Cypher count is returned
as a neo4j Integer, so update DebugService to import neo4j from 'neo4j-driver'
and convert the count using neo4j.integer.toNumber (with a safe fallback)
instead of Number(...); specifically, when handling the result of session.run in
the method that calls `MATCH (d:Deal) ... RETURN count(d) AS broken`, retrieve
the record value via `result.records[0]?.get('broken')`, convert it with
`neo4j.integer.toNumber(...)` (or check `neo4j.integer.inSafeRange` then
toNumber) and fall back to 0 if undefined/unsafe, then use that numeric `broken`
in the `if (broken > 0)` check and issue push.
| (async () => { | ||
| const config = loadConfig(); | ||
| const neo4j = new Neo4jClient(config); | ||
| const debug = new DebugService(neo4j); | ||
|
|
||
| const issues = await debug.run(); | ||
| console.log('Synthetic Debug Pass Results'); | ||
| if (issues.length === 0) { | ||
| console.log('- No issues detected.'); | ||
| } else { | ||
| for (const issue of issues) { | ||
| console.log(`- [${issue.severity}] [${issue.category}] ${issue.message}`); | ||
| } | ||
| } | ||
|
|
||
| await neo4j.close(); | ||
| })(); |
There was a problem hiding this comment.
Unhandled rejection and resource leak if debug.run() throws.
The IIFE has no .catch() handler (unlike main.ts and createTenant.ts), and neo4j.close() is skipped on error.
Proposed fix
(async () => {
const config = loadConfig();
const neo4j = new Neo4jClient(config);
- const debug = new DebugService(neo4j);
-
- const issues = await debug.run();
- console.log('Synthetic Debug Pass Results');
- if (issues.length === 0) {
- console.log('- No issues detected.');
- } else {
- for (const issue of issues) {
- console.log(`- [${issue.severity}] [${issue.category}] ${issue.message}`);
+ try {
+ const debug = new DebugService(neo4j);
+ const issues = await debug.run();
+ console.log('Synthetic Debug Pass Results');
+ if (issues.length === 0) {
+ console.log('- No issues detected.');
+ } else {
+ for (const issue of issues) {
+ console.log(`- [${issue.severity}] [${issue.category}] ${issue.message}`);
+ }
}
+ } catch (err) {
+ console.error('Debug pass failed', err);
+ process.exit(1);
+ } finally {
+ await neo4j.close();
}
-
- await neo4j.close();
})();📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| (async () => { | |
| const config = loadConfig(); | |
| const neo4j = new Neo4jClient(config); | |
| const debug = new DebugService(neo4j); | |
| const issues = await debug.run(); | |
| console.log('Synthetic Debug Pass Results'); | |
| if (issues.length === 0) { | |
| console.log('- No issues detected.'); | |
| } else { | |
| for (const issue of issues) { | |
| console.log(`- [${issue.severity}] [${issue.category}] ${issue.message}`); | |
| } | |
| } | |
| await neo4j.close(); | |
| })(); | |
| (async () => { | |
| const config = loadConfig(); | |
| const neo4j = new Neo4jClient(config); | |
| try { | |
| const debug = new DebugService(neo4j); | |
| const issues = await debug.run(); | |
| console.log('Synthetic Debug Pass Results'); | |
| if (issues.length === 0) { | |
| console.log('- No issues detected.'); | |
| } else { | |
| for (const issue of issues) { | |
| console.log(`- [${issue.severity}] [${issue.category}] ${issue.message}`); | |
| } | |
| } | |
| } catch (err) { | |
| console.error('Debug pass failed', err); | |
| process.exit(1); | |
| } finally { | |
| await neo4j.close(); | |
| } | |
| })(); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/debug/syntheticDebugPass.ts` around lines 5 - 21, The IIFE around
loadConfig(), new Neo4jClient(config), DebugService and debug.run currently has
no error handling so a thrown error will skip neo4j.close() and leak resources;
wrap the await debug.run() and subsequent logging in a try/catch/finally (or
attach a .catch() and .finally() to the async IIFE) so that any error from
debug.run() is caught and logged (include the error), and ensure neo4j.close()
is always invoked in the finally block; reference the functions/classes
loadConfig, Neo4jClient, DebugService, debug.run and neo4j.close when making the
change.
| const shutdown = async () => { | ||
| server.close(); | ||
| await neo4j.close(); | ||
| }; | ||
|
|
||
| process.on('SIGINT', shutdown); | ||
| process.on('SIGTERM', shutdown); |
There was a problem hiding this comment.
Graceful shutdown has several reliability gaps.
server.close()is callback-based (http.Server.close), not promise-based — it isn't actually awaited here, soneo4j.close()may run before in-flight HTTP requests finish.- The
asyncshutdown function's returned promise is ignored byprocess.on, so any rejection is silently lost. - No
process.exit()call — the process may hang if other handles (timers, keep-alive sockets) remain open.
Proposed fix
const shutdown = async () => {
- server.close();
- await neo4j.close();
+ await new Promise<void>((resolve, reject) => {
+ server.close((err) => (err ? reject(err) : resolve()));
+ });
+ await neo4j.close();
+ process.exit(0);
};
- process.on('SIGINT', shutdown);
- process.on('SIGTERM', shutdown);
+ const onSignal = () => {
+ shutdown().catch((err) => {
+ console.error('Shutdown error', err);
+ process.exit(1);
+ });
+ };
+ process.on('SIGINT', onSignal);
+ process.on('SIGTERM', onSignal);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const shutdown = async () => { | |
| server.close(); | |
| await neo4j.close(); | |
| }; | |
| process.on('SIGINT', shutdown); | |
| process.on('SIGTERM', shutdown); | |
| const shutdown = async () => { | |
| await new Promise<void>((resolve, reject) => { | |
| server.close((err) => (err ? reject(err) : resolve())); | |
| }); | |
| await neo4j.close(); | |
| process.exit(0); | |
| }; | |
| const onSignal = () => { | |
| shutdown().catch((err) => { | |
| console.error('Shutdown error', err); | |
| process.exit(1); | |
| }); | |
| }; | |
| process.on('SIGINT', onSignal); | |
| process.on('SIGTERM', onSignal); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main.ts` around lines 43 - 49, Wrap the callback-based server.close into
a Promise (e.g., create a helper like closeServerPromise that calls server.close
and resolves/rejects on its callback), then await that promise before calling
neo4j.close() inside the existing async shutdown function so in-flight requests
finish first; ensure the shutdown handler attached to
process.on('SIGINT'|'SIGTERM') invokes shutdown() and handles rejections (call
shutdown().catch(err => { log error; process.exit(1); })) and finally call
process.exit(0) on success (and use a forced fallback timer — e.g., setTimeout
to process.exit(1) after a short timeout — cleared on successful shutdown) so
the process cannot hang.
Codex generated this pull request, but encountered an unexpected error after generation. This is a placeholder PR message.
Codex Task
Summary by CodeRabbit
Release Notes
New Features
Documentation
Chores