feat(task): track task author and surface as sticker#1166
Conversation
Adds a `created_by` column to the task table that stores the user who opens a task, separate from the assignee. The field is set automatically from the request context on task creation, joined into both single-task and project-board reads, and exposed through the API as `createdBy`, `creatorName`, and `creatorImage`. On the frontend, the kanban card now shows a small author sticker next to the priority badge when the task author differs from the assignee, so shared workspaces no longer make it ambiguous who created a task. The task properties sidebar also displays the author next to the assignee in all three layouts (compact, mobile, desktop). New i18n keys: `tasks:creator.label`, `tasks:creator.unknown`, `tasks:creator.tooltip`. Translations added for all nine supported locales.
Review Summary by QodoTrack task author and surface as sticker in UI
WalkthroughsDescription• Add explicit created_by field to task table with foreign key to user and index for performance • Implement bidirectional creator-task relationships in Drizzle ORM with explicit relation names to disambiguate multiple foreign keys to user table • Extend task queries (getTask, getTasks) with self-join on user table to fetch creator details (createdBy, creatorName, creatorImage) • Populate createdBy automatically from authenticated user when creating new tasks via API • Extend Task type with optional creator information fields (createdBy, creatorName, creatorImage) • Display task creator as compact badge on kanban cards when author differs from assignee, with avatar and localized "Author" label • Add read-only creator chip in task properties sidebar next to assignee chip across all layout variants • Add comprehensive i18n support with creator translations for all nine supported locales (en-US, ru-RU, uk-UA, de-DE, es-ES, fr-FR, nl-NL, mk-MK, el-GR) • Update i18n schema to include creator namespace with required fields • Create Drizzle migration 0028_fantastic_thor_girl.sql to add created_by column with proper constraints and indexing Diagramflowchart LR
A["User creates task"] -->|"createdBy set from userId"| B["Task stored with created_by FK"]
B -->|"self-join on user table"| C["Creator details fetched"]
C -->|"creatorName, creatorImage"| D["Kanban card badge"]
C -->|"creator chip"| E["Task sidebar"]
D -->|"i18n translations"| F["Localized UI"]
E -->|"i18n translations"| F
File Changes1. apps/api/src/task/controllers/get-tasks.ts
|
Code Review by Qodo
1.
|
The task.created event was passing the assignee as the actor (`userId`), which since the introduction of `created_by` makes activity attribution and integration broadcasts record the wrong user when the creator and assignee differ. The event payload now distinguishes the two roles: - `userId` = the actor (creator), falling back to the assignee for tasks created without an authenticated context (e.g. legacy paths) - `assigneeId` = the notification target The notification subscriber for `task.created` is updated to use `assigneeId` so assignees still receive their "task assigned to you" notification regardless of who created it. The activity and plugin broadcast subscribers continue to read `userId`, which now correctly identifies the actor.
|
It shows in the activity of the task
But in the current version it's broken. The activity isn't recorded because the current userId is not passed correctly. I have fix that in my pull request #1161 Not sure if this is enough or not. In my opinion this should be enough. |
- Change createdBy FK onDelete from "set null" to "cascade" (schema convention) - Update migration SQL: cascade delete + add trailing newline - Add "Author" label to creator badge in task sidebar for clarity
📝 WalkthroughWalkthroughThis PR introduces task creator tracking, distinguishing between who created a task and who it's assigned to. Database schema adds a Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
|
@andrejsshell thanks for the review! I've addressed all the feedback:
|
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/api/src/task/index.ts (1)
162-201:⚠️ Potential issue | 🟠 MajorAdd Valibot param validation for
projectIdon create-task route.Line 191 reads
projectIdfromc.req.param()withoutvalidator("param", ...)middleware on this endpoint, which violates the route input-validation convention used elsewhere in this file.🔧 Proposed fix
.post( "/:projectId", describeRoute({ @@ }), + validator("param", v.object({ projectId: v.string() })), validator( "json", v.object({ @@ workspaceAccess.fromProject("projectId"), async (c) => { - const { projectId } = c.req.param(); + const { projectId } = c.req.valid("param"); const { title, description,As per coding guidelines: "All API inputs must be validated using Valibot schemas with
validatormiddleware".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/api/src/task/index.ts` around lines 162 - 201, The POST createTask route is missing Valibot validation for the URL param projectId; add a validator("param", v.object({ projectId: v.string() })) middleware to the route chain (before workspaceAccess.fromProject("projectId") and the async (c) handler) so c.req.param().projectId is validated; keep the existing body validator and other middleware order intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/api/src/database/schema.ts`:
- Around line 299-302: The task.creator foreign key currently uses onDelete:
"cascade" on the createdBy column (createdBy: text("created_by").references(()
=> userTable.id, ...)), which will delete tasks when a user is removed; change
the reference option to onDelete: "set null" and ensure the createdBy column is
defined as nullable (e.g., call nullable()/allowNull as required by the schema
API) so deleted users set created_by to NULL instead of deleting the task;
update any related type/interface if needed to accept null for createdBy.
In `@apps/api/src/notification/index.ts`:
- Around line 159-169: The subscriber for subscribeToEvent("task.created", ...)
currently ignores events that lack assigneeId; to preserve backward
compatibility, make the handler tolerant of missing assigneeId by resolving the
assignee before skipping: when data.assigneeId is absent, load the task by
data.taskId (e.g., via your Task model/service used elsewhere) to determine its
assignee and fall back to that; then call createNotification({ userId:
resolvedAssigneeId, type: "task_created", ... }) only if a resolvedAssigneeId
exists. Update the subscribeToEvent handler (and any related variable names) so
it attempts the DB lookup when data.assigneeId is null/undefined instead of
immediately returning.
In `@apps/api/src/task/controllers/create-task.ts`:
- Around line 90-91: The event payload currently sets userId to an empty string
(userId: createdTask.createdBy ?? createdTask.userId ?? ""), which can break
consumers; change the assignment in the create-task controller (look for
createdTask.createdBy / createdTask.userId) to omit or set userId to
null/undefined when no real ID exists (e.g., use createdTask.createdBy ??
createdTask.userId ?? undefined) so no empty string is emitted, and update any
event-builder call that uses this field to accept an optional userId.
---
Outside diff comments:
In `@apps/api/src/task/index.ts`:
- Around line 162-201: The POST createTask route is missing Valibot validation
for the URL param projectId; add a validator("param", v.object({ projectId:
v.string() })) middleware to the route chain (before
workspaceAccess.fromProject("projectId") and the async (c) handler) so
c.req.param().projectId is validated; keep the existing body validator and other
middleware order intact.
🪄 Autofix (Beta)
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
Run ID: 03d4f598-1419-4baf-9e14-bd8da17d3c56
⛔ Files ignored due to path filters (3)
apps/api/drizzle/0028_fantastic_thor_girl.sqlis excluded by!apps/api/drizzle/**apps/api/drizzle/meta/0028_snapshot.jsonis excluded by!apps/api/drizzle/**apps/api/drizzle/meta/_journal.jsonis excluded by!apps/api/drizzle/**
📒 Files selected for processing (21)
apps/api/src/database/relations.tsapps/api/src/database/schema.tsapps/api/src/notification/index.tsapps/api/src/schemas.tsapps/api/src/task/controllers/create-task.tsapps/api/src/task/controllers/get-task.tsapps/api/src/task/controllers/get-tasks.tsapps/api/src/task/index.tsapps/web/src/components/kanban-board/task-card.tsxapps/web/src/components/task/task-properties-sidebar.tsxapps/web/src/types/task/index.tsi18n/de-DE.jsoni18n/el-GR.jsoni18n/en-US.jsoni18n/es-ES.jsoni18n/fr-FR.jsoni18n/mk-MK.jsoni18n/nl-NL.jsoni18n/ru-RU.jsoni18n/schema.jsoni18n/uk-UA.json
| createdBy: text("created_by").references(() => userTable.id, { | ||
| onDelete: "cascade", | ||
| onUpdate: "cascade", | ||
| }), |
There was a problem hiding this comment.
Use ON DELETE SET NULL for task.created_by to prevent task data loss.
Line 300 currently cascades deletes from user → task creator, which can remove tasks when a creator account is deleted. For creator attribution, this should null the reference instead of deleting the task.
💡 Suggested fix
- createdBy: text("created_by").references(() => userTable.id, {
- onDelete: "cascade",
- onUpdate: "cascade",
- }),
+ createdBy: text("created_by").references(() => userTable.id, {
+ onDelete: "set null",
+ onUpdate: "cascade",
+ }),📝 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.
| createdBy: text("created_by").references(() => userTable.id, { | |
| onDelete: "cascade", | |
| onUpdate: "cascade", | |
| }), | |
| createdBy: text("created_by").references(() => userTable.id, { | |
| onDelete: "set null", | |
| onUpdate: "cascade", | |
| }), |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/api/src/database/schema.ts` around lines 299 - 302, The task.creator
foreign key currently uses onDelete: "cascade" on the createdBy column
(createdBy: text("created_by").references(() => userTable.id, ...)), which will
delete tasks when a user is removed; change the reference option to onDelete:
"set null" and ensure the createdBy column is defined as nullable (e.g., call
nullable()/allowNull as required by the schema API) so deleted users set
created_by to NULL instead of deleting the task; update any related
type/interface if needed to accept null for createdBy.
| subscribeToEvent<{ | ||
| taskId: string; | ||
| userId: string; | ||
| assigneeId: string | null; | ||
| title: string; | ||
| projectId: string; | ||
| }>("task.created", async (data) => { | ||
| if (data.userId) { | ||
| if (data.assigneeId) { | ||
| await createNotification({ | ||
| userId: data.userId, | ||
| userId: data.assigneeId, | ||
| type: "task_created", |
There was a problem hiding this comment.
Preserve backward compatibility for task.created payloads.
Line 162 requires assigneeId, and Line 166 skips notification when it’s absent. At least one publisher (apps/api/src/task/controllers/import-tasks.ts) still emits task.created without assigneeId, so those assignee notifications are silently dropped.
💡 Suggested compatibility fix
subscribeToEvent<{
taskId: string;
userId: string;
- assigneeId: string | null;
+ assigneeId?: string | null;
title: string;
projectId: string;
}>("task.created", async (data) => {
- if (data.assigneeId) {
+ const recipientId =
+ data.assigneeId === undefined ? data.userId : data.assigneeId;
+
+ if (recipientId) {
await createNotification({
- userId: data.assigneeId,
+ userId: recipientId,
type: "task_created",
eventData: {
taskTitle: data.title,
},
resourceId: data.taskId,
resourceType: "task",
});
}
});📝 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.
| subscribeToEvent<{ | |
| taskId: string; | |
| userId: string; | |
| assigneeId: string | null; | |
| title: string; | |
| projectId: string; | |
| }>("task.created", async (data) => { | |
| if (data.userId) { | |
| if (data.assigneeId) { | |
| await createNotification({ | |
| userId: data.userId, | |
| userId: data.assigneeId, | |
| type: "task_created", | |
| subscribeToEvent<{ | |
| taskId: string; | |
| userId: string; | |
| assigneeId?: string | null; | |
| title: string; | |
| projectId: string; | |
| }>("task.created", async (data) => { | |
| const recipientId = | |
| data.assigneeId === undefined ? data.userId : data.assigneeId; | |
| if (recipientId) { | |
| await createNotification({ | |
| userId: recipientId, | |
| type: "task_created", | |
| eventData: { | |
| taskTitle: data.title, | |
| }, | |
| resourceId: data.taskId, | |
| resourceType: "task", | |
| }); | |
| } | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/api/src/notification/index.ts` around lines 159 - 169, The subscriber
for subscribeToEvent("task.created", ...) currently ignores events that lack
assigneeId; to preserve backward compatibility, make the handler tolerant of
missing assigneeId by resolving the assignee before skipping: when
data.assigneeId is absent, load the task by data.taskId (e.g., via your Task
model/service used elsewhere) to determine its assignee and fall back to that;
then call createNotification({ userId: resolvedAssigneeId, type: "task_created",
... }) only if a resolvedAssigneeId exists. Update the subscribeToEvent handler
(and any related variable names) so it attempts the DB lookup when
data.assigneeId is null/undefined instead of immediately returning.
| userId: createdTask.createdBy ?? createdTask.userId ?? "", | ||
| assigneeId: createdTask.userId ?? null, |
There was a problem hiding this comment.
Avoid publishing userId: "" in task.created events.
Line 90 can emit an empty actor ID, which can break downstream consumers expecting a real user identifier.
💡 Suggested fix
- await publishEvent("task.created", {
- ...createdTask,
- taskId: createdTask.id,
- userId: createdTask.createdBy ?? createdTask.userId ?? "",
- assigneeId: createdTask.userId ?? null,
- type: "task",
- content: null,
- });
+ const actorId = createdTask.createdBy ?? createdTask.userId;
+ if (actorId) {
+ await publishEvent("task.created", {
+ ...createdTask,
+ taskId: createdTask.id,
+ userId: actorId,
+ assigneeId: createdTask.userId ?? null,
+ type: "task",
+ content: null,
+ });
+ }📝 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.
| userId: createdTask.createdBy ?? createdTask.userId ?? "", | |
| assigneeId: createdTask.userId ?? null, | |
| const actorId = createdTask.createdBy ?? createdTask.userId; | |
| if (actorId) { | |
| await publishEvent("task.created", { | |
| ...createdTask, | |
| taskId: createdTask.id, | |
| userId: actorId, | |
| assigneeId: createdTask.userId ?? null, | |
| type: "task", | |
| content: null, | |
| }); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/api/src/task/controllers/create-task.ts` around lines 90 - 91, The event
payload currently sets userId to an empty string (userId: createdTask.createdBy
?? createdTask.userId ?? ""), which can break consumers; change the assignment
in the create-task controller (look for createdTask.createdBy /
createdTask.userId) to omit or set userId to null/undefined when no real ID
exists (e.g., use createdTask.createdBy ?? createdTask.userId ?? undefined) so
no empty string is emitted, and update any event-builder call that uses this
field to accept an optional userId.



Summary
In shared workspaces it is currently impossible to tell who created a task — the only user shown on a card is the assignee. This PR adds an explicit
created_byfield to the task table and surfaces it in the UI.Backend
created_bycolumn ontask(FK →user,ON DELETE SET NULL) with index, plus a Drizzle migration (0028_fantastic_thor_girl.sql).taskTableRelationsgets a newcreatorrelation;userTableRelationsgetscreatedTasks. Bothassigneeandcreatoruse explicitrelationNames so Drizzle can disambiguate the two foreign keys touser.createTaskaccepts an optionalcreatedByand thePOST /:projectIdroute fills it fromc.get("userId"). Existing tasks remainnull(the column is nullable, no backfill).getTaskandgetTasksadd a self-join onuseraliased ascreator_userand exposecreatedBy,creatorName,creatorImagein the response.taskSchema(Valibot, used in OpenAPI) getscreatedBy: nullable(string).update-task,move-task, andbulk-update-tasksare intentionally left untouched —createdByis immutable likecreatedAt.Frontend
Tasktype gets optionalcreatedBy,creatorName,creatorImagefields.task-card.tsx): when the author differs from the assignee, a compact badge with the author's avatar and a localized "Author" label is rendered next to the priority badge. Hovering shows a tooltip with the author's name.task-properties-sidebar.tsx): a read-only author chip is added next to the assignee chip in all three layouts (compact, mobile, desktop).i18n
tasks:creator.label,tasks:creator.unknown,tasks:creator.tooltip(with{{name}}interpolation).i18n/schema.jsonupdated andcreatoradded to thetasksnamespace'srequiredlist.Test plan
pnpm --filter @kaneo/api db:migrateruns the new migration cleanly on an existing databasecreated_byto the current user (verify in DB or viaGET /:id)createdBy: null) and the kanban card renders without the author badgecreatorName/creatorImagereturned from the API rather than disappearingSummary by CodeRabbit
New Features