Skip to content

fix(api): stop embedding task rows in the project list response - #1423

Open
alexmakarski wants to merge 1 commit into
usekaneo:mainfrom
alexmakarski:fix/project-list-payload
Open

fix(api): stop embedding task rows in the project list response#1423
alexmakarski wants to merge 1 commit into
usekaneo:mainfrom
alexmakarski:fix/project-list-payload

Conversation

@alexmakarski

@alexmakarski alexmakarski commented Jul 26, 2026

Copy link
Copy Markdown

GET /api/project uses with: { tasks: true } to compute three summary numbers per project (total tasks, completion percentage, earliest due date), then spreads the loaded rows into the response via ...project. Every task in the workspace is fetched into memory and serialised to the client on every call, even though the endpoint only needs the aggregates.

The surrounding code suggests this was unintended: the same return object explicitly sets archivedTasks: [], plannedTasks: [] and columns: [], so nested collections were already being deliberately omitted here. tasks slipped through the spread.

Change

Replaces the join with a grouped aggregate: count(), a filtered count for done/archived, and min(dueDate). Projects with no tasks fall back to zeroed statistics.

Semantics are unchanged, including ignoring null due dates when finding the earliest — which is what SQL min() does natively.

Measurements

10 projects / 2,000 tasks, median of 5 runs:

payload latency
before 946.4 KB 31.3 ms
after 3.8 KB 5.0 ms

The response is now flat with respect to task count rather than linear, so it stays constant as a workspace fills up.

Tests

Adds tests/api-integration/project-list.test.ts covering payload shape, statistics accuracy, empty projects, and per-project isolation. These fail against the previous implementation.

Locally: integration 68/68, unit 201/201, biome check and tsc --noEmit clean.

Note on the response shape

Consumers reading .tasks off this endpoint would stop receiving it. Nothing in apps/web does — the board and list views read column.tasks from a different endpoint.

This follows the same principle as #1037, where the task list endpoint stopped forcing clients to download everything and filter locally. This endpoint appears to have been missed by that pass.

Happy to rework this as an opt-in (?includeTasks=true) instead if you'd prefer to keep the current shape.

Summary by CodeRabbit

  • New Features

    • Project listings now include task statistics, including total tasks, completion percentage, and earliest due date.
    • Projects without tasks display zeroed statistics.
    • Task data remains excluded from the project list response while statistics remain available.
  • Bug Fixes

    • Improved isolation of task statistics between projects in the same workspace.
  • Tests

    • Added coverage for project statistics, empty projects, and project list response contents.

GET /api/project used `with: { tasks: true }` to compute three summary
numbers per project (total tasks, completion percentage, earliest due date),
then spread the loaded rows into the response via `...project`. Every task in
the workspace was fetched into memory and serialised to the client on every
call, even though the endpoint only needs aggregates.

The surrounding code suggests this was unintended: the same object explicitly
returns `archivedTasks: []`, `plannedTasks: []` and `columns: []`, so nested
collections were already being deliberately omitted here. `tasks` slipped
through the spread.

Replace the join with a grouped aggregate (count, filtered count for
done/archived, min(dueDate)). Projects with no tasks fall back to zeroed
statistics. Semantics are unchanged, including ignoring null due dates when
finding the earliest, which is what SQL min() does natively.

Measured on 10 projects / 2000 tasks, median of 5 runs:
  before  946.4 KB   31.3 ms
  after     3.8 KB    5.0 ms

The response is now flat with respect to task count rather than linear.

Adds tests/api-integration/project-list.test.ts covering payload shape,
statistics accuracy, empty projects, and per-project isolation. These fail
against the previous implementation.

Related: usekaneo#1037 applied the same principle to the task list endpoint.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5d38842d-f5e4-4b8d-83e4-f52e60e9e13c

📥 Commits

Reviewing files that changed from the base of the PR and between debad74 and 7771d46.

📒 Files selected for processing (2)
  • apps/api/src/project/controllers/get-projects.ts
  • tests/api-integration/project-list.test.ts

