diff --git a/apps/backend/app/api/routes/tasks/api.py b/apps/backend/app/api/routes/tasks/api.py index 40e6e9be..30e1fd45 100644 --- a/apps/backend/app/api/routes/tasks/api.py +++ b/apps/backend/app/api/routes/tasks/api.py @@ -30,6 +30,7 @@ async def export_tasks( ctx: WorkspaceContext = Depends(require_permission("tasks", "read")), status: str = Query(default="all"), project_id: str = Query(default="all", alias="projectId"), + assignee: str = Query(default="all"), skip: int = Query(default=0, ge=0), limit: int = Query(default=2000, ge=1, le=10000), ) -> list[TaskOut]: @@ -37,6 +38,7 @@ async def export_tasks( ctx=ctx, status_filter=status, project_filter=project_id, + assignee_filter=assignee, skip=skip, limit=limit, ) @@ -55,6 +57,7 @@ async def list_tasks( ctx: WorkspaceContext = Depends(require_permission("tasks", "read")), status: str = Query(default="all"), project_id: str = Query(default="all", alias="projectId"), + assignee: str = Query(default="all"), page: int = Query(default=1, ge=1), page_size: int = Query(default=10, ge=1, le=100, alias="pageSize"), ) -> TaskListResponse: @@ -62,6 +65,7 @@ async def list_tasks( ctx=ctx, status_filter=status, project_filter=project_id, + assignee_filter=assignee, page=page, page_size=page_size, ) diff --git a/apps/backend/app/api/routes/tasks/schema.py b/apps/backend/app/api/routes/tasks/schema.py index 6f7e023b..51480e4c 100644 --- a/apps/backend/app/api/routes/tasks/schema.py +++ b/apps/backend/app/api/routes/tasks/schema.py @@ -67,13 +67,16 @@ class TaskBase(BaseModel): isTimerRunning: bool | None = None timerStartedAt: str | None = None projectId: str | None = None + # Single assignee (a workspace member uid). None = unassigned. + assigneeUid: str | None = None class TaskCreate(BaseModel): - """Maps to ``addTask``: title + optional project.""" + """Maps to ``addTask``: title + optional project / assignee.""" text: str = Field(min_length=1) projectId: str | None = None + assigneeUid: str | None = None class TaskUpdate(BaseModel): @@ -98,6 +101,7 @@ class TaskUpdate(BaseModel): isTimerRunning: bool | None = None timerStartedAt: str | None = None projectId: str | None = None + assigneeUid: str | None = None class TaskStatusUpdate(BaseModel): @@ -129,6 +133,7 @@ class TaskOut(BaseModel): isTimerRunning: bool | None = None timerStartedAt: str | None = None projectId: str | None = None + assigneeUid: str | None = None class TaskListResponse(BaseModel): diff --git a/apps/backend/app/api/routes/tasks/services.py b/apps/backend/app/api/routes/tasks/services.py index 3e360c72..8e0707ec 100644 --- a/apps/backend/app/api/routes/tasks/services.py +++ b/apps/backend/app/api/routes/tasks/services.py @@ -78,6 +78,7 @@ def _task_doc_to_out(doc: dict[str, Any]) -> TaskOut: isTimerRunning=doc.get("isTimerRunning"), timerStartedAt=doc.get("timerStartedAt"), projectId=doc.get("projectId"), + assigneeUid=doc.get("assigneeUid"), ) @@ -93,11 +94,26 @@ def _project_doc_to_out(doc: dict[str, Any]) -> ProjectOut: # ponytail: cache removed during workspace refactor; re-add with (workspace_id, uid) key if hot +def _apply_assignee(base: dict[str, Any], ctx: WorkspaceContext, assignee_filter: str) -> None: + """Mutate ``base`` with an assignee predicate. Supports the literals + ``all`` (no filter), ``me`` (caller), ``unassigned`` (no/None assignee), + or a specific member uid.""" + if not assignee_filter or assignee_filter == "all": + return + if assignee_filter == "unassigned": + base["assigneeUid"] = None + elif assignee_filter == "me": + base["assigneeUid"] = ctx.uid + else: + base["assigneeUid"] = assignee_filter + + async def list_tasks( *, ctx: WorkspaceContext, status_filter: str = "all", project_filter: str = "all", + assignee_filter: str = "all", page: int = 1, page_size: int = 10, ) -> TaskListResponse: @@ -106,6 +122,7 @@ async def list_tasks( base["status"] = status_filter if project_filter and project_filter != "all": base["projectId"] = project_filter + _apply_assignee(base, ctx, assignee_filter) filt = apply_legacy_or_filter(ctx, base, user_field="created_by") total = await db_manager.count_documents(TASKS, filt) total_pages = max(1, (total + page_size - 1) // page_size) if total else 1 @@ -143,6 +160,7 @@ async def export_tasks( ctx: WorkspaceContext, status_filter: str = "all", project_filter: str = "all", + assignee_filter: str = "all", skip: int = 0, limit: int = 2000, ) -> list[TaskOut]: @@ -151,6 +169,7 @@ async def export_tasks( base["status"] = status_filter if project_filter and project_filter != "all": base["projectId"] = project_filter + _apply_assignee(base, ctx, assignee_filter) filt = apply_legacy_or_filter(ctx, base, user_field="created_by") docs = await db_manager.find( TASKS, @@ -174,14 +193,24 @@ async def create_task(ctx: WorkspaceContext, body: TaskCreate) -> TaskOut: "statusOrder": 2, "createdAt": now, "projectId": body.projectId, + "assigneeUid": body.assigneeUid, } await safe_insert(TASKS, doc, name="Task") return _task_doc_to_out(doc) +def _task_scope(ctx: WorkspaceContext, oid: ObjectId) -> dict[str, Any]: + """Row filter for a single task by id. Scopes to the active workspace (any + member may edit shared-workspace tasks; personal workspaces stay owner-locked + via ``owner_uid``) and tolerates legacy pre-migration docs via ``created_by``. + + NOTE: no ``created_by == uid`` clause — collaboration relies on + ``require_permission("tasks", ...)`` for role gating, not creator ownership.""" + return apply_legacy_or_filter(ctx, {"_id": oid}, user_field="created_by") + + async def _assert_task_owner(ctx: WorkspaceContext, oid: ObjectId) -> dict[str, Any]: - filt = apply_workspace_filter(ctx, {"_id": oid, "created_by": ctx.uid}) - doc = await db_manager.find_one(TASKS, filt) + doc = await db_manager.find_one(TASKS, _task_scope(ctx, oid)) if not doc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found.") return doc @@ -198,14 +227,12 @@ async def update_task(ctx: WorkspaceContext, task_id: str, body: TaskUpdate) -> patch["completedAt"] = datetime.now(timezone.utc) if not patch: - filt = apply_workspace_filter(ctx, {"_id": oid, "created_by": ctx.uid}) - doc = await db_manager.find_one(TASKS, filt) + doc = await db_manager.find_one(TASKS, _task_scope(ctx, oid)) if not doc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found.") return _task_doc_to_out(doc) - filt = apply_workspace_filter(ctx, {"_id": oid, "created_by": ctx.uid}) - doc = await safe_update_one(TASKS, filt, patch, name="Task") + doc = await safe_update_one(TASKS, _task_scope(ctx, oid), patch, name="Task") return _task_doc_to_out(doc) @@ -218,8 +245,7 @@ async def update_task_status(ctx: WorkspaceContext, task_id: str, body: TaskStat } if new_status == "completed": patch["completedAt"] = datetime.now(timezone.utc) - filt = apply_workspace_filter(ctx, {"_id": oid, "created_by": ctx.uid}) - doc = await safe_update_one(TASKS, filt, patch, name="Task") + doc = await safe_update_one(TASKS, _task_scope(ctx, oid), patch, name="Task") return _task_doc_to_out(doc) @@ -232,8 +258,7 @@ async def get_task(*, ctx: WorkspaceContext, task_id: str) -> TaskOut: async def delete_task(ctx: WorkspaceContext, task_id: str) -> None: oid = _parse_object_id(task_id, "task id") - filt = apply_workspace_filter(ctx, {"_id": oid, "created_by": ctx.uid}) - await safe_delete_one(TASKS, filt, name="Task") + await safe_delete_one(TASKS, _task_scope(ctx, oid), name="Task") async def import_tasks(ctx: WorkspaceContext, body: TaskImportRequest) -> dict[str, int]: diff --git a/apps/backend/app/core/indexes.py b/apps/backend/app/core/indexes.py index 05e913ec..1917a156 100644 --- a/apps/backend/app/core/indexes.py +++ b/apps/backend/app/core/indexes.py @@ -47,6 +47,8 @@ async def ensure_indexes() -> None: ) await db_manager.create_index(TASKS, [("created_by", 1), ("status", 1), ("statusOrder", 1), ("createdAt", -1)]) await db_manager.create_index(TASKS, [("created_by", 1), ("projectId", 1), ("status", 1), ("statusOrder", 1)]) + # Assignee filter within a shared workspace (Linear-style "assigned to me"). + await db_manager.create_index(TASKS, [("workspace_id", 1), ("assigneeUid", 1), ("statusOrder", 1)]) await db_manager.create_index(PROJECTS, [("created_by", 1), ("createdAt", 1)]) await db_manager.create_index(BOOKMARKS, [("created_by", 1), ("folderId", 1)]) await db_manager.create_index(BOOKMARKS, [("created_by", 1), ("updatedAt", -1)]) diff --git a/apps/backend/tests/test_tasks_workspace_isolation.py b/apps/backend/tests/test_tasks_workspace_isolation.py index 0fc974a7..6992eb87 100644 --- a/apps/backend/tests/test_tasks_workspace_isolation.py +++ b/apps/backend/tests/test_tasks_workspace_isolation.py @@ -1,6 +1,6 @@ import pytest from app.api.routes.tasks import services as task_svc -from app.api.routes.tasks.schema import ProjectCreate, TaskCreate +from app.api.routes.tasks.schema import ProjectCreate, TaskCreate, TaskStatusUpdate, TaskUpdate from app.api.routes.workspaces.middleware import WorkspaceContext @@ -11,6 +11,15 @@ def _ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: ) +def _shared_ctx(uid: str, ws_id: str, org_id: str) -> WorkspaceContext: + """A shared (non-personal) workspace context — no owner_uid lock, so any + member with a writer role can mutate any task in the workspace.""" + return WorkspaceContext( + uid=uid, org_id=org_id, workspace_id=ws_id, ws_role="admin", + is_personal=False, owner_uid=None, + ) + + @pytest.mark.asyncio async def test_tasks_are_isolated_across_personal_workspaces( clean_db, system_org_id, personal_ws_for, @@ -66,3 +75,74 @@ async def test_forged_workspace_id_cannot_cross_task_data( forged_ctx = _ctx("u2", ws_u1, org_id) result = await task_svc.list_tasks(ctx=forged_ctx) assert result.items == [] + + +@pytest.mark.asyncio +async def test_shared_workspace_member_can_edit_anothers_task( + clean_db, system_org_id, +): + """Collaboration unlock: in a shared workspace, member B can edit / move / + delete a task member A created (was blocked by the old created_by filter).""" + org_id = system_org_id + ws = "ws-shared-1" + ctx_a = _shared_ctx("u1", ws, org_id) + ctx_b = _shared_ctx("u2", ws, org_id) + + created = await task_svc.create_task(ctx_a, TaskCreate(text="shared task")) + + # B sees it + listed = await task_svc.list_tasks(ctx=ctx_b) + assert [t.id for t in listed.items] == [created.id] + + # B assigns it to A and renames it + updated = await task_svc.update_task( + ctx_b, created.id, TaskUpdate(text="renamed by B", assigneeUid="u1") + ) + assert updated.text == "renamed by B" + assert updated.assigneeUid == "u1" + + # B moves it across the board + moved = await task_svc.update_task_status( + ctx_b, created.id, TaskStatusUpdate(status="completed") + ) + assert moved.status == "completed" + + # B deletes it + await task_svc.delete_task(ctx_b, created.id) + assert (await task_svc.list_tasks(ctx=ctx_a)).items == [] + + +@pytest.mark.asyncio +async def test_shared_workspace_isolated_from_other_shared_workspace( + clean_db, system_org_id, +): + """A shared workspace's tasks never leak into a different workspace.""" + org_id = system_org_id + ctx_a = _shared_ctx("u1", "ws-shared-A", org_id) + ctx_other = _shared_ctx("u1", "ws-shared-B", org_id) + + await task_svc.create_task(ctx_a, TaskCreate(text="A-only")) + assert (await task_svc.list_tasks(ctx=ctx_other)).items == [] + + +@pytest.mark.asyncio +async def test_assignee_filter(clean_db, system_org_id): + """list_tasks assignee filter supports me / unassigned / specific uid.""" + org_id = system_org_id + ctx = _shared_ctx("u1", "ws-assignee", org_id) + + await task_svc.create_task(ctx, TaskCreate(text="assigned to u2", assigneeUid="u2")) + await task_svc.create_task(ctx, TaskCreate(text="unassigned")) + await task_svc.create_task(ctx, TaskCreate(text="mine", assigneeUid="u1")) + + all_tasks = await task_svc.list_tasks(ctx=ctx) + assert all_tasks.total == 3 + + mine = await task_svc.list_tasks(ctx=ctx, assignee_filter="me") + assert [t.text for t in mine.items] == ["mine"] + + u2 = await task_svc.list_tasks(ctx=ctx, assignee_filter="u2") + assert [t.text for t in u2.items] == ["assigned to u2"] + + unassigned = await task_svc.list_tasks(ctx=ctx, assignee_filter="unassigned") + assert [t.text for t in unassigned.items] == ["unassigned"] diff --git a/apps/web/src/app/[username]/page.tsx b/apps/web/src/app/[username]/page.tsx index 554be53e..31d18611 100644 --- a/apps/web/src/app/[username]/page.tsx +++ b/apps/web/src/app/[username]/page.tsx @@ -16,7 +16,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import Link from 'next/link' import { Logo } from '@/components/logo' import { MdtStatusPage } from '@/components/mdt-status-page' -import { TECH_CATALOG } from '@/components/tech-stack-picker' +import { TECH_CATALOG } from '@/components/tech-catalog' import { GithubStatsSection } from './github-stats-lazy' function StaggerChild({ diff --git a/apps/web/src/app/app/to-do/KanbanCard.tsx b/apps/web/src/app/app/to-do/KanbanCard.tsx index ebc6be90..54beee16 100644 --- a/apps/web/src/app/app/to-do/KanbanCard.tsx +++ b/apps/web/src/app/app/to-do/KanbanCard.tsx @@ -30,6 +30,7 @@ import { } from "@/components/ui/dropdown-menu"; import { STATUS_CONFIG, PRIORITY_CONFIG } from "./config/constants"; import { useProjectContext } from "@/app/app/to-do/context/ProjectContext"; +import { AssigneePicker } from "./components/AssigneePicker"; interface KanbanCardProps { task: Task; @@ -303,6 +304,12 @@ export default function KanbanCard({ task, onUpdateTask, onDeleteTask }: KanbanC onClick={(e) => e.stopPropagation()} className="flex items-center gap-1 flex-shrink-0" > + {/* Assignee */} + onUpdateTask(task.id, { assigneeUid: uid })} + /> + {/* Desktop: Hover Actions */}
+ {/* Assignee — shared workspaces only */} + {isShared && ( +
+ + +
+ )} + {/* Time Estimate */}
diff --git a/apps/web/src/app/app/to-do/TaskItem.tsx b/apps/web/src/app/app/to-do/TaskItem.tsx index 3382258b..4c001090 100644 --- a/apps/web/src/app/app/to-do/TaskItem.tsx +++ b/apps/web/src/app/app/to-do/TaskItem.tsx @@ -40,6 +40,7 @@ import { STATUS_CONFIG, PRIORITY_CONFIG } from "./config/constants"; import { motion, useMotionValue, useTransform, PanInfo } from "framer-motion"; import { useProjectContext } from "@/app/app/to-do/context/ProjectContext"; import { useTranslations } from "next-intl"; +import { AssigneePicker } from "./components/AssigneePicker"; interface TaskItemProps { task: Task; @@ -364,6 +365,13 @@ function TaskItem({ {/* Actions */}
+ {/* Assignee */} + onUpdateTask(task.id, { assigneeUid: uid })} + size="md" + /> + {/* Status Dropdown - Hidden on mobile to save space, accessible via menu */}