Skip to content

fix(task): preserve labels on project import - #1519

Open
eeshsaxena wants to merge 3 commits into
usekaneo:mainfrom
eeshsaxena:fix/import-tasks-preserve-labels
Open

fix(task): preserve labels on project import#1519
eeshsaxena wants to merge 3 commits into
usekaneo:mainfrom
eeshsaxena:fix/import-tasks-preserve-labels

Conversation

@eeshsaxena

@eeshsaxena eeshsaxena commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Problem

Exporting a project and importing it back silently loses every task's labels.

export-tasks serialises labels for each task:

tasks: tasks.map((task) => ({
  ...
  labels: taskLabelsMap.get(task.id) || [],   // [{ name, color }, ...]
})),

But import-tasks never reads them: ImportTask has no labels field, the import route's validator strips the key, and the controller only inserts the task row. So an export -> import round-trip drops all labels.

Fix

  • Add labels?: Array<{ name: string; color: string }> to ImportTask.
  • Accept labels in the /import/:projectId route validator (otherwise valibot strips it before the controller runs).
  • Inside the same transaction that creates the task, re-create its label rows.

Labels are de-duplicated by name before insert, and the insert uses onConflictDoNothing() so the (task_id, name) unique constraint is respected. workspaceId is left null, which is how task-scoped labels are already stored.

The change is additive: exports made before this still import fine (labels default to none), and imports without a labels field behave exactly as before.

Summary by CodeRabbit

  • New Features

    • Task imports can now include optional labels with names and colors.
    • Labels are preserved when tasks are exported and imported.
    • Duplicate labels are avoided when creating tasks.
    • Imported labels are added automatically to newly created tasks.
  • Bug Fixes

    • Task deletion now verifies the required entitlement before proceeding.

@coderabbitai

coderabbitai Bot commented Aug 8, 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: edcc705e-685f-46b3-8544-794abcbc7c07

📥 Commits

Reviewing files that changed from the base of the PR and between ba195dd and 283bfea.

📒 Files selected for processing (1)
  • apps/api/src/task/controllers/import-tasks.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/api/src/task/controllers/import-tasks.ts

📝 Walkthrough

Walkthrough

Task imports now accept optional labels and persist unique labels during task creation. Task exports and imports preserve labels. The delete-task route now validates entitlements before deletion.

Changes

Task import and access control

Layer / File(s) Summary
Task label contract and persistence
apps/api/src/task/controllers/import-tasks.ts
ImportTask accepts optional labels with names and colors. Nonempty labels are deduplicated and inserted for each created task within the transaction.
Task endpoint validation and import wiring
apps/api/src/task/index.ts
The task import endpoint accepts label arrays. The delete-task route adds requireEntitlement before deletion.

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

Possibly related PRs

🚥 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 and concisely describes the main change: preserving task labels during project import.
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 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 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 task import to preserve task labels on project import

🐞 Bug fix 🕐 20-40 Minutes

Grey Divider

AI Description

• Accept per-task labels in the import API payload
• Re-create label rows during task import to preserve export/import round-trips
• De-duplicate labels by name and ignore conflicts to respect unique constraints
Diagram