📝 Walkthrough

Walkthrough

The project list endpoint now computes task statistics with a grouped database query, omits embedded task rows, and supplies zero-valued statistics for projects without tasks. Integration tests cover payload shape, aggregation, empty projects, and project isolation.

Changes

Project statistics

Layer / File(s) Summary
Statistics query and list wiring
apps/api/src/project/controllers/get-projects.ts
Aggregates task counts, completion percentages, and earliest due dates by project, then attaches statistics with an empty fallback.
Project list integration validation
tests/api-integration/project-list.test.ts
Verifies task omission, calculated statistics, empty-project defaults, and isolation between projects.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant getProjects
  participant getProjectStatistics
  participant taskTable
  Client->>getProjects: request project list
  getProjects->>getProjectStatistics: provide project IDs
  getProjectStatistics->>taskTable: grouped statistics query
  taskTable-->>getProjectStatistics: aggregated statistics
  getProjectStatistics-->>getProjects: statistics map
  getProjects-->>Client: project summaries with statistics
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: removing embedded task rows from the project list response.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Fix project list API to return task aggregates without embedding tasks

🐞 Bug fix ✨ Enhancement 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Stop serializing per-project task rows in GET /api/project responses.
• Compute task statistics via grouped SQL aggregates (count/filtered count/min due date).
• Add integration coverage for payload shape and statistics correctness/edge cases.
Diagram

