Skip to content
Merged

ui #244

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/backend/app/api/routes/tasks/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,15 @@ 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]:
return await task_svc.export_tasks(
ctx=ctx,
status_filter=status,
project_filter=project_id,
assignee_filter=assignee,
skip=skip,
limit=limit,
)
Expand All @@ -55,13 +57,15 @@ 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:
return await task_svc.list_tasks(
ctx=ctx,
status_filter=status,
project_filter=project_id,
assignee_filter=assignee,
page=page,
page_size=page_size,
)
Expand Down
7 changes: 6 additions & 1 deletion apps/backend/app/api/routes/tasks/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
45 changes: 35 additions & 10 deletions apps/backend/app/api/routes/tasks/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)


Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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]:
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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)


Expand All @@ -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)


Expand All @@ -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]:
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/app/core/indexes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)])
Expand Down
82 changes: 81 additions & 1 deletion apps/backend/tests/test_tasks_workspace_isolation.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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,
Expand Down Expand Up @@ -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"]
2 changes: 1 addition & 1 deletion apps/web/src/app/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/app/app/to-do/KanbanCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 */}
<AssigneePicker
assigneeUid={task.assigneeUid}
onChange={(uid) => onUpdateTask(task.id, { assigneeUid: uid })}
/>

{/* Desktop: Hover Actions */}
<div className="hidden md:flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
Expand Down
Loading