Fix QA findings from performance, pentest, and code review

Perf-1: Eliminate duplicate permission query on task update.
get_effective_task_permission now returns (effective, project_level)
tuple so the SEC-P02 allowlist check reuses the project-level
permission from the first call instead of querying again.

Perf-2: Memoize member permission lookup in ProjectDetail. Replace
3 inline acceptedMembers.find() calls with useMemo-derived
myPermission and canEditTasks.

S-06: Pass members/currentUserId/ownerId/canAssign to mobile
TaskDetailPanel (was missing — AssignmentPicker never appeared on
mobile).

S-08: Add missing selectinload(TaskComment.user) to subtask comments
chain in _task_load_options. Subtask comment author_name was always
null.

W-01: useDeltaPoll stores queryKeyToInvalidate in a ref to prevent
infinite re-render if caller passes inline array literal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-17 04:55:47 +08:00
parent e0a5f4855f
commit dd637bdc84
4 changed files with 26 additions and 18 deletions

View File

@ -54,7 +54,7 @@ def _task_load_options():
"""All load options needed for task responses."""
return [
selectinload(ProjectTask.comments).selectinload(TaskComment.user),
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments),
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments).selectinload(TaskComment.user),
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks),
selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user),
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user),
@ -375,7 +375,7 @@ async def update_project_task(
current_user: User = Depends(get_current_user)
):
"""Update a project task. Permission checked at project and task level."""
perm = await get_effective_task_permission(db, current_user.id, task_id, project_id)
perm, project_perm = await get_effective_task_permission(db, current_user.id, task_id, project_id)
if perm is None:
raise HTTPException(status_code=404, detail="Project not found")
if perm == "read_only":
@ -395,7 +395,6 @@ async def update_project_task(
update_data = task_update.model_dump(exclude_unset=True)
# SEC-P02: Assignees (non-owner, non-project-member with create_modify) restricted to content fields
project_perm = await get_project_permission(db, project_id, current_user.id)
if project_perm not in ("owner", "create_modify"):
# This user's create_modify comes from task assignment — enforce allowlist
disallowed = set(update_data.keys()) - ASSIGNEE_ALLOWED_FIELDS

View File

@ -110,19 +110,19 @@ async def validate_project_connections(
async def get_effective_task_permission(
db: AsyncSession, user_id: int, task_id: int, project_id: int
) -> str | None:
) -> tuple[str | None, str | None]:
"""
Returns effective permission for a specific task:
Returns (effective_permission, project_level_permission) for a specific task.
1. Get project-level permission (owner/create_modify/read_only)
2. If user is assigned to THIS task max(project_perm, create_modify)
3. If task has parent and user assigned to PARENT same as above
4. Return effective permission
4. Return (effective, project_level)
"""
project_perm = await get_project_permission(db, project_id, user_id)
if project_perm is None:
return None
return None, None
if project_perm == "owner":
return "owner"
return "owner", "owner"
# Check direct assignment on this task
task_result = await db.execute(
@ -130,7 +130,7 @@ async def get_effective_task_permission(
)
task_row = task_result.one_or_none()
if not task_row:
return project_perm
return project_perm, project_perm
parent_task_id = task_row[0]
@ -148,10 +148,10 @@ async def get_effective_task_permission(
if assignment_result.scalar_one_or_none() is not None:
# Assignment grants at least create_modify
if PERMISSION_RANK.get(project_perm, 0) >= PERMISSION_RANK["create_modify"]:
return project_perm
return "create_modify"
return project_perm, project_perm
return "create_modify", project_perm
return project_perm
return project_perm, project_perm
async def ensure_auto_membership(

View File

@ -165,7 +165,12 @@ export default function ProjectDetail() {
},
enabled: !!id,
});
const acceptedMembers = members.filter((m) => m.status === 'accepted');
const acceptedMembers = useMemo(() => members.filter((m) => m.status === 'accepted'), [members]);
const myPermission = useMemo(
() => acceptedMembers.find((m) => m.user_id === currentUserId)?.permission ?? null,
[acceptedMembers, currentUserId]
);
const canEditTasks = isOwner || myPermission === 'create_modify';
const toggleTaskMutation = useMutation({
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
@ -425,7 +430,7 @@ export default function ProjectDetail() {
{/* Permission badge for non-owners */}
{isShared && (
<Badge className="shrink-0 bg-blue-500/10 text-blue-400 border-0">
{acceptedMembers.find((m) => m.user_id === currentUserId)?.permission === 'create_modify' ? 'Editor' : 'Viewer'}
{myPermission === 'create_modify' ? 'Editor' : 'Viewer'}
</Badge>
)}
{canManageProject && (
@ -602,7 +607,7 @@ export default function ProjectDetail() {
</div>
)}
{(isOwner || acceptedMembers.find((m) => m.user_id === currentUserId)?.permission === 'create_modify') && (
{canEditTasks && (
<Button size="sm" onClick={() => openTaskForm(null, null)}>
<Plus className="mr-2 h-3.5 w-3.5" />
Add Task
@ -713,7 +718,7 @@ export default function ProjectDetail() {
members={acceptedMembers}
currentUserId={currentUserId}
ownerId={project?.user_id ?? 0}
canAssign={isOwner || acceptedMembers.some(m => m.user_id === currentUserId && m.permission === 'create_modify')}
canAssign={canEditTasks}
onDelete={handleDeleteTask}
onAddSubtask={(parentId) => openTaskForm(null, parentId)}
onClose={() => setSelectedTaskId(null)}

View File

@ -22,6 +22,10 @@ export function useDeltaPoll(
) {
const queryClient = useQueryClient();
const sinceRef = useRef<string>(toLocalDatetime());
// Store key in ref to decouple from useEffect deps (prevents infinite loop
// if caller passes an inline array literal instead of a memoized one)
const keyRef = useRef(queryKeyToInvalidate);
keyRef.current = queryKeyToInvalidate;
const { data } = useQuery<PollResponse>({
queryKey: ['delta-poll', endpoint],
@ -39,10 +43,10 @@ export function useDeltaPoll(
useEffect(() => {
if (data?.has_changes) {
queryClient.invalidateQueries({ queryKey: queryKeyToInvalidate });
queryClient.invalidateQueries({ queryKey: keyRef.current });
sinceRef.current = toLocalDatetime();
}
}, [data, queryClient, queryKeyToInvalidate]);
}, [data, queryClient]);
const updateSince = (timestamp: string) => {
sinceRef.current = timestamp;