graph TD
  C(["Client"]) --> R["GET /api/project"] --> G["getProjects()"] --> D[("DB")] --> O(["Projects + statistics"])
  G --> S["getProjectStatistics()"] --> D

  subgraph Legend
    direction LR
    _ext(["External/client"]) ~~~ _fn["Function/module"] ~~~ _db[("Database")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Opt-in expansion via query param (e.g. `?includeTasks=true`)
  • ➕ Avoids breaking consumers that (incorrectly) relied on .tasks being present
  • ➕ Keeps payload control explicit for callers
  • ➖ Adds API surface area and branching logic to maintain
  • ➖ Still risks accidental heavy payloads if callers enable it by default
2. Expose a dedicated summary endpoint (e.g. `/api/project/summary`)
  • ➕ Makes the lightweight contract unambiguous and stable
  • ➕ Avoids retrofitting optional behavior into an existing endpoint
  • ➖ Requires client migrations and maintaining two endpoints
  • ➖ More routing/duplication unless carefully factored
3. Maintain denormalized per-project statistics
  • ➕ Fast reads with no aggregate query at request time
  • ➕ Predictable latency at high task volumes
  • ➖ Higher write complexity (must update stats transactionally)
  • ➖ Risk of drift/backfills and more operational burden

Recommendation: The PR’s approach (DB-side aggregates + removing embedded tasks) is the best default for a list/summary endpoint: it fixes the accidental payload leak and makes performance scale with projects rather than tasks. Consider the includeTasks opt-in only if you need to preserve backward compatibility for unknown external consumers.

Files changed (2) +220 / -32

Bug fix (1) +63 / -32
get-projects.tsReplace task eager-loading with per-project aggregate statistics +63/-32

Replace task eager-loading with per-project aggregate statistics

• Removes 'with: { tasks: true }' and the in-memory computation that also leaked 'tasks' via spread. Adds a 'getProjectStatistics()' helper that aggregates counts and earliest due date per project in SQL, with a zeroed fallback for projects with no tasks.

apps/api/src/project/controllers/get-projects.ts

Tests (1) +157 / -0
project-list.test.tsAdd integration tests for project list payload shape and stats +157/-0

Add integration tests for project list payload shape and stats

• Introduces coverage ensuring '.tasks' is not present in the project list response and that statistics are accurate. Includes cases for due date minimum behavior, empty projects, and per-project isolation.

tests/api-integration/project-list.test.ts

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 📜 Skill insights (0)

Context used

Grey Divider


Remediation recommended

1. Large IN query 🐞 Bug ☼ Reliability
Description
getProjectStatistics() filters tasks via inArray(taskTable.projectId, projectIds), which expands the
entire workspace’s project ID list into a single IN predicate. For large workspaces this can produce
very large SQL/bind-parameter lists (slow planning/execution or even DB parameter-limit failures)
and turn GET /api/project into a 500/timeout.
Code

apps/api/src/project/controllers/get-projects.ts[R37-39]

+    .from(taskTable)
+    .where(inArray(taskTable.projectId, projectIds))
+    .groupBy(taskTable.projectId);
Evidence
The controller gathers all project IDs from the workspace query and feeds them directly into
inArray(...) for the aggregate task query, creating a statement whose size scales with the number
of projects. The schema shows tasks relate to projects via task.projectId and projects are scoped
by workspaceId, enabling a workspace-scoped join/subquery that avoids enumerating IDs.

apps/api/src/project/controllers/get-projects.ts[17-40]
apps/api/src/project/controllers/get-projects.ts[56-68]
apps/api/src/database/schema.ts[232-251]
apps/api/src/database/schema.ts[316-344]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`getProjectStatistics()` currently filters tasks with `inArray(taskTable.projectId, projectIds)` where `projectIds` is the full set of workspace project IDs. This generates a potentially massive `IN (...)` predicate, which can become slow or exceed PostgreSQL bind-parameter limits for large workspaces.

## Issue Context
The tasks table is keyed to projects via `task.projectId`, and projects are already scoped by `workspaceId` in the list query. We can compute the same aggregates while filtering by `workspaceId` in SQL (join/subquery) or by chunking the ID list to keep statement size bounded.

## Fix Focus Areas
- apps/api/src/project/controllers/get-projects.ts[17-54]
- apps/api/src/project/controllers/get-projects.ts[56-76]

## Suggested fix approaches
1. **Preferable (single query, no ID explosion):** join `taskTable` to `projectTable` and filter `projectTable.workspaceId = workspaceId` (and `archivedAt` if needed), then `groupBy(taskTable.projectId)`.
2. **Alternative:** chunk `projectIds` into batches (e.g., 500–2000), run the aggregate query per chunk, and merge into the map.

Keep the existing empty-project fallback behavior unchanged.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +37 to +39
.from(taskTable)
.where(inArray(taskTable.projectId, projectIds))
.groupBy(taskTable.projectId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Large in query 🐞 Bug ☼ Reliability

getProjectStatistics() filters tasks via inArray(taskTable.projectId, projectIds), which expands the
entire workspace’s project ID list into a single IN predicate. For large workspaces this can produce
very large SQL/bind-parameter lists (slow planning/execution or even DB parameter-limit failures)
and turn GET /api/project into a 500/timeout.
Agent Prompt
## Issue description
`getProjectStatistics()` currently filters tasks with `inArray(taskTable.projectId, projectIds)` where `projectIds` is the full set of workspace project IDs. This generates a potentially massive `IN (...)` predicate, which can become slow or exceed PostgreSQL bind-parameter limits for large workspaces.

## Issue Context
The tasks table is keyed to projects via `task.projectId`, and projects are already scoped by `workspaceId` in the list query. We can compute the same aggregates while filtering by `workspaceId` in SQL (join/subquery) or by chunking the ID list to keep statement size bounded.

## Fix Focus Areas
- apps/api/src/project/controllers/get-projects.ts[17-54]
- apps/api/src/project/controllers/get-projects.ts[56-76]

## Suggested fix approaches
1. **Preferable (single query, no ID explosion):** join `taskTable` to `projectTable` and filter `projectTable.workspaceId = workspaceId` (and `archivedAt` if needed), then `groupBy(taskTable.projectId)`.
2. **Alternative:** chunk `projectIds` into batches (e.g., 500–2000), run the aggregate query per chunk, and merge into the map.

Keep the existing empty-project fallback behavior unchanged.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexmakarski Thank you for your contribution! Love seeing enhancements like this. Would you mind taking a look at this suggestion? Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants