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>
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { oauthApi } from '@/lib/api';
|
||||
import { useEffect } from 'react';
|
||||
import { useAuth } from '@/hooks';
|
||||
|
||||
interface UseAuthStatusOptions {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function useAuthStatus(options: UseAuthStatusOptions) {
|
||||
return useQuery({
|
||||
const query = useQuery({
|
||||
queryKey: ['auth', 'status'],
|
||||
queryFn: () => oauthApi.status(),
|
||||
enabled: options.enabled,
|
||||
@@ -14,4 +16,13 @@ export function useAuthStatus(options: UseAuthStatusOptions) {
|
||||
retry: 3,
|
||||
staleTime: 0, // Always fetch fresh data when enabled
|
||||
});
|
||||
|
||||
const { isSignedIn } = useAuth();
|
||||
useEffect(() => {
|
||||
if (query) {
|
||||
query.refetch();
|
||||
}
|
||||
}, [isSignedIn, query]);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
23
frontend/src/hooks/auth/useCurrentUser.ts
Normal file
23
frontend/src/hooks/auth/useCurrentUser.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { oauthApi } from '@/lib/api';
|
||||
import { useEffect } from 'react';
|
||||
import { useAuth } from '@/hooks/auth/useAuth';
|
||||
|
||||
export function useCurrentUser() {
|
||||
const { isSignedIn } = useAuth();
|
||||
const query = useQuery({
|
||||
queryKey: ['auth', 'user'],
|
||||
queryFn: () => oauthApi.getCurrentUser(),
|
||||
retry: 2,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
useEffect(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['auth', 'user'] });
|
||||
}, [queryClient, isSignedIn]);
|
||||
|
||||
return query;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export { useTaskAttempts } from './useTaskAttempts';
|
||||
export { useAuth } from './auth/useAuth';
|
||||
export { useAuthMutations } from './auth/useAuthMutations';
|
||||
export { useAuthStatus } from './auth/useAuthStatus';
|
||||
export { useCurrentUser } from './auth/useCurrentUser';
|
||||
export { useUserOrganizations } from './useUserOrganizations';
|
||||
export { useOrganizationSelection } from './useOrganizationSelection';
|
||||
export { useOrganizationMembers } from './useOrganizationMembers';
|
||||
|
||||
38
frontend/src/hooks/useAssigneeUserName.ts
Normal file
38
frontend/src/hooks/useAssigneeUserName.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getSharedTaskAssignees } from '@/lib/remoteApi';
|
||||
import type { SharedTask, UserData } from 'shared/types';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
interface UseAssigneeUserNamesOptions {
|
||||
projectId: string | undefined;
|
||||
sharedTasks?: SharedTask[];
|
||||
}
|
||||
|
||||
export function useAssigneeUserNames(options: UseAssigneeUserNamesOptions) {
|
||||
const { projectId, sharedTasks } = options;
|
||||
|
||||
const { data: assignees, refetch } = useQuery<UserData[], Error>({
|
||||
queryKey: ['project', 'assignees', projectId],
|
||||
queryFn: () => getSharedTaskAssignees(projectId!),
|
||||
enabled: Boolean(projectId),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
const assignedUserIds = useMemo(() => {
|
||||
if (!sharedTasks) return null;
|
||||
return Array.from(
|
||||
new Set(sharedTasks.map((task) => task.assignee_user_id))
|
||||
);
|
||||
}, [sharedTasks]);
|
||||
|
||||
// Refetch when assignee ids change
|
||||
useEffect(() => {
|
||||
if (!assignedUserIds) return;
|
||||
refetch();
|
||||
}, [assignedUserIds, refetch]);
|
||||
|
||||
return {
|
||||
assignees,
|
||||
refetchAssignees: refetch,
|
||||
};
|
||||
}
|
||||
85
frontend/src/hooks/useAutoLinkSharedTasks.ts
Normal file
85
frontend/src/hooks/useAutoLinkSharedTasks.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
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,
|
||||
]);
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useJsonPatchWsStream } from './useJsonPatchWsStream';
|
||||
import { useAuth } from '@/hooks';
|
||||
import { useProject } from '@/contexts/ProjectContext';
|
||||
import { useLiveQuery, eq, isNull } from '@tanstack/react-db';
|
||||
import { sharedTasksCollection } from '@/lib/electric/sharedTasksCollection';
|
||||
import { useAssigneeUserNames } from './useAssigneeUserName';
|
||||
import { useAutoLinkSharedTasks } from './useAutoLinkSharedTasks';
|
||||
import type {
|
||||
SharedTask,
|
||||
TaskStatus,
|
||||
TaskWithAttemptStatus,
|
||||
} from 'shared/types';
|
||||
|
||||
export type SharedTaskRecord = Omit<
|
||||
SharedTask,
|
||||
'version' | 'last_event_seq'
|
||||
> & {
|
||||
version: number;
|
||||
last_event_seq: number | null;
|
||||
created_at: string | Date;
|
||||
updated_at: string | Date;
|
||||
export type SharedTaskRecord = SharedTask & {
|
||||
remote_project_id: string;
|
||||
assignee_first_name?: string | null;
|
||||
assignee_last_name?: string | null;
|
||||
assignee_username?: string | null;
|
||||
@@ -22,7 +21,6 @@ export type SharedTaskRecord = Omit<
|
||||
|
||||
type TasksState = {
|
||||
tasks: Record<string, TaskWithAttemptStatus>;
|
||||
shared_tasks: Record<string, SharedTaskRecord>;
|
||||
};
|
||||
|
||||
export interface UseProjectTasksResult {
|
||||
@@ -43,14 +41,12 @@ export interface UseProjectTasksResult {
|
||||
*/
|
||||
export const useProjectTasks = (projectId: string): UseProjectTasksResult => {
|
||||
const { project } = useProject();
|
||||
const { isSignedIn } = useAuth();
|
||||
const remoteProjectId = project?.remote_project_id;
|
||||
|
||||
const endpoint = `/api/tasks/stream/ws?project_id=${encodeURIComponent(projectId)}&remote_project_id=${encodeURIComponent(remoteProjectId ?? 'null')}`;
|
||||
const endpoint = `/api/tasks/stream/ws?project_id=${encodeURIComponent(projectId)}`;
|
||||
|
||||
const initialData = useCallback(
|
||||
(): TasksState => ({ tasks: {}, shared_tasks: {} }),
|
||||
[]
|
||||
);
|
||||
const initialData = useCallback((): TasksState => ({ tasks: {} }), []);
|
||||
|
||||
const { data, isConnected, error } = useJsonPatchWsStream(
|
||||
endpoint,
|
||||
@@ -58,12 +54,67 @@ export const useProjectTasks = (projectId: string): UseProjectTasksResult => {
|
||||
initialData
|
||||
);
|
||||
|
||||
const localTasksById = useMemo(() => data?.tasks ?? {}, [data?.tasks]);
|
||||
const sharedTasksById = useMemo(
|
||||
() => data?.shared_tasks ?? {},
|
||||
[data?.shared_tasks]
|
||||
const sharedTasksQuery = useLiveQuery(
|
||||
useCallback(
|
||||
(q) => {
|
||||
if (!remoteProjectId || !isSignedIn) {
|
||||
return undefined;
|
||||
}
|
||||
return q
|
||||
.from({ sharedTasks: sharedTasksCollection })
|
||||
.where(({ sharedTasks }) =>
|
||||
eq(sharedTasks.project_id, remoteProjectId)
|
||||
)
|
||||
.where(({ sharedTasks }) => isNull(sharedTasks.deleted_at));
|
||||
},
|
||||
[remoteProjectId, isSignedIn]
|
||||
),
|
||||
[remoteProjectId, isSignedIn]
|
||||
);
|
||||
|
||||
const sharedTasksList = useMemo(
|
||||
() => sharedTasksQuery.data ?? [],
|
||||
[sharedTasksQuery.data]
|
||||
);
|
||||
|
||||
const localTasksById = useMemo(() => data?.tasks ?? {}, [data?.tasks]);
|
||||
|
||||
const referencedSharedIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
Object.values(localTasksById)
|
||||
.map((task) => task.shared_task_id)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
),
|
||||
[localTasksById]
|
||||
);
|
||||
|
||||
const { assignees } = useAssigneeUserNames({
|
||||
projectId: remoteProjectId || undefined,
|
||||
sharedTasks: sharedTasksList,
|
||||
});
|
||||
|
||||
const sharedTasksById = useMemo(() => {
|
||||
if (!sharedTasksList) return {};
|
||||
const map: Record<string, SharedTaskRecord> = {};
|
||||
const list = Array.isArray(sharedTasksList) ? sharedTasksList : [];
|
||||
for (const task of list) {
|
||||
const assignee =
|
||||
task.assignee_user_id && assignees
|
||||
? assignees.find((a) => a.user_id === task.assignee_user_id)
|
||||
: null;
|
||||
map[task.id] = {
|
||||
...task,
|
||||
status: task.status,
|
||||
remote_project_id: task.project_id,
|
||||
assignee_first_name: assignee?.first_name ?? null,
|
||||
assignee_last_name: assignee?.last_name ?? null,
|
||||
assignee_username: assignee?.username ?? null,
|
||||
};
|
||||
}
|
||||
return map;
|
||||
}, [sharedTasksList, assignees]);
|
||||
|
||||
const { tasks, tasksById, tasksByStatus } = useMemo(() => {
|
||||
const merged: Record<string, TaskWithAttemptStatus> = { ...localTasksById };
|
||||
const byStatus: Record<TaskStatus, TaskWithAttemptStatus[]> = {
|
||||
@@ -104,12 +155,6 @@ export const useProjectTasks = (projectId: string): UseProjectTasksResult => {
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
const referencedSharedIds = new Set(
|
||||
Object.values(localTasksById)
|
||||
.map((task) => task.shared_task_id)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
);
|
||||
|
||||
Object.values(sharedTasksById).forEach((sharedTask) => {
|
||||
const hasLocal =
|
||||
Boolean(localTasksById[sharedTask.id]) ||
|
||||
@@ -130,10 +175,20 @@ export const useProjectTasks = (projectId: string): UseProjectTasksResult => {
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}, [localTasksById, sharedTasksById]);
|
||||
}, [localTasksById, sharedTasksById, referencedSharedIds]);
|
||||
|
||||
const isLoading = !data && !error; // until first snapshot
|
||||
|
||||
// Auto-link shared tasks assigned to current user
|
||||
useAutoLinkSharedTasks({
|
||||
sharedTasksById,
|
||||
localTasksById,
|
||||
referencedSharedIds,
|
||||
isLoading,
|
||||
remoteProjectId: project?.remote_project_id || undefined,
|
||||
projectId,
|
||||
});
|
||||
|
||||
return {
|
||||
tasks,
|
||||
tasksById,
|
||||
|
||||
@@ -9,16 +9,18 @@ import type {
|
||||
Task,
|
||||
TaskWithAttemptStatus,
|
||||
UpdateTask,
|
||||
SharedTaskDetails,
|
||||
} from 'shared/types';
|
||||
import { taskKeys } from './useTask';
|
||||
|
||||
export function useTaskMutations(projectId?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigateWithSearch();
|
||||
|
||||
const invalidateQueries = (taskId?: string) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', projectId] });
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.all });
|
||||
if (taskId) {
|
||||
queryClient.invalidateQueries({ queryKey: ['task', taskId] });
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.byId(taskId) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -107,6 +109,19 @@ export function useTaskMutations(projectId?: string) {
|
||||
},
|
||||
});
|
||||
|
||||
const linkSharedTaskToLocal = useMutation({
|
||||
mutationFn: (data: SharedTaskDetails) => tasksApi.linkToLocal(data),
|
||||
onSuccess: (createdTask: Task | null) => {
|
||||
console.log('Linked shared task to local successfully', createdTask);
|
||||
if (createdTask) {
|
||||
invalidateQueries(createdTask.id);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('Failed to link shared task to local:', err);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
createTask,
|
||||
createAndStart,
|
||||
@@ -114,5 +129,6 @@ export function useTaskMutations(projectId?: string) {
|
||||
deleteTask,
|
||||
shareTask,
|
||||
stopShareTask: unshareSharedTask,
|
||||
linkSharedTaskToLocal,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user