graph TD
  A["Client"] --> B["Task API"] --> C["Payload validator"] --> D["Import controller"] --> E["DB transaction"] --> F[("task_table")]
  E --> G[("label_table")]
  D --> H["Event publish"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Batch insert labels across all tasks
  • ➕ Fewer DB round-trips for large imports
  • ➕ Can dedupe labels once per import payload rather than per task
  • ➖ More complex mapping from imported task payloads to created task IDs
  • ➖ Harder to keep atomicity/error reporting per task without additional bookkeeping
2. Import via existing bulk-update label operations
  • ➕ Reuses existing label mutation logic and permissions checks
  • ➕ Centralizes label constraint handling in one place
  • ➖ Requires a second phase after task creation (less atomic)
  • ➖ More event emission / side effects unless carefully suppressed

Recommendation: The PR’s approach (accept labels in the validator and insert label rows inside the same transaction as task creation) is the best default: it preserves export/import fidelity, keeps the operation atomic, and remains backwards-compatible for older exports that lack labels. Consider the batch-insert alternative only if imports commonly contain very large task counts and DB latency becomes a bottleneck.

Files changed (2) +1063 / -1035

Bug fix (2) +1063 / -1035
import-tasks.tsPersist imported task labels during import transaction +157/-132

Persist imported task labels during import transaction

• Extends ImportTask to include optional labels and re-creates label rows when importing a task. Labels are de-duplicated by name and inserted with onConflictDoNothing() to respect the (task_id, name) uniqueness constraint, all within the same transaction as task creation.

apps/api/src/task/controllers/import-tasks.ts

index.tsAllow 'labels' in /import payload validator +906/-903

Allow 'labels' in /import payload validator

• Updates the /import/:projectId route's Valibot schema to accept an optional labels array per task (name + color). This prevents the validator from stripping labels before the controller runs.

apps/api/src/task/index.ts

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/task/controllers/import-tasks.ts`:
- Around line 85-102: Update the label recreation logic around uniqueLabels to
trim or otherwise filter out labels whose names are empty or whitespace-only
before inserting. Use the filtered collection for both the insert values and
emptiness check, skipping the database insert when no valid labels remain.

In `@apps/api/src/task/index.ts`:
- Around line 483-486: Add the existing requireEntitlement middleware to the
single-task delete route before its async handler, matching the middleware
sequence used by other single-task mutation routes while preserving the existing
workspaceAccess and task delete permission checks.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b8f518b9-c62f-42ff-832c-0c25ebcdcd5c

📥 Commits

Reviewing files that changed from the base of the PR and between df60800 and ba195dd.

📒 Files selected for processing (2)
  • apps/api/src/task/controllers/import-tasks.ts
  • apps/api/src/task/index.ts

Comment on lines +85 to +102
// Re-create the task's labels. export-tasks serialises `labels` per task,
// so without this an export/import round-trip silently drops them.
if (task && taskData.labels?.length) {
const uniqueLabels = [
...new Map(taskData.labels.map((label) => [label.name, label])).values(),
];

await tx
.insert(labelTable)
.values(
uniqueLabels.map((label) => ({
name: label.name,
color: label.color,
taskId: task.id,
})),
)
.onConflictDoNothing();
}

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.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Filter out blank label names before insert.

The route validator accepts any string for name, including "" or whitespace. labelTable.name is notNull but not checked for content, so an import can persist blank labels. The layer contract states that only nonempty labels are inserted. Add the filter and skip the insert when nothing remains.

🛠️ Proposed fix
-        if (task && taskData.labels?.length) {
-          const uniqueLabels = [
-            ...new Map(taskData.labels.map((label) => [label.name, label])).values(),
-          ];
-
-          await tx
-            .insert(labelTable)
-            .values(
-              uniqueLabels.map((label) => ({
-                name: label.name,
-                color: label.color,
-                taskId: task.id,
-              })),
-            )
-            .onConflictDoNothing();
-        }
+        if (task && taskData.labels?.length) {
+          const uniqueLabels = [
+            ...new Map(
+              taskData.labels
+                .map((label) => ({ ...label, name: label.name.trim() }))
+                .filter((label) => label.name.length > 0)
+                .map((label) => [label.name, label] as const),
+            ).values(),
+          ];
+
+          if (uniqueLabels.length > 0) {
+            await tx
+              .insert(labelTable)
+              .values(
+                uniqueLabels.map((label) => ({
+                  name: label.name,
+                  color: label.color,
+                  taskId: task.id,
+                })),
+              )
+              .onConflictDoNothing();
+          }
+        }
📝 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.

Suggested change
// Re-create the task's labels. export-tasks serialises `labels` per task,
// so without this an export/import round-trip silently drops them.
if (task && taskData.labels?.length) {
const uniqueLabels = [
...new Map(taskData.labels.map((label) => [label.name, label])).values(),
];
await tx
.insert(labelTable)
.values(
uniqueLabels.map((label) => ({
name: label.name,
color: label.color,
taskId: task.id,
})),
)
.onConflictDoNothing();
}
// Re-create the task's labels. export-tasks serialises `labels` per task,
// so without this an export/import round-trip silently drops them.
if (task && taskData.labels?.length) {
const uniqueLabels = [
...new Map(
taskData.labels
.map((label) => ({ ...label, name: label.name.trim() }))
.filter((label) => label.name.length > 0)
.map((label) => [label.name, label] as const),
).values(),
];
if (uniqueLabels.length > 0) {
await tx
.insert(labelTable)
.values(
uniqueLabels.map((label) => ({
name: label.name,
color: label.color,
taskId: task.id,
})),
)
.onConflictDoNothing();
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/task/controllers/import-tasks.ts` around lines 85 - 102, Update
the label recreation logic around uniqueLabels to trim or otherwise filter out
labels whose names are empty or whitespace-only before inserting. Use the
filtered collection for both the insert values and emptiness check, skipping the
database insert when no valid labels remain.

Comment on lines +483 to +486
validator("param", v.object({ id: v.string() })),
workspaceAccess.fromTask(),
requireWorkspacePermission({ task: ["delete"] }),
async (c) => {

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.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Compare requireEntitlement usage across delete routes in the API.
set -euo pipefail

# List every route registration that references requireEntitlement or a delete permission.
rg -nP -C 6 'requireWorkspacePermission\(\{\s*\w+:\s*\[\s*"delete"' apps/api/src --type=ts

# Show all requireEntitlement call sites for comparison.
rg -nP '\brequireEntitlement\b' apps/api/src --type=ts

Repository: usekaneo/kaneo

Length of output: 4966


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== task/index.ts related snippets =="
sed -n '1,120p' apps/api/src/task/index.ts
sed -n '180,310p' apps/api/src/task/index.ts
sed -n '330,500p' apps/api/src/task/index.ts
sed -n '830,910p' apps/api/src/task/index.ts

echo "== billing middleware =="
cat -n apps/api/src/billing/require-entitlement-middleware.ts

echo "== require-task-permission.ts =="
cat -n apps/api/src/task/controllers/require-task-permission.ts

echo "== route registrations around existing requireEntitlement in task/index.ts =="
rg -n -C 8 'validator\("param"|validator\("json"|requireEntitlement|requireWorkspacePermission|requireBulkTaskEntitlement' apps/api/src/task/index.ts apps/api/src/task/controllers/require-task-permission.ts apps/api/src/project/index.ts apps/api/src/label/index.ts

Repository: usekaneo/kaneo

Length of output: 50370


Add requireEntitlement to the single-task delete route.

The /tasks/:id delete handler only checks workspaceAccess and task: ["delete"], then calls deleteTask. Add the same entitlement middleware that protects single-task mutation routes so expired/cancelled workspaces cannot delete tasks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/task/index.ts` around lines 483 - 486, Add the existing
requireEntitlement middleware to the single-task delete route before its async
handler, matching the middleware sequence used by other single-task mutation
routes while preserving the existing workspaceAccess and task delete permission
checks.

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

qodo-free-for-open-source-projects Bot commented Aug 8, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Labels inserted without workspaceId ✓ Resolved 🐞 Bug ≡ Correctness
Description
importTasks inserts task-scoped label rows without workspaceId, creating labels whose workspace
cannot be resolved by workspaceAccess.fromLabel and causing label endpoints to fail with
"Workspace ID could not be determined". It also prevents bulk label removal from deleting these
imported labels because the delete query filters by labelTable.workspaceId.
Code

apps/api/src/task/controllers/import-tasks.ts[R95-98]

+              uniqueLabels.map((label) => ({
+                name: label.name,
+                color: label.color,
+                taskId: task.id,
Evidence
The import code inserts labels without workspaceId. The DB schema includes workspaceId for
labels, and workspaceAccess.fromLabel determines authorization solely from
labelTable.workspaceId and throws if it is missing; additionally, bulk label removal filters by
labelTable.workspaceId, so null-workspace labels won't match deletion queries.

apps/api/src/task/controllers/import-tasks.ts[85-102]
apps/api/src/database/schema.ts[548-577]
apps/api/src/utils/workspace-access-middleware.ts[44-118]
apps/api/src/utils/workspace-access-middleware.ts[169-176]
apps/api/src/task/controllers/bulk-update-tasks.ts[283-292]
apps/api/src/label/controllers/create-label.ts[40-45]

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

## Issue description
`apps/api/src/task/controllers/import-tasks.ts` recreates labels during import, but inserts into `labelTable` without populating `workspaceId`. In this repo, label authorization (`workspaceAccess.fromLabel`) looks up `labelTable.workspaceId`, and some task bulk label operations also filter by `workspaceId`, so imported labels with `workspaceId = null` become unmanageable.
### Issue Context
- `labelTable.workspaceId` is nullable, so inserts will succeed, but downstream behavior depends on it.
- `workspaceAccessMiddleware` throws when it cannot determine a `workspaceId`.
### Fix
When inserting `labelTable` rows during import, set `workspaceId` consistently with the rest of the codebase (e.g., `workspaceId: project.workspaceId`).
### Fix Focus Areas
- apps/api/src/task/controllers/import-tasks.ts[85-102]
- apps/api/src/utils/workspace-access-middleware.ts[44-118]
- apps/api/src/task/controllers/bulk-update-tasks.ts[283-292]

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


Grey Divider

Tip of the day
💡 Did you know, you can reply 'qodo' on any finding to push back, ask questions, or dig deeper

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread apps/api/src/task/controllers/import-tasks.ts
Imported labels were inserted without workspaceId, so workspaceAccess.fromLabel could not resolve their workspace (label endpoints failed with 'Workspace ID could not be determined') and bulk label removal, which filters by workspaceId, skipped them. Set it from the project being imported into.
@randoneering

Copy link
Copy Markdown
Contributor

@eeshsaxena thank you for your contribution! Please take a look at the suggestions from coderabbit and qodo. In addition, our lint step in CI is failing (specifically in Biome.) This should have thrown when you committed to the branch as well.

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.

2 participants