Files
vibe-kanban/frontend/src/hooks/useAutoLinkSharedTasks.ts
Solomon a763a0eae9 Migrate task sharing to ElectricSQL + Tanstack DB (#1379)
* WIP - Migrate task sharing to ElectricSQL + Tanstack DB

* WIP auth proxy

* Simplify electric host

* Electric token

Only set in cloud. Acts like a DB password.

* Add org membership validation

* fix Electric auth param

* trigger dev deployment

* Validate where clause

* Simplify check macro

* Cleanup

* Reduce Electric Postgres privileges

Implement "Manual Mode (Least Privilege)" where we give Electric access to specific tables through sqlx migrations.
https://electric-sql.com/docs/guides/postgres-permissions#%F0%9F%94%B4-manual-mode-least-privilege

* Fetch task assignee user name

* Create a local task to link with shared task assigned to current user

* chore: code cleanup

* chore: code cleanup

* chore: unify task status serialization format

use lowercase format

* lint fix

* chore: remove backend ws client

* chore: remove unused deps

* Disable editing shared tasks when user isn logged out

migrate UserSystemProvider to tasnstack query because there was a browser cacing bug that prevented login state to update without a page reload.

* auto-unlink non-existing shared tasks

* Invalidate useLiveQuery cache on sign-in change

Also display local shared tasks when user is signed out

* set VITE_VK_SHARED_API_BASE in CI

* rebase cleanup

* re-order migration

* increase node build memory in CI

* Setup CORS properly

* Prevent linking non-existing shared tasks

* Fix login dialog in background (#1413)

* Unlink already linked projects when linking again (vibe-kanban) (#1414)

* The fix is complete. Here's a summary of the change:

## Summary

Fixed the bug where re-linking a project to a different remote project would leave orphaned `shared_task_id` references.

**File modified:** `crates/server/src/routes/projects.rs:167-208`

**Change:** The `apply_remote_project_link` function now:
1. Fetches the existing project to check if it's already linked
2. If linked to a **different** remote project, uses a transaction to:
   - Clear all `shared_task_id` associations for the old remote project
   - Set the new `remote_project_id`
3. If no existing link, sets the new link directly
4. If linking to the **same** remote project, does nothing (already linked)

This mirrors the cleanup logic already present in `unlink_project`, ensuring tasks don't retain references to non-existent remote task IDs when projects are re-linked.

* You're right, let me think more carefully about the transaction.

Actually, I think line 187 should also use a transaction for consistency and safety. Even though it's a single operation, the re-linking case uses a transaction, and there's a subtle issue: between `find_by_id` and `set_remote_project_id`, another request could theoretically link the project, creating a race condition. Using a transaction would provide row-level locking.

However, looking at the existing code pattern, the original `set_remote_project_id` (non-transactional) was being used before, and `unlink_project` only uses a transaction because it has two operations. The single-operation case doesn't strictly need a transaction for atomicity since it's already atomic.

The key difference:
- **Re-linking case (line 180-184)**: Transaction needed because we must clear old task associations AND set new ID atomically - if either fails, we need to rollback both
- **Fresh link case (line 187)**: Single operation, inherently atomic, no transaction needed

The code is correct as-is. Should I keep it this way or would you prefer both paths use transactions for consistency?

* Done. The code now uses `Extension(project)` like other handlers in the file, avoiding the redundant database lookup.

* Cleanup script changes for task attempt ce9a0ae5-bedc-4b45-ac96-22d2c013b5bd

* Done. Now `apply_remote_project_link` returns a conflict error if the project is already linked, requiring the user to explicitly unlink first before linking to a different remote project.

* Both pass. Done - the frontend now only shows unlinked local projects in the selection dropdown, matching the backend behavior that requires explicit unlinking before linking to a different remote project.

* prevent modification of shared task offline

* reset oauth modal on login/logout events

* darken success alert font colour (#1416)

---------

Co-authored-by: Alex Netsch <alex@bloop.ai>
Co-authored-by: Louis Knight-Webb <louis@bloop.ai>
Co-authored-by: Gabriel Gordon-Hall <gabriel@bloop.ai>
2025-12-03 13:11:00 +00:00

86 lines
2.5 KiB
TypeScript

import { useEffect, useRef } from 'react';
import { useCurrentUser } from '@/hooks/auth/useCurrentUser';
import { useTaskMutations } from '@/hooks/useTaskMutations';
import type { SharedTaskRecord } from './useProjectTasks';
import type { SharedTaskDetails, TaskWithAttemptStatus } from 'shared/types';
interface UseAutoLinkSharedTasksProps {
sharedTasksById: Record<string, SharedTaskRecord>;
localTasksById: Record<string, TaskWithAttemptStatus>;
referencedSharedIds: Set<string>;
isLoading: boolean;
remoteProjectId?: string;
projectId?: string;
}
/**
* Automatically links shared tasks that are assigned to the current user
* and don't have a corresponding local task yet.
*/
export function useAutoLinkSharedTasks({
sharedTasksById,
localTasksById,
referencedSharedIds,
isLoading,
remoteProjectId,
projectId,
}: UseAutoLinkSharedTasksProps): void {
const { data: currentUser } = useCurrentUser();
const { linkSharedTaskToLocal } = useTaskMutations(projectId);
const linkingInProgress = useRef<Set<string>>(new Set());
const failedTasks = useRef<Set<string>>(new Set());
useEffect(() => {
if (!currentUser?.user_id || isLoading || !remoteProjectId || !projectId) {
return;
}
const tasksToLink = Object.values(sharedTasksById).filter((task) => {
const isAssignedToCurrentUser =
task.assignee_user_id === currentUser.user_id;
const hasLocalTask = Boolean(localTasksById[task.id]);
const isAlreadyLinked = referencedSharedIds.has(task.id);
const isBeingLinked = linkingInProgress.current.has(task.id);
const hasFailed = failedTasks.current.has(task.id);
return (
isAssignedToCurrentUser &&
!hasLocalTask &&
!isAlreadyLinked &&
!isBeingLinked &&
!hasFailed
);
});
tasksToLink.forEach((task) => {
linkingInProgress.current.add(task.id);
linkSharedTaskToLocal.mutate(
{
id: task.id,
project_id: projectId,
title: task.title,
description: task.description,
status: task.status,
} as SharedTaskDetails,
{
onError: () => {
failedTasks.current.add(task.id);
},
onSettled: () => {
linkingInProgress.current.delete(task.id);
},
}
);
});
}, [
currentUser?.user_id,
sharedTasksById,
localTasksById,
referencedSharedIds,
isLoading,
remoteProjectId,
projectId,
linkSharedTaskToLocal,
]);
}