diff --git a/echo/docs/modular_component_pattern.md b/echo/docs/modular_component_pattern.md new file mode 100644 index 00000000..9bb260c9 --- /dev/null +++ b/echo/docs/modular_component_pattern.md @@ -0,0 +1,126 @@ +# Modular Component Pattern + +## Overview + +The monolithic `frontend/src/lib/query.ts` file has been **dissected and removed**. We now follow a modular component pattern where each domain has its own directory with components, hooks, and utilities. + +## 🚨 Important Changes + +- **`query.ts` no longer exists** - all functionality moved to modular directories +- **New import patterns** - hooks imported from component directories +- **Domain-based organization** - related functionality grouped together + +## Directory Structure + +Each component domain follows this pattern: + +``` +frontend/src/components/{domain}/ +| +├── ComponentName.tsx # React components +├── hooks/ +│ └── index.ts # Domain-specific React Query hooks +└── utils/ + └── index.ts # Domain-specific utilities +``` + +## Where to Find Functionality + +### Component Domains +- **Participant**: `@/components/participant/hooks` +- **Project**: `@/components/project/hooks` +- **Chat**: `@/components/chat/hooks` +- **Auth**: `@/components/auth/hooks` +and so on... + +## Import Patterns + +### ❌ Old Way (No longer works) +```typescript +import { useProjectById } from '@/lib/query'; +``` + +### ✅ New Way +```typescript +import { useProjectById } from '@/components/project/hooks'; +``` + +## Creating New Components + +### 1. Create Directory Structure +```bash +frontend/src/components/{domain}/ +├── hooks/ +│ └── index.ts +└── utils/ + └── index.ts +``` + +### 2. Export Hooks +```typescript +// frontend/src/components/{domain}/hooks/index.ts +export const useDomainQuery = () => { + // Implementation +}; + +export const useDomainMutation = () => { + // Implementation +}; +``` + +### 3. Import in Components +```typescript +import { useDomainQuery, useDomainMutation } from '@/components/{domain}/hooks'; +``` + +## Guidelines + +### 1. **Organization** +- Keep related functionality together +- Use descriptive names that indicate the domain +- Export all hooks from `hooks/index.ts` + +### 2. **File Naming** +- Components: `PascalCase.tsx` +- Hooks/Utils/Types: `index.ts` (within respective directories) + +### 3. **Code Structure** +- Group mutations and queries logically +- Add JSDoc comments for complex functions +- Follow existing patterns in the codebase + +## Troubleshooting + +### "Cannot find module '@/lib/query'" +- The file no longer exists +- Find the hook in the appropriate component directory +- Use the new import pattern + +### "Hook not found" +- Search the codebase for the hook name +- Check component directories for the relevant domain +- Look in shared library files + +### "Import path error" +- Ensure you're using the correct path format +- Check that the hook is exported from the index.ts file + +## Benefits + +- **Better Organization**: Related functionality grouped together +- **Easier Navigation**: Find specific features quickly +- **Reduced Conflicts**: Less merge conflicts between developers +- **Clear Ownership**: Each domain has its own space +- **Improved Testing**: Test individual domains in isolation +- **Better Scalability**: Easy to add new domains + +## Migration Checklist + +When working with existing code or creating new features: + +- [ ] Check if functionality belongs in a specific component domain +- [ ] Use the appropriate import path for hooks +- [ ] Follow the established directory structure +- [ ] Keep related functionality grouped together +- [ ] Test that imports work correctly +- [ ] Verify that the component follows the modular pattern \ No newline at end of file diff --git a/echo/frontend/src/components/announcement/hooks/index.ts b/echo/frontend/src/components/announcement/hooks/index.ts index 19317826..098ea851 100644 --- a/echo/frontend/src/components/announcement/hooks/index.ts +++ b/echo/frontend/src/components/announcement/hooks/index.ts @@ -7,9 +7,9 @@ import { import * as Sentry from "@sentry/react"; import { Query, readItems, createItems, aggregate } from "@directus/sdk"; import { directus } from "@/lib/directus"; -import { useCurrentUser } from "@/lib/query"; import { toast } from "@/components/common/Toaster"; import { t } from "@lingui/core/macro"; +import { useCurrentUser } from "@/components/auth/hooks"; export const useLatestAnnouncement = () => { const { data: currentUser } = useCurrentUser(); diff --git a/echo/frontend/src/components/aspect/AspectCard.tsx b/echo/frontend/src/components/aspect/AspectCard.tsx index dd8ffde2..88a6eef3 100644 --- a/echo/frontend/src/components/aspect/AspectCard.tsx +++ b/echo/frontend/src/components/aspect/AspectCard.tsx @@ -4,7 +4,7 @@ import { Box, Button, LoadingOverlay, Paper, Stack, Text } from "@mantine/core"; import { IconArrowsDiagonal } from "@tabler/icons-react"; import { useParams } from "react-router-dom"; import { I18nLink } from "@/components/common/i18nLink"; -import { useProjectById } from "@/lib/query"; +import { useProjectById } from "@/components/project/hooks"; export const AspectCard = ({ data, diff --git a/echo/frontend/src/components/auth/hooks/index.ts b/echo/frontend/src/components/auth/hooks/index.ts new file mode 100644 index 00000000..e21a8b48 --- /dev/null +++ b/echo/frontend/src/components/auth/hooks/index.ts @@ -0,0 +1,182 @@ +import { toast } from "@/components/common/Toaster"; +import { useI18nNavigate } from "@/hooks/useI18nNavigate"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { directus } from "@/lib/directus"; +import { + passwordRequest, + passwordReset, + readUser, + registerUser, + registerUserVerify, +} from "@directus/sdk"; +import { ADMIN_BASE_URL } from "@/config"; +import { throwWithMessage } from "../utils/errorUtils"; + +export const useCurrentUser = () => + useQuery({ + queryKey: ["users", "me"], + queryFn: () => { + try { + return directus.request(readUser("me")); + } catch (error) { + return null; + } + }, + }); + +export const useResetPasswordMutation = () => { + const navigate = useI18nNavigate(); + return useMutation({ + mutationFn: async ({ + token, + password, + }: { + token: string; + password: string; + }) => { + try { + const response = await directus.request(passwordReset(token, password)); + return response; + } catch (e) { + throwWithMessage(e); + } + }, + onSuccess: () => { + toast.success( + "Password reset successfully. Please login with new password.", + ); + navigate("/login"); + }, + onError: (e) => { + try { + toast.error(e.message); + } catch (e) { + toast.error("Error resetting password. Please contact support."); + } + }, + }); +}; + +export const useRequestPasswordResetMutation = () => { + const navigate = useI18nNavigate(); + return useMutation({ + mutationFn: async (email: string) => { + try { + const response = await directus.request( + passwordRequest(email, `${ADMIN_BASE_URL}/password-reset`), + ); + return response; + } catch (e) { + throwWithMessage(e); + } + }, + onSuccess: () => { + toast.success("Password reset email sent successfully"); + navigate("/check-your-email"); + }, + onError: (e) => { + toast.error(e.message); + }, + }); +}; + +export const useVerifyMutation = (doRedirect: boolean = true) => { + const navigate = useI18nNavigate(); + + return useMutation({ + mutationFn: async (data: { token: string }) => { + try { + const response = await directus.request(registerUserVerify(data.token)); + return response; + } catch (e) { + throwWithMessage(e); + } + }, + onSuccess: () => { + toast.success("Email verified successfully."); + if (doRedirect) { + setTimeout(() => { + // window.location.href = `/login?new=true`; + navigate(`/login?new=true`); + }, 4500); + } + }, + onError: (e) => { + toast.error(e.message); + }, + }); +}; + +export const useRegisterMutation = () => { + const navigate = useI18nNavigate(); + return useMutation({ + mutationFn: async (payload: Parameters) => { + try { + const response = await directus.request(registerUser(...payload)); + return response; + } catch (e) { + try { + throwWithMessage(e); + } catch (inner) { + if (inner instanceof Error) { + if (inner.message === "You don't have permission to access this.") { + throw new Error( + "Oops! It seems your email is not eligible for registration at this time. Please consider joining our waitlist for future updates!", + ); + } + } + } + } + }, + onSuccess: () => { + toast.success("Please check your email to verify your account."); + navigate("/check-your-email"); + }, + onError: (e) => { + toast.error(e.message); + }, + }); +}; + +// todo: add redirection logic here +export const useLoginMutation = () => { + return useMutation({ + mutationFn: (payload: Parameters) => { + return directus.login(...payload); + }, + onSuccess: () => { + toast.success("Login successful"); + }, + }); +}; + +export const useLogoutMutation = () => { + const queryClient = useQueryClient(); + const navigate = useI18nNavigate(); + + return useMutation({ + mutationFn: async ({ + next: _, + }: { + next?: string; + reason?: string; + doRedirect: boolean; + }) => { + try { + await directus.logout(); + } catch (e) { + throwWithMessage(e); + } + }, + onMutate: async ({ next, reason, doRedirect }) => { + queryClient.resetQueries(); + if (doRedirect) { + navigate( + "/login" + + (next ? `?next=${encodeURIComponent(next)}` : "") + + (reason ? `&reason=${reason}` : ""), + ); + } + }, + }); +}; diff --git a/echo/frontend/src/components/auth/utils/errorUtils.ts b/echo/frontend/src/components/auth/utils/errorUtils.ts new file mode 100644 index 00000000..22b813d7 --- /dev/null +++ b/echo/frontend/src/components/auth/utils/errorUtils.ts @@ -0,0 +1,22 @@ +// always throws a error with a message +export function throwWithMessage(e: unknown): never { + if ( + e && + typeof e === "object" && + "errors" in e && + Array.isArray((e as any).errors) + ) { + // Handle Directus error format + const message = (e as any).errors[0].message; + console.log(message); + throw new Error(message); + } else if (e instanceof Error) { + // Handle generic errors + console.log(e.message); + throw new Error(e.message); + } else { + // Handle unknown errors + console.log("An unknown error occurred"); + throw new Error("Something went wrong"); + } +} diff --git a/echo/frontend/src/components/chat/ChatAccordion.tsx b/echo/frontend/src/components/chat/ChatAccordion.tsx index 3cd500bb..ee8a49e6 100644 --- a/echo/frontend/src/components/chat/ChatAccordion.tsx +++ b/echo/frontend/src/components/chat/ChatAccordion.tsx @@ -1,10 +1,6 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { - useDeleteChatMutation, - useProjectChats, - useUpdateChatMutation, -} from "@/lib/query"; +import { useDeleteChatMutation, useProjectChats, useUpdateChatMutation } from "./hooks"; import { Accordion, ActionIcon, diff --git a/echo/frontend/src/components/chat/ChatContextProgress.tsx b/echo/frontend/src/components/chat/ChatContextProgress.tsx index 45affe40..96905bb0 100644 --- a/echo/frontend/src/components/chat/ChatContextProgress.tsx +++ b/echo/frontend/src/components/chat/ChatContextProgress.tsx @@ -1,8 +1,8 @@ import { t } from "@lingui/core/macro"; -import { useProjectChatContext } from "@/lib/query"; import { capitalize } from "@/lib/utils"; import { Box, Progress, Skeleton, Tooltip } from "@mantine/core"; import { ENABLE_CHAT_AUTO_SELECT } from "@/config"; +import { useProjectChatContext } from "./hooks"; export const ChatContextProgress = ({ chatId }: { chatId: string }) => { const chatContextQuery = useProjectChatContext(chatId); diff --git a/echo/frontend/src/components/chat/hooks/index.ts b/echo/frontend/src/components/chat/hooks/index.ts new file mode 100644 index 00000000..ced8557f --- /dev/null +++ b/echo/frontend/src/components/chat/hooks/index.ts @@ -0,0 +1,149 @@ +import { + getChatHistory, + getProjectChatContext, + lockConversations, +} from "@/lib/api"; +import { directus } from "@/lib/directus"; +import { + Query, + createItem, + deleteItem, + readItem, + readItems, + updateItem, +} from "@directus/sdk"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "@/components/common/Toaster"; + +export const useChatHistory = (chatId: string) => { + return useQuery({ + queryKey: ["chats", "history", chatId], + queryFn: () => getChatHistory(chatId ?? ""), + }); +}; + +export const useAddChatMessageMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: Partial) => + directus.request(createItem("project_chat_message", payload as any)), + onSuccess: (_, vars) => { + queryClient.invalidateQueries({ + queryKey: ["chats", "context", vars.project_chat_id], + }); + queryClient.invalidateQueries({ + queryKey: ["chats", "history", vars.project_chat_id], + }); + }, + }); +}; + +export const useLockConversationsMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: { chatId: string }) => + lockConversations(payload.chatId), + onSuccess: (_, vars) => { + queryClient.invalidateQueries({ + queryKey: ["chats", "context", vars.chatId], + }); + queryClient.invalidateQueries({ + queryKey: ["chats", "history", vars.chatId], + }); + }, + }); +}; + +export const useDeleteChatMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: { chatId: string; projectId: string }) => + directus.request(deleteItem("project_chat", payload.chatId)), + onSuccess: (_, vars) => { + queryClient.invalidateQueries({ + queryKey: ["projects", vars.projectId, "chats"], + }); + queryClient.invalidateQueries({ + queryKey: ["chats", vars.chatId], + }); + toast.success("Chat deleted successfully"); + }, + }); +}; + +export const useUpdateChatMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: { + chatId: string; + // for invalidating the chat query + projectId: string; + payload: Partial; + }) => + directus.request( + updateItem("project_chat", payload.chatId, { + project_id: { + id: payload.projectId, + }, + ...payload.payload, + }), + ), + onSuccess: (_, vars) => { + queryClient.invalidateQueries({ + queryKey: ["projects", vars.projectId, "chats"], + }); + + queryClient.invalidateQueries({ + queryKey: ["chats", vars.chatId], + }); + toast.success("Chat updated successfully"); + }, + }); +}; + +export const useProjectChatContext = (chatId: string) => { + return useQuery({ + queryKey: ["chats", "context", chatId], + queryFn: () => getProjectChatContext(chatId), + enabled: chatId !== "", + }); +}; + +export const useChat = (chatId: string) => { + return useQuery({ + queryKey: ["chats", chatId], + queryFn: () => + directus.request( + readItem("project_chat", chatId, { + fields: [ + "*", + { + used_conversations: ["*"], + }, + ], + }), + ), + }); +}; + +export const useProjectChats = ( + projectId: string, + query?: Partial>, +) => { + return useQuery({ + queryKey: ["projects", projectId, "chats", query], + queryFn: () => + directus.request( + readItems("project_chat", { + fields: ["id", "project_id", "date_created", "date_updated", "name"], + sort: "-date_created", + filter: { + project_id: { + _eq: projectId, + }, + }, + ...query, + }), + ), + }); +}; diff --git a/echo/frontend/src/components/conversation/AutoSelectConversations.tsx b/echo/frontend/src/components/conversation/AutoSelectConversations.tsx index ed065cd7..89a451ae 100644 --- a/echo/frontend/src/components/conversation/AutoSelectConversations.tsx +++ b/echo/frontend/src/components/conversation/AutoSelectConversations.tsx @@ -1,10 +1,6 @@ -import { - useAddChatContextMutation, - useConversationsByProjectId, - useDeleteChatContextMutation, - useProjectById, - useProjectChatContext, -} from "@/lib/query"; +import { useAddChatContextMutation, useConversationsByProjectId, useDeleteChatContextMutation } from "./hooks"; +import { useProjectChatContext } from "@/components/chat/hooks"; +import { useProjectById } from "@/components/project/hooks"; import { Trans } from "@lingui/react/macro"; import { Box, diff --git a/echo/frontend/src/components/conversation/ConversationAccordion.tsx b/echo/frontend/src/components/conversation/ConversationAccordion.tsx index ef1955c8..1b5c2306 100644 --- a/echo/frontend/src/components/conversation/ConversationAccordion.tsx +++ b/echo/frontend/src/components/conversation/ConversationAccordion.tsx @@ -1,15 +1,15 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { Icons } from "@/icons"; +import { useProjectById } from "@/components/project/hooks"; import { useAddChatContextMutation, - useConversationsByProjectId, useDeleteChatContextMutation, - useProjectChatContext, useMoveConversationMutation, - useInfiniteProjects, - useProjectById, -} from "@/lib/query"; + useConversationsByProjectId, +} from "./hooks"; +import { useInfiniteProjects } from "@/components/project/hooks"; +import { useProjectChatContext } from "@/components/chat/hooks"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { Accordion, diff --git a/echo/frontend/src/components/conversation/ConversationChunkAudioTranscript.tsx b/echo/frontend/src/components/conversation/ConversationChunkAudioTranscript.tsx index 314742ff..76734bd1 100644 --- a/echo/frontend/src/components/conversation/ConversationChunkAudioTranscript.tsx +++ b/echo/frontend/src/components/conversation/ConversationChunkAudioTranscript.tsx @@ -2,7 +2,7 @@ import { t } from "@lingui/core/macro"; import { Text, Divider, Skeleton } from "@mantine/core"; import { BaseMessage } from "../chat/BaseMessage"; -import { useConversationChunkContentUrl } from "@/lib/query"; +import { useConversationChunkContentUrl } from "./hooks"; export const ConversationChunkAudioTranscript = ({ chunk, diff --git a/echo/frontend/src/components/conversation/ConversationDangerZone.tsx b/echo/frontend/src/components/conversation/ConversationDangerZone.tsx index 28202e2d..6e65b84d 100644 --- a/echo/frontend/src/components/conversation/ConversationDangerZone.tsx +++ b/echo/frontend/src/components/conversation/ConversationDangerZone.tsx @@ -3,7 +3,7 @@ import { Trans } from "@lingui/react/macro"; import { Button, Group, Stack } from "@mantine/core"; import { useParams } from "react-router-dom"; import { IconDownload, IconTrash } from "@tabler/icons-react"; -import { useDeleteConversationByIdMutation } from "@/lib/query"; +import { useDeleteConversationByIdMutation } from "./hooks"; import { useI18nNavigate } from "@/hooks/useI18nNavigate"; import { MoveConversationButton } from "@/components/conversation/MoveConversationButton"; import { api, getConversationContentLink } from "@/lib/api"; diff --git a/echo/frontend/src/components/conversation/ConversationEdit.tsx b/echo/frontend/src/components/conversation/ConversationEdit.tsx index addf6f9b..e8e88715 100644 --- a/echo/frontend/src/components/conversation/ConversationEdit.tsx +++ b/echo/frontend/src/components/conversation/ConversationEdit.tsx @@ -14,7 +14,7 @@ import { Controller, useForm } from "react-hook-form"; import { useUpdateConversationByIdMutation, useUpdateConversationTagsMutation, -} from "@/lib/query"; +} from "./hooks"; import { CloseableAlert } from "../common/ClosableAlert"; import { useAutoSave } from "@/hooks/useAutoSave"; import { FormLabel } from "../form/FormLabel"; diff --git a/echo/frontend/src/components/conversation/MoveConversationButton.tsx b/echo/frontend/src/components/conversation/MoveConversationButton.tsx index 1838f9f7..09d230d6 100644 --- a/echo/frontend/src/components/conversation/MoveConversationButton.tsx +++ b/echo/frontend/src/components/conversation/MoveConversationButton.tsx @@ -18,7 +18,8 @@ import { useEffect, useState } from "react"; import { useInView } from "react-intersection-observer"; import { IconArrowsExchange, IconSearch } from "@tabler/icons-react"; import { FormLabel } from "@/components/form/FormLabel"; -import { useInfiniteProjects, useMoveConversationMutation } from "@/lib/query"; +import { useMoveConversationMutation } from "./hooks"; +import { useInfiniteProjects } from "@/components/project/hooks"; import { Trans } from "@lingui/react/macro"; import { useI18nNavigate } from "@/hooks/useI18nNavigate"; import { useParams } from "react-router-dom"; diff --git a/echo/frontend/src/components/conversation/OpenForParticipationSummaryCard.tsx b/echo/frontend/src/components/conversation/OpenForParticipationSummaryCard.tsx index 38633f5e..02ecfa31 100644 --- a/echo/frontend/src/components/conversation/OpenForParticipationSummaryCard.tsx +++ b/echo/frontend/src/components/conversation/OpenForParticipationSummaryCard.tsx @@ -2,7 +2,7 @@ import { t } from "@lingui/core/macro"; import { Checkbox, Switch, Tooltip } from "@mantine/core"; import { Icons } from "@/icons"; import { SummaryCard } from "../common/SummaryCard"; -import { useProjectById, useUpdateProjectByIdMutation } from "@/lib/query"; +import { useProjectById, useUpdateProjectByIdMutation } from "@/components/project/hooks"; interface OpenForParticipationSummaryCardProps { projectId: string; diff --git a/echo/frontend/src/components/conversation/hooks/index.ts b/echo/frontend/src/components/conversation/hooks/index.ts new file mode 100644 index 00000000..4db57d15 --- /dev/null +++ b/echo/frontend/src/components/conversation/hooks/index.ts @@ -0,0 +1,720 @@ +import { directus } from "@/lib/directus"; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, + UseQueryOptions, +} from "@tanstack/react-query"; +import { + Query, + createItems, + deleteItems, + readItem, + readItems, + updateItem, +} from "@directus/sdk"; +import { + addChatContext, + apiNoAuth, + deleteChatContext, + deleteConversationById, + getConversationChunkContentLink, + getConversationTranscriptString, + retranscribeConversation, +} from "@/lib/api"; +import { toast } from "@/components/common/Toaster"; +import * as Sentry from "@sentry/react"; +import { AxiosError } from "axios"; +import { t } from "@lingui/core/macro"; + +export const useInfiniteConversationChunks = ( + conversationId: string, + options?: { + initialLimit?: number; + refetchInterval?: number | false; + }, +) => { + const defaultOptions = { + initialLimit: 10, + refetchInterval: 30000, + }; + + const { initialLimit, refetchInterval } = { ...defaultOptions, ...options }; + + return useInfiniteQuery({ + queryKey: ["conversations", conversationId, "chunks", "infinite"], + queryFn: async ({ pageParam = 0 }) => { + const response = await directus.request( + readItems("conversation_chunk", { + filter: { + conversation_id: { + _eq: conversationId, + }, + }, + sort: ["timestamp"], + limit: initialLimit, + offset: pageParam * initialLimit, + }), + ); + + return { + chunks: response, + nextOffset: + response.length === initialLimit ? pageParam + 1 : undefined, + }; + }, + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextOffset, + refetchInterval, + }); +}; + +export const useUpdateConversationByIdMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + id, + payload, + }: { + id: string; + payload: Partial; + }) => + directus.request(updateItem("conversation", id, payload)), + onSuccess: (values, variables) => { + queryClient.setQueryData( + ["conversations", variables.id], + (oldData: Conversation | undefined) => { + return { + ...oldData, + ...values, + }; + }, + ); + queryClient.invalidateQueries({ + queryKey: ["conversations"], + }); + }, + }); +}; + +// you always need to provide all the tags +export const useUpdateConversationTagsMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + conversationId, + projectId, + projectTagIdList, + }: { + projectId: string; + conversationId: string; + projectTagIdList: string[]; + }) => { + let validTagsIds: string[] = []; + try { + const validTags = await directus.request( + readItems("project_tag", { + filter: { + id: { + _in: projectTagIdList, + }, + project_id: { + _eq: projectId, + }, + }, + fields: ["*"], + }), + ); + + validTagsIds = validTags.map((tag) => tag.id); + } catch (error) { + validTagsIds = []; + } + + const tagsRequest = await directus.request( + readItems("conversation_project_tag", { + fields: [ + "id", + { + project_tag_id: ["id"], + }, + { + conversation_id: ["id"], + }, + ], + filter: { + conversation_id: { _eq: conversationId }, + }, + }), + ); + + const needToDelete = tagsRequest.filter( + (conversationProjectTag) => + conversationProjectTag.project_tag_id && + !validTagsIds.includes( + (conversationProjectTag.project_tag_id as ProjectTag).id, + ), + ); + + const needToCreate = validTagsIds.filter( + (tagId) => + !tagsRequest.some( + (conversationProjectTag) => + (conversationProjectTag.project_tag_id as ProjectTag).id === + tagId, + ), + ); + + // slightly esoteric, but basically we only want to delete if there are any tags to delete + // otherwise, directus doesn't accept an empty array + const deletePromise = + needToDelete.length > 0 + ? directus.request( + deleteItems( + "conversation_project_tag", + needToDelete.map((tag) => tag.id), + ), + ) + : Promise.resolve(); + + // same deal for creating + const createPromise = + needToCreate.length > 0 + ? directus.request( + createItems( + "conversation_project_tag", + needToCreate.map((tagId) => ({ + conversation_id: { + id: conversationId, + } as Conversation, + project_tag_id: { + id: tagId, + } as ProjectTag, + })), + ), + ) + : Promise.resolve(); + + // await both promises + await Promise.all([deletePromise, createPromise]); + + return directus.request( + readItem("conversation", conversationId, { + fields: ["*"], + }), + ); + }, + onSuccess: (_values, variables) => { + queryClient.invalidateQueries({ + queryKey: ["conversations", variables.conversationId], + }); + queryClient.invalidateQueries({ + queryKey: ["projects", variables.projectId], + }); + }, + }); +}; + +export const useDeleteConversationByIdMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: deleteConversationById, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["projects"], + }); + queryClient.invalidateQueries({ + queryKey: ["conversations"], + }); + toast.success("Conversation deleted successfully"); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +}; + +export const useMoveConversationMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + conversationId, + targetProjectId, + }: { + conversationId: string; + targetProjectId: string; + }) => { + try { + await directus.request( + updateItem("conversation", conversationId, { + project_id: targetProjectId, + }), + ); + } catch (error) { + toast.error("Failed to move conversation."); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["conversations"] }); + queryClient.invalidateQueries({ queryKey: ["projects"] }); + toast.success("Conversation moved successfully"); + }, + onError: (error: Error) => { + toast.error("Failed to move conversation: " + error.message); + }, + }); +}; + +export const useAddChatContextMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: { + chatId: string; + conversationId?: string; + auto_select_bool?: boolean; + }) => + addChatContext( + payload.chatId, + payload.conversationId, + payload.auto_select_bool, + ), + onMutate: async (variables) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ + queryKey: ["chats", "context", variables.chatId], + }); + + // Snapshot the previous value + const previousChatContext = queryClient.getQueryData([ + "chats", + "context", + variables.chatId, + ]); + + // Optimistically update the chat context + let optimisticId: string | undefined = undefined; + queryClient.setQueryData( + ["chats", "context", variables.chatId], + (oldData: TProjectChatContext | undefined) => { + if (!oldData) return oldData; + + // If conversationId is provided, add it to the conversations array + if (variables.conversationId) { + const existingConversation = oldData.conversations.find( + (conv) => conv.conversation_id === variables.conversationId, + ); + + if (!existingConversation) { + optimisticId = "optimistic-" + Date.now() + Math.random(); + return { + ...oldData, + conversations: [ + ...oldData.conversations, + { + conversation_id: variables.conversationId, + conversation_participant_name: t`Loading...`, + locked: false, + token_usage: 0, + optimisticId, + }, + ], + }; + } + } + + // If auto_select_bool is provided, update it + if (variables.auto_select_bool !== undefined) { + return { + ...oldData, + auto_select_bool: variables.auto_select_bool, + }; + } + + return oldData; + }, + ); + + // Return a context object with the snapshotted value + return { + previousChatContext, + optimisticId, + conversationId: variables.conversationId ?? undefined, + }; + }, + onError: (error, variables, context) => { + Sentry.captureException(error); + + // Only rollback the failed optimistic entry + if (context?.optimisticId && context?.conversationId) { + queryClient.setQueryData( + ["chats", "context", variables.chatId], + (oldData: TProjectChatContext | undefined) => { + if (!oldData) return oldData; + return { + ...oldData, + conversations: oldData.conversations.filter( + (conv) => + conv.conversation_id !== context.conversationId || + conv.optimisticId !== context.optimisticId, + ), + }; + }, + ); + } else if (context?.previousChatContext) { + // fallback: full rollback + queryClient.setQueryData( + ["chats", "context", variables.chatId], + context.previousChatContext, + ); + } + + if (error instanceof AxiosError) { + let errorMessage = t`Failed to add conversation to chat${ + error.response?.data?.detail ? `: ${error.response.data.detail}` : "" + }`; + if (variables.auto_select_bool) { + errorMessage = t`Failed to enable Auto Select for this chat`; + } + toast.error(errorMessage); + } else { + let errorMessage = t`Failed to add conversation to chat`; + if (variables.auto_select_bool) { + errorMessage = t`Failed to enable Auto Select for this chat`; + } + toast.error(errorMessage); + } + }, + onSettled: (_, __, variables) => { + queryClient.invalidateQueries({ + queryKey: ["chats", "context", variables.chatId], + }); + }, + onSuccess: (_, variables) => { + const message = variables.auto_select_bool + ? t`Auto-select enabled` + : t`Conversation added to chat`; + toast.success(message); + }, + }); +}; + +export const useDeleteChatContextMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: { + chatId: string; + conversationId?: string; + auto_select_bool?: boolean; + }) => + deleteChatContext( + payload.chatId, + payload.conversationId, + payload.auto_select_bool, + ), + onMutate: async (variables) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ + queryKey: ["chats", "context", variables.chatId], + }); + + // Snapshot the previous value + const previousChatContext = queryClient.getQueryData([ + "chats", + "context", + variables.chatId, + ]); + + // Optimistically update the chat context + queryClient.setQueryData( + ["chats", "context", variables.chatId], + (oldData: TProjectChatContext | undefined) => { + if (!oldData) return oldData; + + // If conversationId is provided, remove it from the conversations array + if (variables.conversationId) { + const conversationToRemove = oldData.conversations.find( + (conv) => conv.conversation_id === variables.conversationId, + ); + + if (conversationToRemove) { + return { + ...oldData, + conversations: oldData.conversations.filter( + (conv) => conv.conversation_id !== variables.conversationId, + ), + }; + } + } + + // If auto_select_bool is provided, update it + if (variables.auto_select_bool !== undefined) { + return { + ...oldData, + auto_select_bool: variables.auto_select_bool, + }; + } + + return oldData; + }, + ); + + // Return a context object with the snapshotted value + return { + previousChatContext, + conversationId: variables.conversationId ?? undefined, + }; + }, + onError: (error, variables, context) => { + Sentry.captureException(error); + + // Only rollback the failed optimistic entry + if (context?.conversationId) { + queryClient.setQueryData( + ["chats", "context", variables.chatId], + (oldData: TProjectChatContext | undefined) => { + if (!oldData) return oldData; + + // Find the conversation that was removed optimistically + const previousContext = context.previousChatContext as + | TProjectChatContext + | undefined; + const removedConversation = previousContext?.conversations?.find( + (conv) => conv.conversation_id === context.conversationId, + ); + + if (removedConversation) { + return { + ...oldData, + conversations: [ + ...oldData.conversations, + { + ...removedConversation, + }, + ], + }; + } + + return oldData; + }, + ); + } else if (context?.previousChatContext) { + // fallback: full rollback + queryClient.setQueryData( + ["chats", "context", variables.chatId], + context.previousChatContext, + ); + } + + if (error instanceof AxiosError) { + let errorMessage = t`Failed to remove conversation from chat${ + error.response?.data?.detail ? `: ${error.response.data.detail}` : "" + }`; + if (variables.auto_select_bool === false) { + errorMessage = t`Failed to disable Auto Select for this chat`; + } + toast.error(errorMessage); + } else { + let errorMessage = t`Failed to remove conversation from chat`; + if (variables.auto_select_bool === false) { + errorMessage = t`Failed to disable Auto Select for this chat`; + } + toast.error(errorMessage); + } + }, + onSettled: (_, __, variables) => { + queryClient.invalidateQueries({ + queryKey: ["chats", "context", variables.chatId], + }); + }, + onSuccess: (_, variables) => { + const message = + variables.auto_select_bool === false + ? t`Auto-select disabled` + : t`Conversation removed from chat`; + toast.success(message); + }, + }); +}; + +export const useConversationChunkContentUrl = ( + conversationId: string, + chunkId: string, + enabled: boolean = true, +) => { + return useQuery({ + queryKey: ["conversation", conversationId, "chunk", chunkId, "audio-url"], + queryFn: async () => { + const url = getConversationChunkContentLink( + conversationId, + chunkId, + true, + ); + return apiNoAuth.get(url); + }, + enabled, + staleTime: 1000 * 60 * 30, // 30 minutes + gcTime: 1000 * 60 * 60, // 1 hour + }); +}; + +export const useRetranscribeConversationMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + conversationId, + newConversationName, + }: { + conversationId: string; + newConversationName: string; + }) => retranscribeConversation(conversationId, newConversationName), + onSuccess: (_data) => { + // Invalidate all conversation related queries + queryClient.invalidateQueries({ + queryKey: ["conversations"], + }); + + // Toast success message + toast.success( + t`Retranscription started. New conversation will be available soon.`, + ); + }, + onError: (error) => { + toast.error(t`Failed to retranscribe conversation. Please try again.`); + console.error("Retranscribe error:", error); + }, + }); +}; + +export const useConversationTranscriptString = (conversationId: string) => { + return useQuery({ + queryKey: ["conversations", conversationId, "transcript"], + queryFn: () => getConversationTranscriptString(conversationId), + }); +}; + +export const useConversationChunks = ( + conversationId: string, + refetchInterval: number = 10000, +) => { + return useQuery({ + queryKey: ["conversations", conversationId, "chunks"], + queryFn: () => + directus.request( + readItems("conversation_chunk", { + filter: { + conversation_id: { + _eq: conversationId, + }, + }, + sort: "timestamp", + }), + ), + refetchInterval, + }); +}; + +export const useConversationsByProjectId = ( + projectId: string, + loadChunks?: boolean, + // unused + loadWhereTranscriptExists?: boolean, + query?: Partial>, + filterBySource?: string[], +) => { + return useQuery({ + queryKey: [ + "projects", + projectId, + "conversations", + loadChunks ? "chunks" : "no-chunks", + loadWhereTranscriptExists ? "transcript" : "no-transcript", + query, + filterBySource, + ], + queryFn: () => + directus.request( + readItems("conversation", { + sort: "-updated_at", + fields: [ + "*", + { + tags: [ + { + project_tag_id: ["id", "text", "created_at"], + }, + ], + }, + { chunks: ["*"] }, + ], + deep: { + // @ts-expect-error chunks is not typed + chunks: { + _limit: loadChunks ? 1000 : 1, + }, + }, + filter: { + project_id: { + _eq: projectId, + }, + chunks: { + ...(loadWhereTranscriptExists && { + _some: { + transcript: { + _nempty: true, + }, + }, + }), + }, + ...(filterBySource && { + source: { + _in: filterBySource, + }, + }), + }, + limit: 1000, + ...query, + }), + ), + refetchInterval: 30000, + }); +}; + +export const useConversationById = ({ + conversationId, + loadConversationChunks = false, + query = {}, + useQueryOpts = { + refetchInterval: 10000, + }, +}: { + conversationId: string; + loadConversationChunks?: boolean; + // query overrides the default query and loadChunks + query?: Partial>; + useQueryOpts?: Partial>; +}) => { + return useQuery({ + queryKey: ["conversations", conversationId, loadConversationChunks, query], + queryFn: () => + directus.request( + readItem("conversation", conversationId, { + fields: [ + "*", + { + tags: [ + { + project_tag_id: ["id", "text", "created_at"], + }, + ], + }, + ...(loadConversationChunks ? [{ chunks: ["*"] as any }] : []), + ], + ...query, + }), + ), + ...useQueryOpts, + }); +}; diff --git a/echo/frontend/src/components/dropzone/UploadConversationDropzone.tsx b/echo/frontend/src/components/dropzone/UploadConversationDropzone.tsx index 90d9c36b..f26e31d0 100644 --- a/echo/frontend/src/components/dropzone/UploadConversationDropzone.tsx +++ b/echo/frontend/src/components/dropzone/UploadConversationDropzone.tsx @@ -1,5 +1,6 @@ import { t } from "@lingui/core/macro"; -import { useProjectById, useConversationUploader } from "@/lib/query"; +import { useProjectById } from "@/components/project/hooks"; +import { useConversationUploader } from "./hooks"; import { LoadingOverlay, Modal, diff --git a/echo/frontend/src/components/dropzone/hooks/index.ts b/echo/frontend/src/components/dropzone/hooks/index.ts new file mode 100644 index 00000000..f4bce8d1 --- /dev/null +++ b/echo/frontend/src/components/dropzone/hooks/index.ts @@ -0,0 +1,216 @@ +import { + initiateAndUploadConversationChunk, + uploadResourceByProjectId, +} from "@/lib/api"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "@/components/common/Toaster"; +import { useCallback, useEffect, useRef, useState } from "react"; + +export const useUploadConversation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (payload: { + projectId: string; + pin: string; + namePrefix: string; + tagIdList: string[]; + chunks: Blob[]; + timestamps: Date[]; + email?: string; + onProgress?: (fileName: string, progress: number) => void; + }) => initiateAndUploadConversationChunk(payload), + onMutate: () => { + // When the mutation starts, cancel any in-progress queries + // to prevent them from overwriting our optimistic update + queryClient.cancelQueries({ queryKey: ["conversations"] }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["conversations"], + }); + queryClient.invalidateQueries({ + queryKey: ["projects"], + }); + toast.success("Conversation(s) uploaded successfully"); + }, + onError: (error) => { + toast.error( + `Upload failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + }, + retry: 3, // Reduced retry count to avoid too many duplicate attempts + }); +}; + +export const useUploadResourceByProjectIdMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: uploadResourceByProjectId, + retry: 3, + onSuccess: (_values, variables) => { + const projectId = variables.projectId; + queryClient.invalidateQueries({ + queryKey: ["projects", projectId, "resources"], + }); + toast.success("Resource uploaded successfully"); + }, + }); +}; + +// Higher-level hook for managing conversation uploads with better state control +export const useConversationUploader = () => { + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState>( + {}, + ); + const [uploadErrors, setUploadErrors] = useState>({}); + const uploadMutation = useUploadConversation(); + // Use a ref to track if we've completed the upload to avoid multiple state updates + const hasCompletedRef = useRef(false); + // Use refs to track previous state to avoid unnecessary updates + const progressRef = useRef>({}); + const errorsRef = useRef>({}); + + // Clean up function to reset states + const resetUpload = useCallback(() => { + hasCompletedRef.current = false; + progressRef.current = {}; + errorsRef.current = {}; + setIsUploading(false); + setUploadProgress({}); + setUploadErrors({}); + uploadMutation.reset(); + }, [uploadMutation]); + + // Handle real progress updates with debouncing + const handleProgress = useCallback((fileName: string, progress: number) => { + // Only update if progress actually changed by at least 1% + if (Math.abs((progressRef.current[fileName] || 0) - progress) < 1) { + return; // Skip tiny updates that don't matter visually + } + + // Update the ref and then the state + progressRef.current = { + ...progressRef.current, + [fileName]: progress, + }; + + setUploadProgress((prev) => ({ + ...prev, + [fileName]: progress, + })); + }, []); + + // Upload files with real progress tracking + const uploadFiles = useCallback( + (payload: { + projectId: string; + pin: string; + namePrefix: string; + tagIdList: string[]; + chunks: Blob[]; + timestamps: Date[]; + email?: string; + }) => { + // Don't start if already uploading + if (isUploading || uploadMutation.isPending) { + return; + } + + hasCompletedRef.current = false; + + // Initialize progress tracking for all files + const initialProgress: Record = {}; + payload.chunks.forEach((chunk) => { + const name = + chunk instanceof File + ? chunk.name + : `chunk-${payload.chunks.indexOf(chunk)}`; + initialProgress[name] = 0; + }); + + // Update refs first + progressRef.current = initialProgress; + errorsRef.current = {}; + + // Then update state + setUploadProgress(initialProgress); + setUploadErrors({}); + setIsUploading(true); + + // Start the upload with progress tracking + uploadMutation.mutate({ + ...payload, + onProgress: handleProgress, + }); + }, + [isUploading, uploadMutation, handleProgress], + ); + + // Handle success state - separate from error handling to prevent cycles + useEffect(() => { + // Skip if conditions aren't right + if (!isUploading || !uploadMutation.isSuccess || hasCompletedRef.current) { + return; + } + + // Set flag to prevent repeated updates + hasCompletedRef.current = true; + + // Mark all files as complete when successful + const fileNames = Object.keys(progressRef.current); + if (fileNames.length > 0) { + // Update refs first + const completed = { ...progressRef.current }; + fileNames.forEach((key) => { + completed[key] = 100; + }); + progressRef.current = completed; + + // Then update state - do this once rather than per file + setUploadProgress(completed); + } + }, [uploadMutation.isSuccess, isUploading]); + + // Handle error state separately + useEffect(() => { + // Skip if conditions aren't right + if (!isUploading || !uploadMutation.isError) { + return; + } + + // Only do this once + if (Object.keys(errorsRef.current).length > 0) { + return; + } + + // Set errors on failure + const fileNames = Object.keys(progressRef.current); + if (fileNames.length > 0) { + // Update refs first + const newErrors = { ...errorsRef.current }; + const errorMessage = uploadMutation.error?.message || "Upload failed"; + + fileNames.forEach((key) => { + newErrors[key] = errorMessage; + }); + errorsRef.current = newErrors; + + // Then update state - do this once rather than per file + setUploadErrors(newErrors); + } + }, [uploadMutation.isError, isUploading, uploadMutation.error]); + + return { + uploadFiles, + resetUpload, + isUploading, + uploadProgress, + uploadErrors, + isSuccess: uploadMutation.isSuccess, + isError: uploadMutation.isError, + isPending: uploadMutation.isPending, + error: uploadMutation.error, + }; +}; diff --git a/echo/frontend/src/components/layout/Header.tsx b/echo/frontend/src/components/layout/Header.tsx index bba72af9..f0770364 100644 --- a/echo/frontend/src/components/layout/Header.tsx +++ b/echo/frontend/src/components/layout/Header.tsx @@ -17,7 +17,7 @@ import { IconNotes, IconSettings, } from "@tabler/icons-react"; -import { useCurrentUser, useLogoutMutation } from "@/lib/query"; +import { useCurrentUser, useLogoutMutation } from "@/components/auth/hooks"; import { useAuthenticated } from "@/hooks/useAuthenticated"; import { I18nLink } from "@/components/common/i18nLink"; import { LanguagePicker } from "../language/LanguagePicker"; diff --git a/echo/frontend/src/components/layout/ProjectConversationLayout.tsx b/echo/frontend/src/components/layout/ProjectConversationLayout.tsx index d2384ba4..776a50f4 100644 --- a/echo/frontend/src/components/layout/ProjectConversationLayout.tsx +++ b/echo/frontend/src/components/layout/ProjectConversationLayout.tsx @@ -1,9 +1,9 @@ import { t } from "@lingui/core/macro"; -import { useConversationById } from "@/lib/query"; import { Stack, Title } from "@mantine/core"; import { useParams } from "react-router-dom"; import { TabsWithRouter } from "./TabsWithRouter"; import { ConversationStatusIndicators } from "../conversation/ConversationAccordion"; +import { useConversationById } from "../conversation/hooks"; export const ProjectConversationLayout = () => { const { conversationId } = useParams(); diff --git a/echo/frontend/src/components/layout/ProjectOverviewLayout.tsx b/echo/frontend/src/components/layout/ProjectOverviewLayout.tsx index 19299cc2..1aac5ddf 100644 --- a/echo/frontend/src/components/layout/ProjectOverviewLayout.tsx +++ b/echo/frontend/src/components/layout/ProjectOverviewLayout.tsx @@ -1,5 +1,5 @@ import { t } from "@lingui/core/macro"; -import { useProjectById } from "@/lib/query"; +import { useProjectById } from "@/components/project/hooks"; import { Box, Divider, LoadingOverlay, Stack } from "@mantine/core"; import { useParams } from "react-router-dom"; import { TabsWithRouter } from "./TabsWithRouter"; diff --git a/echo/frontend/src/components/library/hooks/index.ts b/echo/frontend/src/components/library/hooks/index.ts new file mode 100644 index 00000000..465a63e3 --- /dev/null +++ b/echo/frontend/src/components/library/hooks/index.ts @@ -0,0 +1,74 @@ +import { + generateProjectLibrary, + getProjectViews, +} from "@/lib/api"; +import { directus } from "@/lib/directus"; +import { readItem } from "@directus/sdk"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "@/components/common/Toaster"; + +export const useProjectViews = (projectId: string) => { + return useQuery({ + queryKey: ["projects", projectId, "views"], + queryFn: () => getProjectViews(projectId), + refetchInterval: 20000, + }); +}; + +export const useViewById = (projectId: string, viewId: string) => { + return useQuery({ + queryKey: ["projects", projectId, "views", viewId], + queryFn: () => + directus.request( + readItem("view", viewId, { + fields: [ + "*", + { + aspects: ["*", "count(aspect_segment)"], + }, + ], + deep: { + // get the aspects that have at least one aspect segment + aspects: { + _sort: "-count(aspect_segment)", + } as any, + }, + }), + ), + }); +}; + +export const useAspectById = (projectId: string, aspectId: string) => { + return useQuery({ + queryKey: ["projects", projectId, "aspects", aspectId], + queryFn: () => + directus.request( + readItem("aspect", aspectId, { + fields: [ + "*", + { + "aspect_segment": [ + "*", + { + "segment": [ + "*", + ] + } + ] + } + ], + }), + ), + }); +}; + +export const useGenerateProjectLibraryMutation = () => { + const client = useQueryClient(); + return useMutation({ + mutationFn: generateProjectLibrary, + onSuccess: (_, variables) => { + toast.success("Analysis requested successfully"); + client.invalidateQueries({ queryKey: ["projects", variables.projectId] }); + }, + }); +}; diff --git a/echo/frontend/src/components/participant/ParticipantInitiateForm.tsx b/echo/frontend/src/components/participant/ParticipantInitiateForm.tsx index 8be921de..859a3ca8 100644 --- a/echo/frontend/src/components/participant/ParticipantInitiateForm.tsx +++ b/echo/frontend/src/components/participant/ParticipantInitiateForm.tsx @@ -12,7 +12,7 @@ import { useForm } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import { useEffect } from "react"; -import { useInitiateConversationMutation } from "@/lib/query"; +import { useInitiateConversationMutation } from "./hooks"; import { AxiosError } from "axios"; import { useI18nNavigate } from "@/hooks/useI18nNavigate"; diff --git a/echo/frontend/src/components/participant/hooks/index.ts b/echo/frontend/src/components/participant/hooks/index.ts new file mode 100644 index 00000000..9f9a3419 --- /dev/null +++ b/echo/frontend/src/components/participant/hooks/index.ts @@ -0,0 +1,274 @@ +import { + initiateConversation, + submitNotificationParticipant, + uploadConversationChunk, + uploadConversationText, +} from "@/lib/api"; +import { directus } from "@/lib/directus"; +import { createItem } from "@directus/sdk"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "@/components/common/Toaster"; + +export const useCreateProjectReportMetricOncePerDayMutation = () => { + return useMutation({ + mutationFn: ({ payload }: { payload: Partial }) => { + const key = `rm_${payload.project_report_id}_updated`; + let shouldUpdate = false; + + try { + const lastUpdated = localStorage.getItem(key); + if (!lastUpdated) { + shouldUpdate = true; + } else { + const lastUpdateTime = new Date(lastUpdated).getTime(); + const currentTime = new Date().getTime(); + const hoursDiff = (currentTime - lastUpdateTime) / (1000 * 60 * 60); + shouldUpdate = hoursDiff >= 24; + } + + if (shouldUpdate) { + localStorage.setItem(key, new Date().toISOString()); + } + } catch (e) { + // Ignore localStorage errors + shouldUpdate = true; + } + + if (!shouldUpdate) { + return Promise.resolve(null); + } + + return directus.request( + createItem("project_report_metric", payload as any), + ); + }, + }); +}; + +export const useUploadConversationChunk = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: uploadConversationChunk, + retry: 10, + // When mutate is called: + onMutate: async (variables) => { + // Cancel any outgoing refetches + // (so they don't overwrite our optimistic update) + await queryClient.cancelQueries({ + queryKey: ["conversations", variables.conversationId, "chunks"], + }); + + await queryClient.cancelQueries({ + queryKey: [ + "participant", + "conversation_chunks", + variables.conversationId, + ], + }); + + // Snapshot the previous value + const previousChunks = queryClient.getQueryData([ + "conversations", + variables.conversationId, + "chunks", + ]); + + // Optimistically update to the new value + queryClient.setQueryData( + ["conversations", variables.conversationId, "chunks"], + (oldData: ConversationChunk[] | undefined) => { + return oldData + ? [ + ...oldData, + { + id: "optimistic-" + Date.now(), + conversation_id: variables.conversationId, + created_at: new Date().toISOString(), + timestamp: new Date().toISOString(), + updated_at: new Date().toISOString(), + transcript: undefined, + } as ConversationChunk, + ] + : []; + }, + ); + + queryClient.setQueryData( + ["participant", "conversation_chunks", variables.conversationId], + (oldData: ConversationChunk[] | undefined) => { + return oldData + ? [ + ...oldData, + { + id: "optimistic-" + Date.now(), + conversation_id: variables.conversationId, + created_at: new Date().toISOString(), + timestamp: new Date().toISOString(), + updated_at: new Date().toISOString(), + transcript: undefined, + } as ConversationChunk, + ] + : []; + }, + ); + + // Return a context object with the snapshotted value + return { previousChunks }; + }, + // If the mutation fails, + // use the context returned from onMutate to roll back + onError: (_err, variables, context) => { + queryClient.setQueryData( + ["conversations", variables.conversationId, "chunks"], + context?.previousChunks, + ); + }, + // Always refetch after error or success: + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: ["conversations", variables.conversationId], + }); + + queryClient.invalidateQueries({ + queryKey: [ + "participant", + "conversation_chunks", + variables.conversationId, + ], + }); + }, + }); +}; + +export const useUploadConversationTextChunk = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: uploadConversationText, + retry: 10, + // When mutate is called: + onMutate: async (variables) => { + // Cancel any outgoing refetches + // (so they don't overwrite our optimistic update) + await queryClient.cancelQueries({ + queryKey: ["conversations", variables.conversationId, "chunks"], + }); + + await queryClient.cancelQueries({ + queryKey: [ + "participant", + "conversation_chunks", + variables.conversationId, + ], + }); + + // Snapshot the previous value + const previousChunks = queryClient.getQueryData([ + "conversations", + variables.conversationId, + "chunks", + ]); + + // Optimistically update to the new value + queryClient.setQueryData( + ["conversations", variables.conversationId, "chunks"], + (oldData: ConversationChunk[] | undefined) => { + return oldData + ? [ + ...oldData, + { + id: "optimistic-" + Date.now(), + conversation_id: variables.conversationId, + created_at: new Date().toISOString(), + timestamp: new Date().toISOString(), + updated_at: new Date().toISOString(), + transcript: undefined, + } as ConversationChunk, + ] + : []; + }, + ); + + queryClient.setQueryData( + ["participant", "conversation_chunks", variables.conversationId], + (oldData: ConversationChunk[] | undefined) => { + return oldData + ? [ + ...oldData, + { + id: "optimistic-" + Date.now(), + conversation_id: variables.conversationId, + created_at: new Date().toISOString(), + timestamp: new Date().toISOString(), + updated_at: new Date().toISOString(), + transcript: undefined, + } as ConversationChunk, + ] + : []; + }, + ); + + // Return a context object with the snapshotted value + return { previousChunks }; + }, + // If the mutation fails, + // use the context returned from onMutate to roll back + onError: (_err, variables, context) => { + queryClient.setQueryData( + ["conversations", variables.conversationId, "chunks"], + context?.previousChunks, + ); + }, + // Always refetch after error or success: + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: ["conversations", variables.conversationId, "chunks"], + }); + + queryClient.invalidateQueries({ + queryKey: [ + "participant", + "conversation_chunks", + variables.conversationId, + ], + }); + }, + }); +}; + +export const useInitiateConversationMutation = () => { + return useMutation({ + mutationFn: initiateConversation, + onSuccess: () => { + toast.success("Success"); + }, + onError: () => { + toast.error("Invalid PIN or email. Please try again."); + }, + }); +}; + +export const useSubmitNotificationParticipant = () => { + return useMutation({ + mutationFn: async ({ + emails, + projectId, + conversationId, + }: { + emails: string[]; + projectId: string; + conversationId: string; + }) => { + return await submitNotificationParticipant( + emails, + projectId, + conversationId, + ); + }, + retry: 2, + onError: (error) => { + console.error("Notification submission failed:", error); + }, + }); +}; diff --git a/echo/frontend/src/components/project/ProjectAnalysisRunStatus.tsx b/echo/frontend/src/components/project/ProjectAnalysisRunStatus.tsx index ba74bcae..3316c845 100644 --- a/echo/frontend/src/components/project/ProjectAnalysisRunStatus.tsx +++ b/echo/frontend/src/components/project/ProjectAnalysisRunStatus.tsx @@ -1,10 +1,10 @@ import { Trans } from "@lingui/react/macro"; import { directus } from "@/lib/directus"; -import { useLatestProjectAnalysisRunByProjectId } from "@/lib/query"; import { readItems } from "@directus/sdk"; import { Alert, Stack } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import { CloseableAlert } from "../common/ClosableAlert"; +import { useLatestProjectAnalysisRunByProjectId } from "./hooks"; export const ProjectAnalysisRunStatus = ({ projectId, diff --git a/echo/frontend/src/components/project/ProjectBasicEdit.tsx b/echo/frontend/src/components/project/ProjectBasicEdit.tsx index 93c751de..e7adab54 100644 --- a/echo/frontend/src/components/project/ProjectBasicEdit.tsx +++ b/echo/frontend/src/components/project/ProjectBasicEdit.tsx @@ -13,7 +13,7 @@ import { Badge, } from "@mantine/core"; import { useForm, Controller } from "react-hook-form"; -import { useUpdateProjectByIdMutation } from "@/lib/query"; +import { useUpdateProjectByIdMutation } from "./hooks"; import { SaveStatus } from "../form/SaveStatus"; import { FormLabel } from "../form/FormLabel"; import { useAutoSave } from "@/hooks/useAutoSave"; diff --git a/echo/frontend/src/components/project/ProjectDangerZone.tsx b/echo/frontend/src/components/project/ProjectDangerZone.tsx index 87b59450..fc63fb6d 100644 --- a/echo/frontend/src/components/project/ProjectDangerZone.tsx +++ b/echo/frontend/src/components/project/ProjectDangerZone.tsx @@ -1,6 +1,6 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { useDeleteProjectByIdMutation } from "@/lib/query"; +import { useDeleteProjectByIdMutation } from "./hooks"; import { Box, Button, Stack, Title } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import { useI18nNavigate } from "@/hooks/useI18nNavigate"; diff --git a/echo/frontend/src/components/project/ProjectPortalEditor.tsx b/echo/frontend/src/components/project/ProjectPortalEditor.tsx index d006aa2a..0420dc3a 100644 --- a/echo/frontend/src/components/project/ProjectPortalEditor.tsx +++ b/echo/frontend/src/components/project/ProjectPortalEditor.tsx @@ -21,7 +21,7 @@ import { } from "@mantine/core"; import { ProjectTagsInput } from "./ProjectTagsInput"; import { MarkdownWYSIWYG } from "../form/MarkdownWYSIWYG/MarkdownWYSIWYG"; -import { useUpdateProjectByIdMutation } from "@/lib/query"; + import { IconEye, IconEyeOff, IconRefresh } from "@tabler/icons-react"; import { useProjectSharingLink } from "./ProjectQRCode"; import { Resizable } from "re-resizable"; @@ -32,6 +32,7 @@ import { SaveStatus } from "../form/SaveStatus"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import { Logo } from "../common/Logo"; +import { useUpdateProjectByIdMutation } from "./hooks"; const FormSchema = z.object({ language: z.enum(["en", "nl", "de", "fr", "es"]), @@ -441,46 +442,71 @@ const ProjectPortalEditorComponent: React.FC<{ project: Project }> = ({ watchedReplyEnabled && field.onChange("summarize")} + onClick={() => + watchedReplyEnabled && field.onChange("summarize") + } > Summarize watchedReplyEnabled && field.onChange("brainstorm")} + onClick={() => + watchedReplyEnabled && + field.onChange("brainstorm") + } > Brainstorm Ideas watchedReplyEnabled && field.onChange("custom")} + onClick={() => + watchedReplyEnabled && field.onChange("custom") + } > Custom diff --git a/echo/frontend/src/components/project/ProjectSidebar.tsx b/echo/frontend/src/components/project/ProjectSidebar.tsx index 59290137..8dd82ae0 100644 --- a/echo/frontend/src/components/project/ProjectSidebar.tsx +++ b/echo/frontend/src/components/project/ProjectSidebar.tsx @@ -1,7 +1,8 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { Icons } from "@/icons"; -import { useCreateChatMutation, useProjectById } from "@/lib/query"; +import { useProjectById } from "@/components/project/hooks"; +import { useCreateChatMutation } from "./hooks"; import { ActionIcon, Box, diff --git a/echo/frontend/src/components/project/ProjectTagsInput.tsx b/echo/frontend/src/components/project/ProjectTagsInput.tsx index 570f66c8..6dce0884 100644 --- a/echo/frontend/src/components/project/ProjectTagsInput.tsx +++ b/echo/frontend/src/components/project/ProjectTagsInput.tsx @@ -3,9 +3,9 @@ import { Trans } from "@lingui/react/macro"; import { useCreateProjectTagMutation, useDeleteTagByIdMutation, - useProjectById, useUpdateProjectTagByIdMutation, -} from "@/lib/query"; +} from "./hooks"; +import { useProjectById } from "@/components/project/hooks"; import { ActionIcon, Alert, @@ -269,4 +269,4 @@ export const ProjectTagsInput = (props: { project: Project }) => { ); -}; \ No newline at end of file +}; diff --git a/echo/frontend/src/components/project/hooks/index.ts b/echo/frontend/src/components/project/hooks/index.ts new file mode 100644 index 00000000..0361edbe --- /dev/null +++ b/echo/frontend/src/components/project/hooks/index.ts @@ -0,0 +1,238 @@ +import { + useMutation, + useQuery, + useQueryClient, + useInfiniteQuery, +} from "@tanstack/react-query"; +import { + Query, + createItem, + deleteItem, + readItem, + readItems, + updateItem, +} from "@directus/sdk"; +import { directus } from "@/lib/directus"; +import { toast } from "@/components/common/Toaster"; +import { + addChatContext, + api, + getLatestProjectAnalysisRunByProjectId, +} from "@/lib/api"; +import { useI18nNavigate } from "@/hooks/useI18nNavigate"; + +export const useDeleteProjectByIdMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (projectId: string) => + directus.request(deleteItem("project", projectId)), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["projects"], + }); + queryClient.resetQueries(); + toast.success("Project deleted successfully"); + }, + }); +}; + +export const useCreateProjectTagMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: { + project_id: { + id: string; + directus_user_id: string; + }; + text: string; + sort?: number; + }) => directus.request(createItem("project_tag", payload as any)), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: ["projects", variables.project_id.id], + }); + toast.success("Tag created successfully"); + }, + }); +}; + +export const useUpdateProjectTagByIdMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + id, + payload, + }: { + id: string; + project_id: string; + payload: Partial; + }) => directus.request(updateItem("project_tag", id, payload)), + onSuccess: (_values, variables) => { + queryClient.invalidateQueries({ + queryKey: ["projects", variables.project_id], + }); + }, + }); +}; + +export const useDeleteTagByIdMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (tagId: string) => + directus.request(deleteItem("project_tag", tagId)), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["projects"], + }); + toast.success("Tag deleted successfully"); + }, + }); +}; + +export const useCreateChatMutation = () => { + const navigate = useI18nNavigate(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (payload: { + navigateToNewChat?: boolean; + conversationId?: string; + project_id: { + id: string; + }; + }) => { + const project = await directus.request( + readItem("project", payload.project_id.id), + ); + + const chat = await directus.request( + createItem("project_chat", { + ...(payload as any), + auto_select: !!project.is_enhanced_audio_processing_enabled, + }), + ); + + try { + if (payload.conversationId) { + await addChatContext(chat.id, payload.conversationId); + } + } catch (error) { + console.error("Failed to add conversation to chat:", error); + toast.error("Failed to add conversation to chat"); + } + + if (payload.navigateToNewChat && chat && chat.id) { + navigate(`/projects/${payload.project_id.id}/chats/${chat.id}`); + } + + return chat; + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: ["projects", variables.project_id.id, "chats"], + }); + toast.success("Chat created successfully"); + }, + }); +}; + +export const useLatestProjectAnalysisRunByProjectId = (projectId: string) => { + return useQuery({ + queryKey: ["projects", projectId, "latest_analysis"], + queryFn: () => getLatestProjectAnalysisRunByProjectId(projectId), + refetchInterval: 10000, + }); +}; + +export const useUpdateProjectByIdMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: Partial }) => + directus.request(updateItem("project", id, payload)), + onSuccess: (_values, variables) => { + queryClient.invalidateQueries({ + queryKey: ["projects", variables.id], + }); + toast.success("Project updated successfully"); + }, + }); +}; + +export const useCreateProjectMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: Partial) => { + return api.post("/projects", payload); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["projects"] }); + toast.success("Project created successfully"); + }, + onError: (e) => { + console.error(e); + toast.error("Error creating project"); + }, + }); +}; + +export const useInfiniteProjects = ({ + query, + options = { + initialLimit: 15, + }, +}: { + query: Partial>; + options?: { + initialLimit?: number; + }; +}) => { + const { initialLimit = 15 } = options; + + return useInfiniteQuery({ + queryKey: ["projects", query], + queryFn: async ({ pageParam = 0 }) => { + const response = await directus.request( + readItems("project", { + ...query, + limit: initialLimit, + offset: pageParam * initialLimit, + }), + ); + + return { + projects: response, + nextOffset: + response.length === initialLimit ? pageParam + 1 : undefined, + }; + }, + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextOffset, + }); +}; + +export const useProjectById = ({ + projectId, + query = { + fields: [ + "*", + { + tags: ["id", "created_at", "text", "sort"], + }, + ], + deep: { + // @ts-expect-error tags won't be typed + tags: { + _sort: "sort", + }, + }, + }, +}: { + projectId: string; + query?: Partial>; +}) => { + return useQuery({ + queryKey: ["projects", projectId, query], + queryFn: () => + directus.request(readItem("project", projectId, query)), + }); +}; diff --git a/echo/frontend/src/components/report/ConversationStatusTable.tsx b/echo/frontend/src/components/report/ConversationStatusTable.tsx index b2e98af5..e3c81fac 100644 --- a/echo/frontend/src/components/report/ConversationStatusTable.tsx +++ b/echo/frontend/src/components/report/ConversationStatusTable.tsx @@ -1,6 +1,6 @@ import { Trans } from "@lingui/react/macro"; import { Anchor, Table, Text } from "@mantine/core"; -import { useProjectConversationCounts } from "@/lib/query"; +import { useProjectConversationCounts } from "@/components/report/hooks"; import { Link } from "react-router-dom"; interface Conversation { diff --git a/echo/frontend/src/components/report/CreateReportForm.tsx b/echo/frontend/src/components/report/CreateReportForm.tsx index 47c5477a..cc939e9b 100644 --- a/echo/frontend/src/components/report/CreateReportForm.tsx +++ b/echo/frontend/src/components/report/CreateReportForm.tsx @@ -12,12 +12,9 @@ import { Title, } from "@mantine/core"; import { ConversationStatusTable } from "./ConversationStatusTable"; - import { useEffect, useState } from "react"; -import { - useCreateProjectReportMutation, - useProjectConversationCounts, -} from "@/lib/query"; +import { useCreateProjectReportMutation } from "./hooks"; +import { useProjectConversationCounts } from "@/components/report/hooks"; import { useParams } from "react-router-dom"; import { t } from "@lingui/core/macro"; import { languageOptionsByIso639_1 } from "../language/LanguagePicker"; diff --git a/echo/frontend/src/components/report/ReportModalNavigationButton.tsx b/echo/frontend/src/components/report/ReportModalNavigationButton.tsx index 452dbcf4..3387a541 100644 --- a/echo/frontend/src/components/report/ReportModalNavigationButton.tsx +++ b/echo/frontend/src/components/report/ReportModalNavigationButton.tsx @@ -5,11 +5,12 @@ import { useDisclosure } from "@mantine/hooks"; import { NavigationButton } from "../common/NavigationButton"; import { useCallback } from "react"; -import { useLatestProjectReport } from "@/lib/query"; + import { useParams, useLocation } from "react-router-dom"; import { t } from "@lingui/core/macro"; import { useI18nNavigate } from "@/hooks/useI18nNavigate"; import { CreateReportForm } from "./CreateReportForm"; +import { useLatestProjectReport } from "./hooks"; export const ReportModalNavigationButton = () => { const [opened, { open, close }] = useDisclosure(); diff --git a/echo/frontend/src/components/report/ReportRenderer.tsx b/echo/frontend/src/components/report/ReportRenderer.tsx index 32c997ed..915ba208 100644 --- a/echo/frontend/src/components/report/ReportRenderer.tsx +++ b/echo/frontend/src/components/report/ReportRenderer.tsx @@ -1,4 +1,4 @@ -import { useProjectReport } from "@/lib/query"; +import { useProjectReport } from "./hooks"; import { Button, Center, diff --git a/echo/frontend/src/components/report/ReportTimeline.tsx b/echo/frontend/src/components/report/ReportTimeline.tsx index 1733005c..7ee04fa2 100644 --- a/echo/frontend/src/components/report/ReportTimeline.tsx +++ b/echo/frontend/src/components/report/ReportTimeline.tsx @@ -11,7 +11,7 @@ import { AreaChart, } from "recharts"; import { Skeleton, Text } from "@mantine/core"; -import { useProjectReportTimelineData } from "@/lib/query"; +import { useProjectReportTimelineData } from "./hooks"; import { addDays, format, subDays } from "date-fns"; import { t } from "@lingui/core/macro"; import { useState } from "react"; diff --git a/echo/frontend/src/components/report/UpdateReportModalButton.tsx b/echo/frontend/src/components/report/UpdateReportModalButton.tsx index 8cc54d25..01c26413 100644 --- a/echo/frontend/src/components/report/UpdateReportModalButton.tsx +++ b/echo/frontend/src/components/report/UpdateReportModalButton.tsx @@ -9,11 +9,7 @@ import { } from "@mantine/core"; import { useEffect, useState } from "react"; -import { - useCreateProjectReportMutation, - useDoesProjectReportNeedUpdate, - useProjectReport, -} from "@/lib/query"; +import { useProjectReport, useCreateProjectReportMutation, useDoesProjectReportNeedUpdate} from "./hooks"; import { useParams } from "react-router-dom"; import { t } from "@lingui/core/macro"; import { languageOptionsByIso639_1 } from "../language/LanguagePicker"; diff --git a/echo/frontend/src/components/report/hooks/index.ts b/echo/frontend/src/components/report/hooks/index.ts new file mode 100644 index 00000000..1ad92d22 --- /dev/null +++ b/echo/frontend/src/components/report/hooks/index.ts @@ -0,0 +1,343 @@ +import { createProjectReport, getProjectConversationCounts } from "@/lib/api"; +import { directus } from "@/lib/directus"; +import { aggregate, readItem, readItems, updateItem } from "@directus/sdk"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +// always give the project_id in payload used for invalidation +export const useUpdateProjectReportMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + reportId, + payload, + }: { + reportId: number; + payload: Partial; + }) => directus.request(updateItem("project_report", reportId, payload)), + onSuccess: (_, vars) => { + const projectId = vars.payload.project_id; + const projectIdValue = + typeof projectId === "object" && projectId !== null + ? projectId.id + : projectId; + + queryClient.invalidateQueries({ + queryKey: ["projects", projectIdValue, "report"], + }); + queryClient.invalidateQueries({ + queryKey: ["reports"], + }); + }, + }); +}; + +export const useCreateProjectReportMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createProjectReport, + onSuccess: (_, vars) => { + queryClient.invalidateQueries({ + queryKey: ["projects", vars.projectId, "report"], + }); + queryClient.invalidateQueries({ + queryKey: ["reports"], + }); + }, + }); +}; + +export const useGetProjectParticipants = (project_id: string) => { + return useQuery({ + queryKey: ["projectParticipants", project_id], + queryFn: async () => { + if (!project_id) return 0; + + const submissions = await directus.request( + readItems("project_report_notification_participants", { + filter: { + _and: [ + { project_id: { _eq: project_id } }, + { email_opt_in: { _eq: true } }, + ], + }, + fields: ["id"], + }), + ); + + return submissions.length; + }, + enabled: !!project_id, // Only run query if project_id exists + }); +}; + +/** + * Gathers data needed to build a timeline chart of: + * 1) Project creation (vertical reference line). + * 2) This specific project report creation (vertical reference line). + * 3) Green "stem" lines representing Conversations created (height = number of conversation chunks). Uses Directus aggregate() to get counts. + * 4) Blue line points representing Project Report Metrics associated with the given project_report_id (e.g., "views", "score", etc.). + * + * Based on Mantine Charts docs: https://mantine.dev/charts/line-chart/#reference-lines + * + * NOTES: + * - Make sure you match your date fields in Directus (e.g., "date_created" vs. "created_at"). + * - For any chart "stems", you typically create two data points with the same X but different Y values. + */ +export const useProjectReportTimelineData = (projectReportId: string) => { + return useQuery({ + queryKey: ["reports", projectReportId, "timelineData"], + queryFn: async () => { + // 1. Fetch the project report so we know the projectId and the report's creation date + const projectReport = await directus.request( + readItem("project_report", projectReportId, { + fields: ["id", "date_created", "project_id"], + }), + ); + + if (!projectReport?.project_id) { + throw new Error("No project_id found on this report"); + } + + const allProjectReports = await directus.request( + readItems("project_report", { + filter: { + project_id: { + _eq: projectReport.project_id, + }, + }, + limit: 1000, + sort: "date_created", + }), + ); + + // 2. Fetch the project to get the creation date + // Adjust fields to match your date field naming + const project = await directus.request( + readItem("project", projectReport.project_id.toString(), { + fields: ["id", "created_at"], // or ["id", "created_at"] + }), + ); + + // 3. Fetch all Conversations and use an aggregate to count conversation_chunks + const conversations = await directus.request( + readItems("conversation", { + fields: ["id", "created_at"], // or ["id", "date_created"] + filter: { + project_id: { + _eq: projectReport.project_id, + }, + }, + limit: 1000, // adjust to your needs + }), + ); + + // Aggregate chunk counts per conversation with Directus aggregator + let conversationChunkAgg: { conversation_id: string; count: number }[] = + []; + if (conversations.length > 0) { + const conversationIds = conversations.map((c) => c.id); + const chunkCountsAgg = await directus.request< + Array<{ conversation_id: string; count: number }> + >( + readItems("conversation_chunk", { + aggregate: { count: "*" }, + groupBy: ["conversation_id"], + filter: { conversation_id: { _in: conversationIds } }, + }), + ); + + // chunkCountsAgg shape is [{ conversation_id: '...', count: 5 }, ...] + conversationChunkAgg = chunkCountsAgg; + } + + // 4. Fetch all Project Report Metrics for this project_report_id + // (e.g., type "view", "score," etc. – adapt as needed) + const projectReportMetrics = await directus.request< + ProjectReportMetric[] + >( + readItems("project_report_metric", { + fields: ["id", "date_created", "project_report_id"], + filter: { + project_report_id: { + project_id: { + _eq: project.id, + }, + }, + }, + sort: "date_created", + limit: 1000, + }), + ); + + // Return all structured data. The consuming component can then create the chart data arrays. + return { + projectCreatedAt: project.created_at, + reportCreatedAt: projectReport.date_created, + allReports: allProjectReports.map((r) => ({ + id: r.id, + createdAt: r.date_created, + })), + conversations: conversations, + conversationChunks: conversations.map((conv) => { + const aggRow = conversationChunkAgg.find( + (row) => row.conversation_id === conv.id, + ); + return { + conversationId: conv.id, + createdAt: conv.created_at, + chunkCount: aggRow?.count ?? 0, + }; + }), + projectReportMetrics, + }; + }, + }); +}; + +export const useDoesProjectReportNeedUpdate = (projectReportId: number) => { + return useQuery({ + queryKey: ["reports", projectReportId, "needsUpdate"], + queryFn: async () => { + const reports = await directus.request( + readItems("project_report", { + filter: { + id: { + _eq: projectReportId, + }, + status: { + _eq: "published", + }, + }, + fields: ["id", "date_created", "project_id"], + sort: "-date_created", + limit: 1, + }), + ); + + if (reports.length === 0) { + return false; + } + + const latestReport = reports[0]; + + const latestConversation = await directus.request( + readItems("conversation", { + filter: { + project_id: { + _eq: latestReport.project_id, + }, + }, + fields: ["id", "created_at"], + sort: "-created_at", + limit: 1, + }), + ); + + if (latestConversation.length === 0) { + return false; + } + + return ( + new Date(latestConversation[0].created_at!) > + new Date(latestReport.date_created!) + ); + }, + }); +}; + +export const useProjectReport = (reportId: number) => { + return useQuery({ + queryKey: ["reports", reportId], + queryFn: () => directus.request(readItem("project_report", reportId)), + refetchInterval: 30000, + }); +}; + +export const useProjectConversationCounts = (projectId: string) => { + return useQuery({ + queryKey: ["projects", projectId, "conversation-counts"], + queryFn: () => getProjectConversationCounts(projectId), + refetchInterval: 15000, + }); +}; + +export const useProjectReportViews = (reportId: number) => { + return useQuery({ + queryKey: ["reports", reportId, "views"], + queryFn: async () => { + const report = await directus.request( + readItem("project_report", reportId, { + fields: ["project_id"], + }), + ); + + const total = await directus.request( + aggregate("project_report_metric", { + aggregate: { + count: "*", + }, + query: { + filter: { + project_report_id: { + project_id: { + _eq: report.project_id, + }, + }, + }, + }, + }), + ); + + const recent = await directus.request( + aggregate("project_report_metric", { + aggregate: { + count: "*", + }, + query: { + filter: { + project_report_id: {}, + // in the last 10 mins + date_created: { + // @ts-ignore + _gte: new Date(Date.now() - 10 * 60 * 1000).toISOString(), + }, + }, + }, + }), + ); + + return { + total: total[0].count, + recent: recent[0].count, + }; + }, + refetchInterval: 30000, + }); +}; + +export const useLatestProjectReport = (projectId: string) => { + return useQuery({ + queryKey: ["projects", projectId, "report"], + queryFn: async () => { + const reports = await directus.request( + readItems("project_report", { + filter: { + project_id: { + _eq: projectId, + }, + }, + fields: ["*"], + sort: "-date_created", + limit: 1, + }), + ); + + if (reports.length === 0) { + return null; + } + + return reports[0]; + }, + refetchInterval: 30000, + }); +}; diff --git a/echo/frontend/src/components/resource/hooks/index.ts b/echo/frontend/src/components/resource/hooks/index.ts new file mode 100644 index 00000000..29b535db --- /dev/null +++ b/echo/frontend/src/components/resource/hooks/index.ts @@ -0,0 +1,46 @@ +import { + deleteResourceById, + getResourceById, + updateResourceById, +} from "@/lib/api"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "@/components/common/Toaster"; + +export const useUpdateResourceByIdMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: updateResourceById, + onSuccess: (_values, variables) => { + queryClient.invalidateQueries({ + queryKey: ["resources", variables.id], + }); + queryClient.invalidateQueries({ + queryKey: ["projects"], + }); + toast.success("Resource updated successfully"); + }, + }); +}; + +export const useDeleteResourceByIdMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: deleteResourceById, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["projects"], + }); + queryClient.invalidateQueries({ + queryKey: ["resources"], + }); + toast.success("Resource deleted successfully"); + }, + }); +}; + +export const useResourceById = (resourceId: string) => { + return useQuery({ + queryKey: ["resources", resourceId], + queryFn: () => getResourceById(resourceId), + }); +}; diff --git a/echo/frontend/src/components/unsubscribe/hooks/index.ts b/echo/frontend/src/components/unsubscribe/hooks/index.ts new file mode 100644 index 00000000..ef9ec18a --- /dev/null +++ b/echo/frontend/src/components/unsubscribe/hooks/index.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; +import { checkUnsubscribeStatus } from "@/lib/api"; + +export const useCheckUnsubscribeStatus = (token: string, projectId: string) => { + return useQuery<{ eligible: boolean }>({ + queryKey: ["checkUnsubscribe", token, projectId], + queryFn: async () => { + if (!token || !projectId) { + throw new Error("Invalid or missing unsubscribe link."); + } + const response = await checkUnsubscribeStatus(token, projectId); + return response.data; + }, + retry: false, + refetchOnWindowFocus: false, + }); +}; diff --git a/echo/frontend/src/components/view/CreateViewForm.tsx b/echo/frontend/src/components/view/CreateViewForm.tsx index dcc97db4..e15af282 100644 --- a/echo/frontend/src/components/view/CreateViewForm.tsx +++ b/echo/frontend/src/components/view/CreateViewForm.tsx @@ -15,7 +15,7 @@ import { } from "@mantine/core"; import { useLanguage } from "@/hooks/useLanguage"; -import { useGenerateProjectViewMutation } from "@/lib/query"; +import { useGenerateProjectViewMutation } from "./hooks"; import { CloseableAlert } from "../common/ClosableAlert"; import { languageOptionsByIso639_1 } from "../language/LanguagePicker"; diff --git a/echo/frontend/src/components/view/hooks/index.ts b/echo/frontend/src/components/view/hooks/index.ts new file mode 100644 index 00000000..5ae12482 --- /dev/null +++ b/echo/frontend/src/components/view/hooks/index.ts @@ -0,0 +1,12 @@ +import { generateProjectView } from "@/lib/api"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "@/components/common/Toaster"; + +export const useGenerateProjectViewMutation = () => { + return useMutation({ + mutationFn: generateProjectView, + onSuccess: () => { + toast.success("Analysis requested successfully"); + }, + }); +}; diff --git a/echo/frontend/src/hooks/useAuthenticated.tsx b/echo/frontend/src/hooks/useAuthenticated.tsx index 0b57cce8..710dfcbe 100644 --- a/echo/frontend/src/hooks/useAuthenticated.tsx +++ b/echo/frontend/src/hooks/useAuthenticated.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useLocation, useSearchParams } from "react-router-dom"; import { directus } from "@/lib/directus"; -import { useLogoutMutation } from "@/lib/query"; +import { useLogoutMutation } from "@/components/auth/hooks"; export const useAuthenticated = (doRedirect = false) => { const logoutMutation = useLogoutMutation(); diff --git a/echo/frontend/src/lib/query.ts b/echo/frontend/src/lib/query.ts deleted file mode 100644 index a6b61aba..00000000 --- a/echo/frontend/src/lib/query.ts +++ /dev/null @@ -1,2304 +0,0 @@ -// conventions -// query key uses the following format: projects , chats (plural) -// mutation key uses the following format: projects , chats (plural) -import { - UseQueryOptions, - useMutation, - useQuery, - useQueryClient, - useInfiniteQuery, -} from "@tanstack/react-query"; -import * as Sentry from "@sentry/react"; -import { useI18nNavigate } from "@/hooks/useI18nNavigate"; -import { - addChatContext, - api, - apiNoAuth, - checkUnsubscribeStatus, - createProjectReport, - deleteChatContext, - deleteConversationById, - deleteResourceById, - generateProjectLibrary as generateProjectLibrary, - generateProjectView, - getChatHistory, - getConversationChunkContentLink, - getConversationTranscriptString, - getLatestProjectAnalysisRunByProjectId, - getProjectChatContext, - getProjectConversationCounts, - getProjectViews, - getResourceById, - getResourcesByProjectId, - initiateAndUploadConversationChunk, - initiateConversation, - lockConversations, - retranscribeConversation, - submitNotificationParticipant, - updateResourceById, - uploadConversationChunk, - uploadConversationText, - uploadResourceByProjectId, -} from "./api"; -import { toast } from "@/components/common/Toaster"; -import { directus } from "./directus"; -import { - Query, - aggregate, - createItem, - createItems, - deleteItem, - passwordRequest, - passwordReset, - readItem, - readItems, - readUser, - registerUser, - registerUserVerify, - updateItem, - deleteItems, -} from "@directus/sdk"; -import { ADMIN_BASE_URL } from "@/config"; -import { AxiosError } from "axios"; -import { t } from "@lingui/core/macro"; -import { useState, useCallback, useEffect, useRef } from "react"; - -// always throws a error with a message -function throwWithMessage(e: unknown): never { - if ( - e && - typeof e === "object" && - "errors" in e && - Array.isArray((e as any).errors) - ) { - // Handle Directus error format - const message = (e as any).errors[0].message; - console.log(message); - throw new Error(message); - } else if (e instanceof Error) { - // Handle generic errors - console.log(e.message); - throw new Error(e.message); - } else { - // Handle unknown errors - console.log("An unknown error occurred"); - throw new Error("Something went wrong"); - } -} - -export const useProjects = ({ - query, -}: { - query: Partial>; -}) => { - return useQuery({ - queryKey: ["projects", query], - queryFn: () => - directus.request( - readItems("project", { - fields: [ - "*", - { - tags: ["*"], - }, - ], - deep: { - // @ts-expect-error tags is not typed - tags: { - _sort: "sort", - }, - }, - ...query, - }), - ), - }); -}; - -export const useInfiniteProjects = ({ - query, - options = { - initialLimit: 15, - }, -}: { - query: Partial>; - options?: { - initialLimit?: number; - }; -}) => { - const { initialLimit = 15 } = options; - - return useInfiniteQuery({ - queryKey: ["projects", query], - queryFn: async ({ pageParam = 0 }) => { - const response = await directus.request( - readItems("project", { - ...query, - limit: initialLimit, - offset: pageParam * initialLimit, - }), - ); - - return { - projects: response, - nextOffset: - response.length === initialLimit ? pageParam + 1 : undefined, - }; - }, - initialPageParam: 0, - getNextPageParam: (lastPage) => lastPage.nextOffset, - }); -}; - -export const useCreateProjectMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (payload: Partial) => { - return api.post("/projects", payload); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["projects"] }); - toast.success("Project created successfully"); - }, - onError: (e) => { - console.error(e); - toast.error("Error creating project"); - }, - }); -}; - -// todo: add redirection logic here -export const useLoginMutation = () => { - return useMutation({ - mutationFn: (payload: Parameters) => { - return directus.login(...payload); - }, - onSuccess: () => { - toast.success("Login successful"); - }, - }); -}; - -export const useRegisterMutation = () => { - const navigate = useI18nNavigate(); - return useMutation({ - mutationFn: async (payload: Parameters) => { - try { - const response = await directus.request(registerUser(...payload)); - return response; - } catch (e) { - try { - throwWithMessage(e); - } catch (inner) { - if (inner instanceof Error) { - if (inner.message === "You don't have permission to access this.") { - throw new Error( - "Oops! It seems your email is not eligible for registration at this time. Please consider joining our waitlist for future updates!", - ); - } - } - } - } - }, - onSuccess: () => { - toast.success("Please check your email to verify your account."); - navigate("/check-your-email"); - }, - onError: (e) => { - toast.error(e.message); - }, - }); -}; - -export const useVerifyMutation = (doRedirect: boolean = true) => { - const navigate = useI18nNavigate(); - - return useMutation({ - mutationFn: async (data: { token: string }) => { - try { - const response = await directus.request(registerUserVerify(data.token)); - return response; - } catch (e) { - throwWithMessage(e); - } - }, - onSuccess: () => { - toast.success("Email verified successfully."); - if (doRedirect) { - setTimeout(() => { - // window.location.href = `/login?new=true`; - navigate(`/login?new=true`); - }, 4500); - } - }, - onError: (e) => { - toast.error(e.message); - }, - }); -}; - -export const useRequestPasswordResetMutation = () => { - const navigate = useI18nNavigate(); - return useMutation({ - mutationFn: async (email: string) => { - try { - const response = await directus.request( - passwordRequest(email, `${ADMIN_BASE_URL}/password-reset`), - ); - return response; - } catch (e) { - throwWithMessage(e); - } - }, - onSuccess: () => { - toast.success("Password reset email sent successfully"); - navigate("/check-your-email"); - }, - onError: (e) => { - toast.error(e.message); - }, - }); -}; - -export const useResetPasswordMutation = () => { - const navigate = useI18nNavigate(); - return useMutation({ - mutationFn: async ({ - token, - password, - }: { - token: string; - password: string; - }) => { - try { - const response = await directus.request(passwordReset(token, password)); - return response; - } catch (e) { - throwWithMessage(e); - } - }, - onSuccess: () => { - toast.success( - "Password reset successfully. Please login with new password.", - ); - navigate("/login"); - }, - onError: (e) => { - try { - toast.error(e.message); - } catch (e) { - toast.error("Error resetting password. Please contact support."); - } - }, - }); -}; - -export const useLogoutMutation = () => { - const queryClient = useQueryClient(); - const navigate = useI18nNavigate(); - - return useMutation({ - mutationFn: async ({ - next: _, - }: { - next?: string; - reason?: string; - doRedirect: boolean; - }) => { - try { - await directus.logout(); - } catch (e) { - throwWithMessage(e); - } - }, - onMutate: async ({ next, reason, doRedirect }) => { - queryClient.resetQueries(); - if (doRedirect) { - navigate( - "/login" + - (next ? `?next=${encodeURIComponent(next)}` : "") + - (reason ? `&reason=${reason}` : ""), - ); - } - }, - }); -}; - -export const useProjectById = ({ - projectId, - query = { - fields: [ - "*", - { - tags: ["id", "created_at", "text", "sort"], - }, - ], - deep: { - // @ts-expect-error tags won't be typed - tags: { - _sort: "sort", - }, - }, - }, -}: { - projectId: string; - query?: Partial>; -}) => { - return useQuery({ - queryKey: ["projects", projectId, query], - queryFn: () => - directus.request(readItem("project", projectId, query)), - }); -}; - -export const useProjectViews = (projectId: string) => { - return useQuery({ - queryKey: ["projects", projectId, "views"], - queryFn: () => getProjectViews(projectId), - refetchInterval: 20000, - }); -}; - -export const useViewById = (projectId: string, viewId: string) => { - return useQuery({ - queryKey: ["projects", projectId, "views", viewId], - queryFn: () => - directus.request( - readItem("view", viewId, { - fields: [ - "*", - { - aspects: ["*", "count(aspect_segment)"], - }, - ], - deep: { - // get the aspects that have at least one aspect segment - aspects: { - _sort: "-count(aspect_segment)", - } as any, - }, - }), - ), - }); -}; - -export const useAspectById = (projectId: string, aspectId: string) => { - return useQuery({ - queryKey: ["projects", projectId, "aspects", aspectId], - queryFn: () => - directus.request( - readItem("aspect", aspectId, { - fields: [ - "*", - { - "aspect_segment": [ - "*", - { - "segment": [ - "*", - ] - } - ] - } - ], - }), - ), - }); -}; - -export const useUpdateProjectTagByIdMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ - id, - payload, - }: { - id: string; - project_id: string; - payload: Partial; - }) => directus.request(updateItem("project_tag", id, payload)), - onSuccess: (_values, variables) => { - queryClient.invalidateQueries({ - queryKey: ["projects", variables.project_id], - }); - }, - }); -}; - -export const useUpdateProjectByIdMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ id, payload }: { id: string; payload: Partial }) => - directus.request(updateItem("project", id, payload)), - onSuccess: (_values, variables) => { - queryClient.invalidateQueries({ - queryKey: ["projects", variables.id], - }); - toast.success("Project updated successfully"); - }, - }); -}; - -export const useDeleteProjectByIdMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (projectId: string) => - directus.request(deleteItem("project", projectId)), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["projects"], - }); - queryClient.resetQueries(); - toast.success("Project deleted successfully"); - }, - }); -}; - -export const useResourceById = (resourceId: string) => { - return useQuery({ - queryKey: ["resources", resourceId], - queryFn: () => getResourceById(resourceId), - }); -}; - -export const useResourcesByProjectId = (projectId: string) => { - return useQuery({ - queryKey: ["projects", projectId, "resources"], - queryFn: () => getResourcesByProjectId(projectId), - }); -}; - -export const useUploadResourceByProjectIdMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: uploadResourceByProjectId, - retry: 3, - onSuccess: (_values, variables) => { - const projectId = variables.projectId; - queryClient.invalidateQueries({ - queryKey: ["projects", projectId, "resources"], - }); - toast.success("Resource uploaded successfully"); - }, - }); -}; - -export const useUpdateResourceByIdMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: updateResourceById, - onSuccess: (_values, variables) => { - queryClient.invalidateQueries({ - queryKey: ["resources", variables.id], - }); - queryClient.invalidateQueries({ - queryKey: ["projects"], - }); - toast.success("Resource updated successfully"); - }, - }); -}; - -export const useDeleteResourceByIdMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: deleteResourceById, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["projects"], - }); - queryClient.invalidateQueries({ - queryKey: ["resources"], - }); - toast.success("Resource deleted successfully"); - }, - }); -}; - -export const useInitiateConversationMutation = () => { - return useMutation({ - mutationFn: initiateConversation, - onSuccess: () => { - toast.success("Success"); - }, - onError: () => { - toast.error("Invalid PIN or email. Please try again."); - }, - }); -}; - -export const useConversationById = ({ - conversationId, - loadConversationChunks = false, - query = {}, - useQueryOpts = { - refetchInterval: 10000, - }, -}: { - conversationId: string; - loadConversationChunks?: boolean; - // query overrides the default query and loadChunks - query?: Partial>; - useQueryOpts?: Partial>; -}) => { - return useQuery({ - queryKey: ["conversations", conversationId, loadConversationChunks, query], - queryFn: () => - directus.request( - readItem("conversation", conversationId, { - fields: [ - "*", - { - tags: [ - { - project_tag_id: ["id", "text", "created_at"], - }, - ], - }, - ...(loadConversationChunks ? [{ chunks: ["*"] as any }] : []), - ], - ...query, - }), - ), - ...useQueryOpts, - }); -}; - -export const useUpdateConversationByIdMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ - id, - payload, - }: { - id: string; - payload: Partial; - }) => - directus.request(updateItem("conversation", id, payload)), - onSuccess: (values, variables) => { - queryClient.setQueryData( - ["conversations", variables.id], - (oldData: Conversation | undefined) => { - return { - ...oldData, - ...values, - }; - }, - ); - queryClient.invalidateQueries({ - queryKey: ["conversations"], - }); - }, - }); -}; - -// you always need to provide all the tags -export const useUpdateConversationTagsMutation = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ - conversationId, - projectId, - projectTagIdList, - }: { - projectId: string; - conversationId: string; - projectTagIdList: string[]; - }) => { - let validTagsIds: string[] = []; - try { - const validTags = await directus.request( - readItems("project_tag", { - filter: { - id: { - _in: projectTagIdList, - }, - project_id: { - _eq: projectId, - }, - }, - fields: ["*"], - }), - ); - - validTagsIds = validTags.map((tag) => tag.id); - } catch (error) { - validTagsIds = []; - } - - const tagsRequest = await directus.request( - readItems("conversation_project_tag", { - fields: [ - "id", - { - project_tag_id: ["id"], - }, - { - conversation_id: ["id"], - }, - ], - filter: { - conversation_id: { _eq: conversationId }, - }, - }), - ); - - const needToDelete = tagsRequest.filter( - (conversationProjectTag) => - conversationProjectTag.project_tag_id && - !validTagsIds.includes( - (conversationProjectTag.project_tag_id as ProjectTag).id, - ), - ); - - const needToCreate = validTagsIds.filter( - (tagId) => - !tagsRequest.some( - (conversationProjectTag) => - (conversationProjectTag.project_tag_id as ProjectTag).id === - tagId, - ), - ); - - // slightly esoteric, but basically we only want to delete if there are any tags to delete - // otherwise, directus doesn't accept an empty array - const deletePromise = - needToDelete.length > 0 - ? directus.request( - deleteItems( - "conversation_project_tag", - needToDelete.map((tag) => tag.id), - ), - ) - : Promise.resolve(); - - // same deal for creating - const createPromise = - needToCreate.length > 0 - ? directus.request( - createItems( - "conversation_project_tag", - needToCreate.map((tagId) => ({ - conversation_id: { - id: conversationId, - } as Conversation, - project_tag_id: { - id: tagId, - } as ProjectTag, - })), - ), - ) - : Promise.resolve(); - - // await both promises - await Promise.all([deletePromise, createPromise]); - - return directus.request( - readItem("conversation", conversationId, { - fields: ["*"], - }), - ); - }, - onSuccess: (_values, variables) => { - queryClient.invalidateQueries({ - queryKey: ["conversations", variables.conversationId], - }); - queryClient.invalidateQueries({ - queryKey: ["projects", variables.projectId], - }); - }, - }); -}; - -export const useDeleteConversationByIdMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: deleteConversationById, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["projects"], - }); - queryClient.invalidateQueries({ - queryKey: ["conversations"], - }); - toast.success("Conversation deleted successfully"); - }, - onError: (error: Error) => { - toast.error(error.message); - }, - }); -}; - -export const useDeleteConversationChunkByIdMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (chunkId: string) => - directus.request(deleteItem("conversation_chunk", chunkId)), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["conversations"], - }); - }, - }); -}; - -export const useProjectConversationCounts = (projectId: string) => { - return useQuery({ - queryKey: ["projects", projectId, "conversation-counts"], - queryFn: () => getProjectConversationCounts(projectId), - refetchInterval: 15000, - }); -}; - -export const useConversationsByProjectId = ( - projectId: string, - loadChunks?: boolean, - // unused - loadWhereTranscriptExists?: boolean, - query?: Partial>, - filterBySource?: string[], -) => { - return useQuery({ - queryKey: [ - "projects", - projectId, - "conversations", - loadChunks ? "chunks" : "no-chunks", - loadWhereTranscriptExists ? "transcript" : "no-transcript", - query, - filterBySource, - ], - queryFn: () => - directus.request( - readItems("conversation", { - sort: "-updated_at", - fields: [ - "*", - { - tags: [ - { - project_tag_id: ["id", "text", "created_at"], - }, - ], - }, - { chunks: ["*"] }, - ], - deep: { - // @ts-expect-error chunks is not typed - chunks: { - _limit: loadChunks ? 1000 : 1, - }, - }, - filter: { - project_id: { - _eq: projectId, - }, - chunks: { - ...(loadWhereTranscriptExists && { - _some: { - transcript: { - _nempty: true, - }, - }, - }), - }, - ...(filterBySource && { - source: { - _in: filterBySource, - }, - }), - }, - limit: 1000, - ...query, - }), - ), - refetchInterval: 30000, - }); -}; - -export const useUploadConversationChunk = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: uploadConversationChunk, - retry: 10, - // When mutate is called: - onMutate: async (variables) => { - // Cancel any outgoing refetches - // (so they don't overwrite our optimistic update) - await queryClient.cancelQueries({ - queryKey: ["conversations", variables.conversationId, "chunks"], - }); - - await queryClient.cancelQueries({ - queryKey: [ - "participant", - "conversation_chunks", - variables.conversationId, - ], - }); - - // Snapshot the previous value - const previousChunks = queryClient.getQueryData([ - "conversations", - variables.conversationId, - "chunks", - ]); - - // Optimistically update to the new value - queryClient.setQueryData( - ["conversations", variables.conversationId, "chunks"], - (oldData: ConversationChunk[] | undefined) => { - return oldData - ? [ - ...oldData, - { - id: "optimistic-" + Date.now(), - conversation_id: variables.conversationId, - created_at: new Date().toISOString(), - timestamp: new Date().toISOString(), - updated_at: new Date().toISOString(), - transcript: undefined, - } as ConversationChunk, - ] - : []; - }, - ); - - queryClient.setQueryData( - ["participant", "conversation_chunks", variables.conversationId], - (oldData: ConversationChunk[] | undefined) => { - return oldData - ? [ - ...oldData, - { - id: "optimistic-" + Date.now(), - conversation_id: variables.conversationId, - created_at: new Date().toISOString(), - timestamp: new Date().toISOString(), - updated_at: new Date().toISOString(), - transcript: undefined, - } as ConversationChunk, - ] - : []; - }, - ); - - // Return a context object with the snapshotted value - return { previousChunks }; - }, - // If the mutation fails, - // use the context returned from onMutate to roll back - onError: (_err, variables, context) => { - queryClient.setQueryData( - ["conversations", variables.conversationId, "chunks"], - context?.previousChunks, - ); - }, - // Always refetch after error or success: - onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ - queryKey: ["conversations", variables.conversationId], - }); - - queryClient.invalidateQueries({ - queryKey: [ - "participant", - "conversation_chunks", - variables.conversationId, - ], - }); - }, - }); -}; - -export const useUploadConversationTextChunk = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: uploadConversationText, - retry: 10, - // When mutate is called: - onMutate: async (variables) => { - // Cancel any outgoing refetches - // (so they don't overwrite our optimistic update) - await queryClient.cancelQueries({ - queryKey: ["conversations", variables.conversationId, "chunks"], - }); - - await queryClient.cancelQueries({ - queryKey: [ - "participant", - "conversation_chunks", - variables.conversationId, - ], - }); - - // Snapshot the previous value - const previousChunks = queryClient.getQueryData([ - "conversations", - variables.conversationId, - "chunks", - ]); - - // Optimistically update to the new value - queryClient.setQueryData( - ["conversations", variables.conversationId, "chunks"], - (oldData: ConversationChunk[] | undefined) => { - return oldData - ? [ - ...oldData, - { - id: "optimistic-" + Date.now(), - conversation_id: variables.conversationId, - created_at: new Date().toISOString(), - timestamp: new Date().toISOString(), - updated_at: new Date().toISOString(), - transcript: undefined, - } as ConversationChunk, - ] - : []; - }, - ); - - queryClient.setQueryData( - ["participant", "conversation_chunks", variables.conversationId], - (oldData: ConversationChunk[] | undefined) => { - return oldData - ? [ - ...oldData, - { - id: "optimistic-" + Date.now(), - conversation_id: variables.conversationId, - created_at: new Date().toISOString(), - timestamp: new Date().toISOString(), - updated_at: new Date().toISOString(), - transcript: undefined, - } as ConversationChunk, - ] - : []; - }, - ); - - // Return a context object with the snapshotted value - return { previousChunks }; - }, - // If the mutation fails, - // use the context returned from onMutate to roll back - onError: (_err, variables, context) => { - queryClient.setQueryData( - ["conversations", variables.conversationId, "chunks"], - context?.previousChunks, - ); - }, - // Always refetch after error or success: - onSettled: (_data, _error, variables) => { - queryClient.invalidateQueries({ - queryKey: ["conversations", variables.conversationId, "chunks"], - }); - - queryClient.invalidateQueries({ - queryKey: [ - "participant", - "conversation_chunks", - variables.conversationId, - ], - }); - }, - }); -}; - -export const useUploadConversation = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (payload: { - projectId: string; - pin: string; - namePrefix: string; - tagIdList: string[]; - chunks: Blob[]; - timestamps: Date[]; - email?: string; - onProgress?: (fileName: string, progress: number) => void; - }) => initiateAndUploadConversationChunk(payload), - onMutate: () => { - // When the mutation starts, cancel any in-progress queries - // to prevent them from overwriting our optimistic update - queryClient.cancelQueries({ queryKey: ["conversations"] }); - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["conversations"], - }); - queryClient.invalidateQueries({ - queryKey: ["projects"], - }); - toast.success("Conversation(s) uploaded successfully"); - }, - onError: (error) => { - toast.error( - `Upload failed: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - }, - retry: 3, // Reduced retry count to avoid too many duplicate attempts - }); -}; - -export const useConversationChunks = ( - conversationId: string, - refetchInterval: number = 10000, -) => { - return useQuery({ - queryKey: ["conversations", conversationId, "chunks"], - queryFn: () => - directus.request( - readItems("conversation_chunk", { - filter: { - conversation_id: { - _eq: conversationId, - }, - }, - sort: "timestamp", - }), - ), - refetchInterval, - }); -}; - -export const useInfiniteConversationChunks = ( - conversationId: string, - options?: { - initialLimit?: number; - refetchInterval?: number | false; - }, -) => { - const defaultOptions = { - initialLimit: 10, - refetchInterval: 30000, - }; - - const { initialLimit, refetchInterval } = { ...defaultOptions, ...options }; - - return useInfiniteQuery({ - queryKey: ["conversations", conversationId, "chunks", "infinite"], - queryFn: async ({ pageParam = 0 }) => { - const response = await directus.request( - readItems("conversation_chunk", { - filter: { - conversation_id: { - _eq: conversationId, - }, - }, - sort: ["timestamp"], - limit: initialLimit, - offset: pageParam * initialLimit, - }), - ); - - return { - chunks: response, - nextOffset: - response.length === initialLimit ? pageParam + 1 : undefined, - }; - }, - initialPageParam: 0, - getNextPageParam: (lastPage) => lastPage.nextOffset, - refetchInterval, - }); -}; - -export const useDeleteTagByIdMutation = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (tagId: string) => - directus.request(deleteItem("project_tag", tagId)), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["projects"], - }); - toast.success("Tag deleted successfully"); - }, - }); -}; - -export const useCreateProjectTagMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (payload: { - project_id: { - id: string; - directus_user_id: string; - }; - text: string; - sort?: number; - }) => directus.request(createItem("project_tag", payload as any)), - onSuccess: (_, variables) => { - queryClient.invalidateQueries({ - queryKey: ["projects", variables.project_id.id], - }); - toast.success("Tag created successfully"); - }, - }); -}; - -export const useGenerateProjectLibraryMutation = () => { - const client = useQueryClient(); - return useMutation({ - mutationFn: generateProjectLibrary, - onSuccess: (_, variables) => { - toast.success("Analysis requested successfully"); - client.invalidateQueries({ queryKey: ["projects", variables.projectId] }); - }, - }); -}; - -export const useGenerateProjectViewMutation = () => { - return useMutation({ - mutationFn: generateProjectView, - onSuccess: () => { - toast.success("Analysis requested successfully"); - }, - }); -}; - -export const useLatestProjectAnalysisRunByProjectId = (projectId: string) => { - return useQuery({ - queryKey: ["projects", projectId, "latest_analysis"], - queryFn: () => getLatestProjectAnalysisRunByProjectId(projectId), - refetchInterval: 10000, - }); -}; - -export const useCurrentUser = () => - useQuery({ - queryKey: ["users", "me"], - queryFn: () => { - try { - return directus.request(readUser("me")); - } catch (error) { - return null; - } - }, - }); - -export const useConversationTranscriptString = (conversationId: string) => { - return useQuery({ - queryKey: ["conversations", conversationId, "transcript"], - queryFn: () => getConversationTranscriptString(conversationId), - }); -}; - -export const useCreateChatMutation = () => { - const navigate = useI18nNavigate(); - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async (payload: { - navigateToNewChat?: boolean; - conversationId?: string; - project_id: { - id: string; - }; - }) => { - const project = await directus.request( - readItem("project", payload.project_id.id), - ); - - const chat = await directus.request( - createItem("project_chat", { - ...(payload as any), - auto_select: !!project.is_enhanced_audio_processing_enabled, - }), - ); - - try { - if (payload.conversationId) { - await addChatContext(chat.id, payload.conversationId); - } - } catch (error) { - console.error("Failed to add conversation to chat:", error); - toast.error("Failed to add conversation to chat"); - } - - if (payload.navigateToNewChat && chat && chat.id) { - navigate(`/projects/${payload.project_id.id}/chats/${chat.id}`); - } - - return chat; - }, - onSuccess: (_, variables) => { - queryClient.invalidateQueries({ - queryKey: ["projects", variables.project_id.id, "chats"], - }); - toast.success("Chat created successfully"); - }, - }); -}; - -export const useDeleteChatMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (payload: { chatId: string; projectId: string }) => - directus.request(deleteItem("project_chat", payload.chatId)), - onSuccess: (_, vars) => { - queryClient.invalidateQueries({ - queryKey: ["projects", vars.projectId, "chats"], - }); - queryClient.invalidateQueries({ - queryKey: ["chats", vars.chatId], - }); - toast.success("Chat deleted successfully"); - }, - }); -}; - -export const useChat = (chatId: string) => { - return useQuery({ - queryKey: ["chats", chatId], - queryFn: () => - directus.request( - readItem("project_chat", chatId, { - fields: [ - "*", - { - used_conversations: ["*"], - }, - ], - }), - ), - }); -}; - -export const useUpdateChatMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (payload: { - chatId: string; - // for invalidating the chat query - projectId: string; - payload: Partial; - }) => - directus.request( - updateItem("project_chat", payload.chatId, { - project_id: { - id: payload.projectId, - }, - ...payload.payload, - }), - ), - onSuccess: (_, vars) => { - queryClient.invalidateQueries({ - queryKey: ["projects", vars.projectId, "chats"], - }); - - queryClient.invalidateQueries({ - queryKey: ["chats", vars.chatId], - }); - toast.success("Chat updated successfully"); - }, - }); -}; - -export const useProjectChats = ( - projectId: string, - query?: Partial>, -) => { - return useQuery({ - queryKey: ["projects", projectId, "chats", query], - queryFn: () => - directus.request( - readItems("project_chat", { - fields: ["id", "project_id", "date_created", "date_updated", "name"], - sort: "-date_created", - filter: { - project_id: { - _eq: projectId, - }, - }, - ...query, - }), - ), - }); -}; - -export const useProjectChatContext = (chatId: string) => { - return useQuery({ - queryKey: ["chats", "context", chatId], - queryFn: () => getProjectChatContext(chatId), - enabled: chatId !== "", - }); -}; - -export const useAddChatContextMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (payload: { - chatId: string; - conversationId?: string; - auto_select_bool?: boolean; - }) => - addChatContext( - payload.chatId, - payload.conversationId, - payload.auto_select_bool, - ), - onMutate: async (variables) => { - // Cancel any outgoing refetches - await queryClient.cancelQueries({ - queryKey: ["chats", "context", variables.chatId], - }); - - // Snapshot the previous value - const previousChatContext = queryClient.getQueryData([ - "chats", - "context", - variables.chatId, - ]); - - // Optimistically update the chat context - let optimisticId: string | undefined = undefined; - queryClient.setQueryData( - ["chats", "context", variables.chatId], - (oldData: TProjectChatContext | undefined) => { - if (!oldData) return oldData; - - // If conversationId is provided, add it to the conversations array - if (variables.conversationId) { - const existingConversation = oldData.conversations.find( - (conv) => conv.conversation_id === variables.conversationId, - ); - - if (!existingConversation) { - optimisticId = "optimistic-" + Date.now() + Math.random(); - return { - ...oldData, - conversations: [ - ...oldData.conversations, - { - conversation_id: variables.conversationId, - conversation_participant_name: t`Loading...`, - locked: false, - token_usage: 0, - optimisticId, - }, - ], - }; - } - } - - // If auto_select_bool is provided, update it - if (variables.auto_select_bool !== undefined) { - return { - ...oldData, - auto_select_bool: variables.auto_select_bool, - }; - } - - return oldData; - }, - ); - - // Return a context object with the snapshotted value - return { - previousChatContext, - optimisticId, - conversationId: variables.conversationId ?? undefined, - }; - }, - onError: (error, variables, context) => { - Sentry.captureException(error); - - // Only rollback the failed optimistic entry - if (context?.optimisticId && context?.conversationId) { - queryClient.setQueryData( - ["chats", "context", variables.chatId], - (oldData: TProjectChatContext | undefined) => { - if (!oldData) return oldData; - return { - ...oldData, - conversations: oldData.conversations.filter( - (conv) => - conv.conversation_id !== context.conversationId || - conv.optimisticId !== context.optimisticId, - ), - }; - }, - ); - } else if (context?.previousChatContext) { - // fallback: full rollback - queryClient.setQueryData( - ["chats", "context", variables.chatId], - context.previousChatContext, - ); - } - - if (error instanceof AxiosError) { - let errorMessage = t`Failed to add conversation to chat${ - error.response?.data?.detail ? `: ${error.response.data.detail}` : "" - }`; - if (variables.auto_select_bool) { - errorMessage = t`Failed to enable Auto Select for this chat`; - } - toast.error(errorMessage); - } else { - let errorMessage = t`Failed to add conversation to chat`; - if (variables.auto_select_bool) { - errorMessage = t`Failed to enable Auto Select for this chat`; - } - toast.error(errorMessage); - } - }, - onSettled: (_, __, variables) => { - queryClient.invalidateQueries({ - queryKey: ["chats", "context", variables.chatId], - }); - }, - onSuccess: (_, variables) => { - const message = variables.auto_select_bool - ? t`Auto-select enabled` - : t`Conversation added to chat`; - toast.success(message); - }, - }); -}; - -export const useDeleteChatContextMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (payload: { - chatId: string; - conversationId?: string; - auto_select_bool?: boolean; - }) => - deleteChatContext( - payload.chatId, - payload.conversationId, - payload.auto_select_bool, - ), - onMutate: async (variables) => { - // Cancel any outgoing refetches - await queryClient.cancelQueries({ - queryKey: ["chats", "context", variables.chatId], - }); - - // Snapshot the previous value - const previousChatContext = queryClient.getQueryData([ - "chats", - "context", - variables.chatId, - ]); - - // Optimistically update the chat context - queryClient.setQueryData( - ["chats", "context", variables.chatId], - (oldData: TProjectChatContext | undefined) => { - if (!oldData) return oldData; - - // If conversationId is provided, remove it from the conversations array - if (variables.conversationId) { - const conversationToRemove = oldData.conversations.find( - (conv) => conv.conversation_id === variables.conversationId, - ); - - if (conversationToRemove) { - return { - ...oldData, - conversations: oldData.conversations.filter( - (conv) => conv.conversation_id !== variables.conversationId, - ), - }; - } - } - - // If auto_select_bool is provided, update it - if (variables.auto_select_bool !== undefined) { - return { - ...oldData, - auto_select_bool: variables.auto_select_bool, - }; - } - - return oldData; - }, - ); - - // Return a context object with the snapshotted value - return { - previousChatContext, - conversationId: variables.conversationId ?? undefined, - }; - }, - onError: (error, variables, context) => { - Sentry.captureException(error); - - // Only rollback the failed optimistic entry - if (context?.conversationId) { - queryClient.setQueryData( - ["chats", "context", variables.chatId], - (oldData: TProjectChatContext | undefined) => { - if (!oldData) return oldData; - - // Find the conversation that was removed optimistically - const previousContext = context.previousChatContext as - | TProjectChatContext - | undefined; - const removedConversation = previousContext?.conversations?.find( - (conv) => conv.conversation_id === context.conversationId, - ); - - if (removedConversation) { - return { - ...oldData, - conversations: [ - ...oldData.conversations, - { - ...removedConversation, - }, - ], - }; - } - - return oldData; - }, - ); - } else if (context?.previousChatContext) { - // fallback: full rollback - queryClient.setQueryData( - ["chats", "context", variables.chatId], - context.previousChatContext, - ); - } - - if (error instanceof AxiosError) { - let errorMessage = t`Failed to remove conversation from chat${ - error.response?.data?.detail ? `: ${error.response.data.detail}` : "" - }`; - if (variables.auto_select_bool === false) { - errorMessage = t`Failed to disable Auto Select for this chat`; - } - toast.error(errorMessage); - } else { - let errorMessage = t`Failed to remove conversation from chat`; - if (variables.auto_select_bool === false) { - errorMessage = t`Failed to disable Auto Select for this chat`; - } - toast.error(errorMessage); - } - }, - onSettled: (_, __, variables) => { - queryClient.invalidateQueries({ - queryKey: ["chats", "context", variables.chatId], - }); - }, - onSuccess: (_, variables) => { - const message = - variables.auto_select_bool === false - ? t`Auto-select disabled` - : t`Conversation removed from chat`; - toast.success(message); - }, - }); -}; - -export const useChatHistory = (chatId: string) => { - return useQuery({ - queryKey: ["chats", "history", chatId], - queryFn: () => getChatHistory(chatId ?? ""), - }); -}; - -export const useLockConversationsMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (payload: { chatId: string }) => - lockConversations(payload.chatId), - onSuccess: (_, vars) => { - queryClient.invalidateQueries({ - queryKey: ["chats", "context", vars.chatId], - }); - queryClient.invalidateQueries({ - queryKey: ["chats", "history", vars.chatId], - }); - }, - }); -}; - -export const useAddChatMessageMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (payload: Partial) => - directus.request(createItem("project_chat_message", payload as any)), - onSuccess: (_, vars) => { - queryClient.invalidateQueries({ - queryKey: ["chats", "context", vars.project_chat_id], - }); - queryClient.invalidateQueries({ - queryKey: ["chats", "history", vars.project_chat_id], - }); - }, - }); -}; - -export const useMoveConversationMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async ({ - conversationId, - targetProjectId, - }: { - conversationId: string; - targetProjectId: string; - }) => { - try { - await directus.request( - updateItem("conversation", conversationId, { - project_id: targetProjectId, - }), - ); - } catch (error) { - toast.error("Failed to move conversation."); - } - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["conversations"] }); - queryClient.invalidateQueries({ queryKey: ["projects"] }); - toast.success("Conversation moved successfully"); - }, - onError: (error: Error) => { - toast.error("Failed to move conversation: " + error.message); - }, - }); -}; - -export const useLatestProjectReport = (projectId: string) => { - return useQuery({ - queryKey: ["projects", projectId, "report"], - queryFn: async () => { - const reports = await directus.request( - readItems("project_report", { - filter: { - project_id: { - _eq: projectId, - }, - }, - fields: ["*"], - sort: "-date_created", - limit: 1, - }), - ); - - if (reports.length === 0) { - return null; - } - - return reports[0]; - }, - refetchInterval: 30000, - }); -}; - -export const useProjectReportViews = (reportId: number) => { - return useQuery({ - queryKey: ["reports", reportId, "views"], - queryFn: async () => { - const report = await directus.request( - readItem("project_report", reportId, { - fields: ["project_id"], - }), - ); - - const total = await directus.request( - aggregate("project_report_metric", { - aggregate: { - count: "*", - }, - query: { - filter: { - project_report_id: { - project_id: { - _eq: report.project_id, - }, - }, - }, - }, - }), - ); - - const recent = await directus.request( - aggregate("project_report_metric", { - aggregate: { - count: "*", - }, - query: { - filter: { - project_report_id: {}, - // in the last 10 mins - date_created: { - // @ts-ignore - _gte: new Date(Date.now() - 10 * 60 * 1000).toISOString(), - }, - }, - }, - }), - ); - - return { - total: total[0].count, - recent: recent[0].count, - }; - }, - refetchInterval: 30000, - }); -}; - -export const useProjectReport = (reportId: number) => { - return useQuery({ - queryKey: ["reports", reportId], - queryFn: () => directus.request(readItem("project_report", reportId)), - refetchInterval: 30000, - }); -}; - -// always give the project_id in payload used for invalidation -export const useUpdateProjectReportMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ - reportId, - payload, - }: { - reportId: number; - payload: Partial; - }) => directus.request(updateItem("project_report", reportId, payload)), - onSuccess: (_, vars) => { - const projectId = vars.payload.project_id; - const projectIdValue = - typeof projectId === "object" && projectId !== null - ? projectId.id - : projectId; - - queryClient.invalidateQueries({ - queryKey: ["projects", projectIdValue, "report"], - }); - queryClient.invalidateQueries({ - queryKey: ["reports"], - }); - }, - }); -}; - -export const useCreateProjectReportMetricOncePerDayMutation = () => { - return useMutation({ - mutationFn: ({ payload }: { payload: Partial }) => { - const key = `rm_${payload.project_report_id}_updated`; - let shouldUpdate = false; - - try { - const lastUpdated = localStorage.getItem(key); - if (!lastUpdated) { - shouldUpdate = true; - } else { - const lastUpdateTime = new Date(lastUpdated).getTime(); - const currentTime = new Date().getTime(); - const hoursDiff = (currentTime - lastUpdateTime) / (1000 * 60 * 60); - shouldUpdate = hoursDiff >= 24; - } - - if (shouldUpdate) { - localStorage.setItem(key, new Date().toISOString()); - } - } catch (e) { - // Ignore localStorage errors - shouldUpdate = true; - } - - if (!shouldUpdate) { - return Promise.resolve(null); - } - - return directus.request( - createItem("project_report_metric", payload as any), - ); - }, - }); -}; - -export const useCreateProjectReportMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: createProjectReport, - onSuccess: (_, vars) => { - queryClient.invalidateQueries({ - queryKey: ["projects", vars.projectId, "report"], - }); - queryClient.invalidateQueries({ - queryKey: ["reports"], - }); - }, - }); -}; - -export const useDoesProjectReportNeedUpdate = (projectReportId: number) => { - return useQuery({ - queryKey: ["reports", projectReportId, "needsUpdate"], - queryFn: async () => { - const reports = await directus.request( - readItems("project_report", { - filter: { - id: { - _eq: projectReportId, - }, - status: { - _eq: "published", - }, - }, - fields: ["id", "date_created", "project_id"], - sort: "-date_created", - limit: 1, - }), - ); - - if (reports.length === 0) { - return false; - } - - const latestReport = reports[0]; - - const latestConversation = await directus.request( - readItems("conversation", { - filter: { - project_id: { - _eq: latestReport.project_id, - }, - }, - fields: ["id", "created_at"], - sort: "-created_at", - limit: 1, - }), - ); - - if (latestConversation.length === 0) { - return false; - } - - return ( - new Date(latestConversation[0].created_at!) > - new Date(latestReport.date_created!) - ); - }, - }); -}; - -// always give the project_report_id in payload used for invalidation -export const usePostProjectReportMetricMutation = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (payload: { - reportId: number; - payload: Partial; - }) => directus.request(createItem("project_report_metric", payload as any)), - onSuccess: (_, vars) => { - queryClient.invalidateQueries({ - queryKey: ["projects", vars.payload.project_report_id, "report"], - }); - queryClient.invalidateQueries({ - queryKey: ["reports", vars.reportId], - }); - }, - }); -}; - -/** - * Gathers data needed to build a timeline chart of: - * 1) Project creation (vertical reference line). - * 2) This specific project report creation (vertical reference line). - * 3) Green "stem" lines representing Conversations created (height = number of conversation chunks). Uses Directus aggregate() to get counts. - * 4) Blue line points representing Project Report Metrics associated with the given project_report_id (e.g., "views", "score", etc.). - * - * Based on Mantine Charts docs: https://mantine.dev/charts/line-chart/#reference-lines - * - * NOTES: - * - Make sure you match your date fields in Directus (e.g., "date_created" vs. "created_at"). - * - For any chart "stems", you typically create two data points with the same X but different Y values. - */ -export const useProjectReportTimelineData = (projectReportId: string) => { - return useQuery({ - queryKey: ["reports", projectReportId, "timelineData"], - queryFn: async () => { - // 1. Fetch the project report so we know the projectId and the report's creation date - const projectReport = await directus.request( - readItem("project_report", projectReportId, { - fields: ["id", "date_created", "project_id"], - }), - ); - - if (!projectReport?.project_id) { - throw new Error("No project_id found on this report"); - } - - const allProjectReports = await directus.request( - readItems("project_report", { - filter: { - project_id: { - _eq: projectReport.project_id, - }, - }, - limit: 1000, - sort: "date_created", - }), - ); - - // 2. Fetch the project to get the creation date - // Adjust fields to match your date field naming - const project = await directus.request( - readItem("project", projectReport.project_id.toString(), { - fields: ["id", "created_at"], // or ["id", "created_at"] - }), - ); - - // 3. Fetch all Conversations and use an aggregate to count conversation_chunks - const conversations = await directus.request( - readItems("conversation", { - fields: ["id", "created_at"], // or ["id", "date_created"] - filter: { - project_id: { - _eq: projectReport.project_id, - }, - }, - limit: 1000, // adjust to your needs - }), - ); - - // Aggregate chunk counts per conversation with Directus aggregator - let conversationChunkAgg: { conversation_id: string; count: number }[] = - []; - if (conversations.length > 0) { - const conversationIds = conversations.map((c) => c.id); - const chunkCountsAgg = await directus.request< - Array<{ conversation_id: string; count: number }> - >( - readItems("conversation_chunk", { - aggregate: { count: "*" }, - groupBy: ["conversation_id"], - filter: { conversation_id: { _in: conversationIds } }, - }), - ); - - // chunkCountsAgg shape is [{ conversation_id: '...', count: 5 }, ...] - conversationChunkAgg = chunkCountsAgg; - } - - // 4. Fetch all Project Report Metrics for this project_report_id - // (e.g., type "view", "score," etc. – adapt as needed) - const projectReportMetrics = await directus.request< - ProjectReportMetric[] - >( - readItems("project_report_metric", { - fields: ["id", "date_created", "project_report_id"], - filter: { - project_report_id: { - project_id: { - _eq: project.id, - }, - }, - }, - sort: "date_created", - limit: 1000, - }), - ); - - // Return all structured data. The consuming component can then create the chart data arrays. - return { - projectCreatedAt: project.created_at, - reportCreatedAt: projectReport.date_created, - allReports: allProjectReports.map((r) => ({ - id: r.id, - createdAt: r.date_created, - })), - conversations: conversations, - conversationChunks: conversations.map((conv) => { - const aggRow = conversationChunkAgg.find( - (row) => row.conversation_id === conv.id, - ); - return { - conversationId: conv.id, - createdAt: conv.created_at, - chunkCount: aggRow?.count ?? 0, - }; - }), - projectReportMetrics, - }; - }, - }); -}; - -export const useConversationChunkContentUrl = ( - conversationId: string, - chunkId: string, - enabled: boolean = true, -) => { - return useQuery({ - queryKey: ["conversation", conversationId, "chunk", chunkId, "audio-url"], - queryFn: async () => { - const url = getConversationChunkContentLink( - conversationId, - chunkId, - true, - ); - return apiNoAuth.get(url); - }, - enabled, - staleTime: 1000 * 60 * 30, // 30 minutes - gcTime: 1000 * 60 * 60, // 1 hour - }); -}; - -export const useRetranscribeConversationMutation = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: ({ - conversationId, - newConversationName, - }: { - conversationId: string; - newConversationName: string; - }) => retranscribeConversation(conversationId, newConversationName), - onSuccess: (_data) => { - // Invalidate all conversation related queries - queryClient.invalidateQueries({ - queryKey: ["conversations"], - }); - - // Toast success message - toast.success( - t`Retranscription started. New conversation will be available soon.`, - ); - }, - onError: (error) => { - toast.error(t`Failed to retranscribe conversation. Please try again.`); - console.error("Retranscribe error:", error); - }, - }); -}; - -// Higher-level hook for managing conversation uploads with better state control -export const useConversationUploader = () => { - const [isUploading, setIsUploading] = useState(false); - const [uploadProgress, setUploadProgress] = useState>( - {}, - ); - const [uploadErrors, setUploadErrors] = useState>({}); - const uploadMutation = useUploadConversation(); - // Use a ref to track if we've completed the upload to avoid multiple state updates - const hasCompletedRef = useRef(false); - // Use refs to track previous state to avoid unnecessary updates - const progressRef = useRef>({}); - const errorsRef = useRef>({}); - - // Clean up function to reset states - const resetUpload = useCallback(() => { - hasCompletedRef.current = false; - progressRef.current = {}; - errorsRef.current = {}; - setIsUploading(false); - setUploadProgress({}); - setUploadErrors({}); - uploadMutation.reset(); - }, [uploadMutation]); - - // Handle real progress updates with debouncing - const handleProgress = useCallback((fileName: string, progress: number) => { - // Only update if progress actually changed by at least 1% - if (Math.abs((progressRef.current[fileName] || 0) - progress) < 1) { - return; // Skip tiny updates that don't matter visually - } - - // Update the ref and then the state - progressRef.current = { - ...progressRef.current, - [fileName]: progress, - }; - - setUploadProgress((prev) => ({ - ...prev, - [fileName]: progress, - })); - }, []); - - // Upload files with real progress tracking - const uploadFiles = useCallback( - (payload: { - projectId: string; - pin: string; - namePrefix: string; - tagIdList: string[]; - chunks: Blob[]; - timestamps: Date[]; - email?: string; - }) => { - // Don't start if already uploading - if (isUploading || uploadMutation.isPending) { - return; - } - - hasCompletedRef.current = false; - - // Initialize progress tracking for all files - const initialProgress: Record = {}; - payload.chunks.forEach((chunk) => { - const name = - chunk instanceof File - ? chunk.name - : `chunk-${payload.chunks.indexOf(chunk)}`; - initialProgress[name] = 0; - }); - - // Update refs first - progressRef.current = initialProgress; - errorsRef.current = {}; - - // Then update state - setUploadProgress(initialProgress); - setUploadErrors({}); - setIsUploading(true); - - // Start the upload with progress tracking - uploadMutation.mutate({ - ...payload, - onProgress: handleProgress, - }); - }, - [isUploading, uploadMutation, handleProgress], - ); - - // Handle success state - separate from error handling to prevent cycles - useEffect(() => { - // Skip if conditions aren't right - if (!isUploading || !uploadMutation.isSuccess || hasCompletedRef.current) { - return; - } - - // Set flag to prevent repeated updates - hasCompletedRef.current = true; - - // Mark all files as complete when successful - const fileNames = Object.keys(progressRef.current); - if (fileNames.length > 0) { - // Update refs first - const completed = { ...progressRef.current }; - fileNames.forEach((key) => { - completed[key] = 100; - }); - progressRef.current = completed; - - // Then update state - do this once rather than per file - setUploadProgress(completed); - } - }, [uploadMutation.isSuccess, isUploading]); - - // Handle error state separately - useEffect(() => { - // Skip if conditions aren't right - if (!isUploading || !uploadMutation.isError) { - return; - } - - // Only do this once - if (Object.keys(errorsRef.current).length > 0) { - return; - } - - // Set errors on failure - const fileNames = Object.keys(progressRef.current); - if (fileNames.length > 0) { - // Update refs first - const newErrors = { ...errorsRef.current }; - const errorMessage = uploadMutation.error?.message || "Upload failed"; - - fileNames.forEach((key) => { - newErrors[key] = errorMessage; - }); - errorsRef.current = newErrors; - - // Then update state - do this once rather than per file - setUploadErrors(newErrors); - } - }, [uploadMutation.isError, isUploading, uploadMutation.error]); - - return { - uploadFiles, - resetUpload, - isUploading, - uploadProgress, - uploadErrors, - isSuccess: uploadMutation.isSuccess, - isError: uploadMutation.isError, - isPending: uploadMutation.isPending, - error: uploadMutation.error, - }; -}; - -export const useCheckUnsubscribeStatus = (token: string, projectId: string) => { - return useQuery<{ eligible: boolean }>({ - queryKey: ["checkUnsubscribe", token, projectId], - queryFn: async () => { - if (!token || !projectId) { - throw new Error("Invalid or missing unsubscribe link."); - } - const response = await checkUnsubscribeStatus(token, projectId); - return response.data; - }, - retry: false, - refetchOnWindowFocus: false, - }); -}; - -export const useGetProjectParticipants = (project_id: string) => { - return useQuery({ - queryKey: ["projectParticipants", project_id], - queryFn: async () => { - if (!project_id) return 0; - - const submissions = await directus.request( - readItems("project_report_notification_participants", { - filter: { - _and: [ - { project_id: { _eq: project_id } }, - { email_opt_in: { _eq: true } }, - ], - }, - fields: ["id"], - }), - ); - - return submissions.length; - }, - enabled: !!project_id, // Only run query if project_id exists - }); -}; - -export const useSubmitNotificationParticipant = () => { - return useMutation({ - mutationFn: async ({ - emails, - projectId, - conversationId, - }: { - emails: string[]; - projectId: string; - conversationId: string; - }) => { - return await submitNotificationParticipant( - emails, - projectId, - conversationId, - ); - }, - retry: 2, - onError: (error) => { - console.error("Notification submission failed:", error); - }, - }); -}; diff --git a/echo/frontend/src/routes/Debug.tsx b/echo/frontend/src/routes/Debug.tsx index ef3fac4d..e4dbcc33 100644 --- a/echo/frontend/src/routes/Debug.tsx +++ b/echo/frontend/src/routes/Debug.tsx @@ -15,12 +15,10 @@ import { import { toast } from "@/components/common/Toaster"; import { useRef, useState, useMemo, useEffect } from "react"; import { useParams } from "react-router-dom"; -import { - useConversationById, - useCurrentUser, - useProjectById, - useProjectChats, -} from "@/lib/query"; +import { useProjectById } from "@/components/project/hooks"; +import { useConversationById } from "@/components/conversation/hooks"; +import { useProjectChats } from "@/components/chat/hooks"; +import { useCurrentUser } from "@/components/auth/hooks"; import { ENABLE_CHAT_AUTO_SELECT, API_BASE_URL, diff --git a/echo/frontend/src/routes/auth/Login.tsx b/echo/frontend/src/routes/auth/Login.tsx index 9fb98005..732ae0df 100644 --- a/echo/frontend/src/routes/auth/Login.tsx +++ b/echo/frontend/src/routes/auth/Login.tsx @@ -3,7 +3,8 @@ import { Trans } from "@lingui/react/macro"; import { DIRECTUS_PUBLIC_URL } from "@/config"; import { useI18nNavigate } from "@/hooks/useI18nNavigate"; import { directus } from "@/lib/directus"; -import { useCreateProjectMutation, useLoginMutation } from "@/lib/query"; +import { useLoginMutation } from "@/components/auth/hooks"; +import { useCreateProjectMutation } from "@/components/project/hooks"; import { readItems, readProviders } from "@directus/sdk"; import { Alert, diff --git a/echo/frontend/src/routes/auth/PasswordReset.tsx b/echo/frontend/src/routes/auth/PasswordReset.tsx index 568dbfba..52f6405d 100644 --- a/echo/frontend/src/routes/auth/PasswordReset.tsx +++ b/echo/frontend/src/routes/auth/PasswordReset.tsx @@ -1,6 +1,6 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { useResetPasswordMutation } from "@/lib/query"; +import { useResetPasswordMutation } from "@/components/auth/hooks"; import { Alert, Button, diff --git a/echo/frontend/src/routes/auth/Register.tsx b/echo/frontend/src/routes/auth/Register.tsx index bad72347..95aa3366 100644 --- a/echo/frontend/src/routes/auth/Register.tsx +++ b/echo/frontend/src/routes/auth/Register.tsx @@ -2,7 +2,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { I18nLink } from "@/components/common/i18nLink"; import { ADMIN_BASE_URL } from "@/config"; -import { useRegisterMutation } from "@/lib/query"; +import { useRegisterMutation } from "@/components/auth/hooks"; import { Alert, Button, diff --git a/echo/frontend/src/routes/auth/RequestPasswordReset.tsx b/echo/frontend/src/routes/auth/RequestPasswordReset.tsx index 4e0e5268..0767376f 100644 --- a/echo/frontend/src/routes/auth/RequestPasswordReset.tsx +++ b/echo/frontend/src/routes/auth/RequestPasswordReset.tsx @@ -1,6 +1,6 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { useRequestPasswordResetMutation } from "@/lib/query"; +import { useRequestPasswordResetMutation } from "@/components/auth/hooks"; import { Button, Container, Stack, TextInput, Title } from "@mantine/core"; import { useDocumentTitle } from "@mantine/hooks"; import { useForm } from "react-hook-form"; diff --git a/echo/frontend/src/routes/auth/VerifyEmail.tsx b/echo/frontend/src/routes/auth/VerifyEmail.tsx index 9203ff54..654ff72d 100644 --- a/echo/frontend/src/routes/auth/VerifyEmail.tsx +++ b/echo/frontend/src/routes/auth/VerifyEmail.tsx @@ -1,6 +1,6 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { useVerifyMutation } from "@/lib/query"; +import { useVerifyMutation } from "@/components/auth/hooks"; import { Container, Group, Loader, Stack, Text, Title } from "@mantine/core"; import { useDocumentTitle } from "@mantine/hooks"; import { useEffect, useRef } from "react"; diff --git a/echo/frontend/src/routes/participant/ParticipantConversation.tsx b/echo/frontend/src/routes/participant/ParticipantConversation.tsx index 71b93849..1005468d 100644 --- a/echo/frontend/src/routes/participant/ParticipantConversation.tsx +++ b/echo/frontend/src/routes/participant/ParticipantConversation.tsx @@ -1,10 +1,7 @@ import { useI18nNavigate } from "@/hooks/useI18nNavigate"; import { I18nLink } from "@/components/common/i18nLink"; import { useProjectSharingLink } from "@/components/project/ProjectQRCode"; -import { - useUploadConversationChunk, - useUploadConversationTextChunk, -} from "@/lib/query"; +import { useUploadConversationChunk, useUploadConversationTextChunk } from "@/components/participant/hooks"; import { ActionIcon, Box, diff --git a/echo/frontend/src/routes/participant/ParticipantPostConversation.tsx b/echo/frontend/src/routes/participant/ParticipantPostConversation.tsx index 79742e2f..f0c81aa2 100644 --- a/echo/frontend/src/routes/participant/ParticipantPostConversation.tsx +++ b/echo/frontend/src/routes/participant/ParticipantPostConversation.tsx @@ -29,9 +29,7 @@ import { useParams } from "react-router-dom"; import { useMutation } from "@tanstack/react-query"; import { directus } from "@/lib/directus"; import { readItems, createItems } from "@directus/sdk"; -import { - useSubmitNotificationParticipant, -} from "@/lib/query"; +import { useSubmitNotificationParticipant } from "@/components/participant/hooks"; export const ParticipantPostConversation = () => { const { projectId, conversationId } = useParams(); diff --git a/echo/frontend/src/routes/participant/ParticipantReport.tsx b/echo/frontend/src/routes/participant/ParticipantReport.tsx index d0189278..83bd4794 100644 --- a/echo/frontend/src/routes/participant/ParticipantReport.tsx +++ b/echo/frontend/src/routes/participant/ParticipantReport.tsx @@ -1,10 +1,7 @@ import { useParams, useSearchParams } from "react-router-dom"; import { ReportRenderer } from "@/components/report/ReportRenderer"; -import { - useCreateProjectReportMetricOncePerDayMutation, - useLatestProjectReport, - useProjectReportViews, -} from "@/lib/query"; +import { useCreateProjectReportMetricOncePerDayMutation } from "@/components/participant/hooks"; +import { useProjectReportViews, useLatestProjectReport } from "@/components/report/hooks"; import { LoadingOverlay, Text, Title, Stack } from "@mantine/core"; import { Trans } from "@lingui/react/macro"; import { Logo } from "@/components/common/Logo"; diff --git a/echo/frontend/src/routes/project/ProjectRoutes.tsx b/echo/frontend/src/routes/project/ProjectRoutes.tsx index c6a4b8b8..dd8d9ac3 100644 --- a/echo/frontend/src/routes/project/ProjectRoutes.tsx +++ b/echo/frontend/src/routes/project/ProjectRoutes.tsx @@ -3,7 +3,7 @@ import ProjectBasicEdit from "@/components/project/ProjectBasicEdit"; import { ProjectDangerZone } from "@/components/project/ProjectDangerZone"; import { ProjectPortalEditor } from "@/components/project/ProjectPortalEditor"; import { getProjectTranscriptsLink } from "@/lib/api"; -import { useProjectById } from "@/lib/query"; +import { useProjectById } from "@/components/project/hooks"; import { Alert, Box, diff --git a/echo/frontend/src/routes/project/ProjectsHome.tsx b/echo/frontend/src/routes/project/ProjectsHome.tsx index 14a4bb8a..204bd211 100644 --- a/echo/frontend/src/routes/project/ProjectsHome.tsx +++ b/echo/frontend/src/routes/project/ProjectsHome.tsx @@ -7,10 +7,10 @@ import { getDirectusErrorString } from "@/lib/directus"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useCreateProjectMutation, - useCurrentUser, useInfiniteProjects, useUpdateProjectByIdMutation, -} from "@/lib/query"; +} from "@/components/project/hooks"; +import { useCurrentUser } from "@/components/auth/hooks"; import { ActionIcon, Alert, diff --git a/echo/frontend/src/routes/project/chat/ProjectChatRoute.tsx b/echo/frontend/src/routes/project/chat/ProjectChatRoute.tsx index ad8c4d7f..232133b1 100644 --- a/echo/frontend/src/routes/project/chat/ProjectChatRoute.tsx +++ b/echo/frontend/src/routes/project/chat/ProjectChatRoute.tsx @@ -3,12 +3,12 @@ import { Trans } from "@lingui/react/macro"; import { ChatContextProgress } from "@/components/chat/ChatContextProgress"; import { ChatMessage } from "@/components/chat/ChatMessage"; import { + useChat as useProjectChat, useAddChatMessageMutation, useChatHistory, useLockConversationsMutation, - useChat as useProjectChat, useProjectChatContext, -} from "@/lib/query"; +} from "@/components/chat/hooks"; import { Alert, Box, @@ -37,7 +37,10 @@ import { CopyRichTextIconButton } from "@/components/common/CopyRichTextIconButt import { ConversationLinks } from "@/components/conversation/ConversationLinks"; import { ChatHistoryMessage } from "@/components/chat/ChatHistoryMessage"; import { ChatTemplatesMenu } from "@/components/chat/ChatTemplatesMenu"; -import { extractMessageMetadata, formatMessage } from "@/components/chat/chatUtils"; +import { + extractMessageMetadata, + formatMessage, +} from "@/components/chat/chatUtils"; import SourcesSearch from "@/components/chat/SourcesSearch"; import SpikeMessage from "@/components/participant/SpikeMessage"; import { Logo } from "@/components/common/Logo"; @@ -169,7 +172,7 @@ const useDembraneChat = ({ chatId }: { chatId: string }) => { // Lock conversations first await lockConversationsMutation.mutateAsync({ chatId }); await chatContextQuery.refetch(); - + // Submit the chat handleSubmit(); diff --git a/echo/frontend/src/routes/project/conversation/ProjectConversationOverview.tsx b/echo/frontend/src/routes/project/conversation/ProjectConversationOverview.tsx index d10a178e..1fc4c1a7 100644 --- a/echo/frontend/src/routes/project/conversation/ProjectConversationOverview.tsx +++ b/echo/frontend/src/routes/project/conversation/ProjectConversationOverview.tsx @@ -11,11 +11,8 @@ import { Tooltip, } from "@mantine/core"; import { useParams } from "react-router-dom"; -import { - useConversationById, - useConversationChunks, - useProjectById, -} from "@/lib/query"; +import { useProjectById } from "@/components/project/hooks"; +import { useConversationById, useConversationChunks } from "@/components/conversation/hooks"; import { ConversationEdit } from "@/components/conversation/ConversationEdit"; import { ConversationDangerZone } from "@/components/conversation/ConversationDangerZone"; import { finishConversation, generateConversationSummary } from "@/lib/api"; diff --git a/echo/frontend/src/routes/project/conversation/ProjectConversationTranscript.tsx b/echo/frontend/src/routes/project/conversation/ProjectConversationTranscript.tsx index 8dc35ac0..adfe1706 100644 --- a/echo/frontend/src/routes/project/conversation/ProjectConversationTranscript.tsx +++ b/echo/frontend/src/routes/project/conversation/ProjectConversationTranscript.tsx @@ -3,10 +3,10 @@ import { Trans } from "@lingui/react/macro"; import { InformationTooltip } from "@/components/common/InformationTooltip"; import { useConversationById, - useConversationTranscriptString, useInfiniteConversationChunks, useRetranscribeConversationMutation, -} from "@/lib/query"; + useConversationTranscriptString, +} from "@/components/conversation/hooks"; import { ActionIcon, Group, @@ -108,7 +108,8 @@ export const ProjectConversationTranscript = () => { (chunk) => chunk.transcript && chunk.transcript.trim().length > 0, ); - const isEmptyConversation = !hasValidTranscripts && conversationQuery.data?.is_finished; + const isEmptyConversation = + !hasValidTranscripts && conversationQuery.data?.is_finished; const handleDownloadTranscript = (filename: string) => { const text = transcriptQuery.data ?? ""; @@ -124,9 +125,9 @@ export const ProjectConversationTranscript = () => { } else { a.download = "Conversation" + - "-" + - conversationQuery.data.participant_email + - ".md"; + "-" + + conversationQuery.data.participant_email + + ".md"; } } @@ -311,35 +312,30 @@ export const ProjectConversationTranscript = () => { back later. - ) : - // !hasValidTranscripts ? ( - // } - // title={t`Processing Transcript`} - // color="gray" - // > - // - // The transcript for this conversation is being processed. Please - // check back later. - // - // - // ) : - ( - allChunks - .map((chunk, index, array) => { - const isLastChunk = index === array.length - 1; - return ( -
- -
- ); - }) + ) : ( + // !hasValidTranscripts ? ( + // } + // title={t`Processing Transcript`} + // color="gray" + // > + // + // The transcript for this conversation is being processed. Please + // check back later. + // + // + // ) : + allChunks.map((chunk, index, array) => { + const isLastChunk = index === array.length - 1; + return ( +
+ +
+ ); + }) )} {isFetchingNextPage && ( diff --git a/echo/frontend/src/routes/project/library/ProjectLibrary.tsx b/echo/frontend/src/routes/project/library/ProjectLibrary.tsx index e28116b8..1dcf9dff 100644 --- a/echo/frontend/src/routes/project/library/ProjectLibrary.tsx +++ b/echo/frontend/src/routes/project/library/ProjectLibrary.tsx @@ -5,13 +5,13 @@ import { CloseableAlert } from "@/components/common/ClosableAlert"; import { ProjectAnalysisRunStatus } from "@/components/project/ProjectAnalysisRunStatus"; import { ViewExpandedCard } from "@/components/view/View"; import { Icons } from "@/icons"; +import { useProjectById } from "@/components/project/hooks"; import { - useConversationsByProjectId, useGenerateProjectLibraryMutation, - useLatestProjectAnalysisRunByProjectId, - useProjectById, useProjectViews, -} from "@/lib/query"; +} from "@/components/library/hooks"; +import { useLatestProjectAnalysisRunByProjectId } from "@/components/project/hooks"; +import { useConversationsByProjectId } from "@/components/conversation/hooks"; import { useLanguage } from "@/hooks/useLanguage"; import { useAutoAnimate } from "@formkit/auto-animate/react"; import { @@ -81,7 +81,6 @@ export const ProjectLibraryRoute = () => { ); } - const viewsExist = viewsQuery && viewsQuery.data && viewsQuery.data.length > 0; @@ -113,7 +112,7 @@ export const ProjectLibraryRoute = () => { ]} /> - {latestRun ? ( + {latestRun ? ( @@ -205,9 +209,8 @@ export const ProjectLibraryRoute = () => { - {!opened && latestRun - // && latestRun.processing_status === "DONE" - && ( + {!opened && latestRun && ( + // && latestRun.processing_status === "DONE" }> diff --git a/echo/frontend/src/routes/project/library/ProjectLibraryAspect.tsx b/echo/frontend/src/routes/project/library/ProjectLibraryAspect.tsx index f6b47208..76aa4cc0 100644 --- a/echo/frontend/src/routes/project/library/ProjectLibraryAspect.tsx +++ b/echo/frontend/src/routes/project/library/ProjectLibraryAspect.tsx @@ -11,7 +11,8 @@ import { import { useParams } from "react-router-dom"; import { Quote } from "../../../components/quote/Quote"; import { Markdown } from "@/components/common/Markdown"; -import { useAspectById, useProjectById } from "@/lib/query"; +import { useProjectById } from "@/components/project/hooks"; +import { useAspectById } from "@/components/library/hooks"; import { Breadcrumbs } from "@/components/common/Breadcrumbs"; import { useCopyAspect } from "@/hooks/useCopyAspect"; import { CopyIconButton } from "@/components/common/CopyIconButton"; diff --git a/echo/frontend/src/routes/project/library/ProjectLibraryView.tsx b/echo/frontend/src/routes/project/library/ProjectLibraryView.tsx index 2ff58390..b8b8d229 100644 --- a/echo/frontend/src/routes/project/library/ProjectLibraryView.tsx +++ b/echo/frontend/src/routes/project/library/ProjectLibraryView.tsx @@ -3,7 +3,7 @@ import { Markdown } from "@/components/common/Markdown"; import { AspectCard } from "@/components/aspect/AspectCard"; import { Breadcrumbs } from "@/components/common/Breadcrumbs"; import { Icons } from "@/icons"; -import { useViewById } from "@/lib/query"; +import { useViewById } from "@/components/library/hooks"; import { Divider, Group, diff --git a/echo/frontend/src/routes/project/report/ProjectReportRoute.tsx b/echo/frontend/src/routes/project/report/ProjectReportRoute.tsx index f0a06f1b..0bbfb1fa 100644 --- a/echo/frontend/src/routes/project/report/ProjectReportRoute.tsx +++ b/echo/frontend/src/routes/project/report/ProjectReportRoute.tsx @@ -1,11 +1,11 @@ import { useParams } from "react-router-dom"; import { - useDoesProjectReportNeedUpdate, + useUpdateProjectReportMutation, useGetProjectParticipants, - useLatestProjectReport, + useDoesProjectReportNeedUpdate, useProjectReportViews, - useUpdateProjectReportMutation, -} from "@/lib/query"; + useLatestProjectReport, +} from "@/components/report/hooks"; import { ActionIcon, Badge, diff --git a/echo/frontend/src/routes/project/resource/ProjectResourceOverview.tsx b/echo/frontend/src/routes/project/resource/ProjectResourceOverview.tsx index 23edf779..69875bd1 100644 --- a/echo/frontend/src/routes/project/resource/ProjectResourceOverview.tsx +++ b/echo/frontend/src/routes/project/resource/ProjectResourceOverview.tsx @@ -1,9 +1,9 @@ import { Trans } from "@lingui/react/macro"; +import { useResourceById } from "@/components/resource/hooks"; import { useDeleteResourceByIdMutation, - useResourceById, useUpdateResourceByIdMutation, -} from "@/lib/query"; +} from "@/components/resource/hooks"; import { ActionIcon, Box, diff --git a/echo/frontend/src/routes/project/unsubscribe/ProjectUnsubscribe.tsx b/echo/frontend/src/routes/project/unsubscribe/ProjectUnsubscribe.tsx index faaa97eb..a03e80b4 100644 --- a/echo/frontend/src/routes/project/unsubscribe/ProjectUnsubscribe.tsx +++ b/echo/frontend/src/routes/project/unsubscribe/ProjectUnsubscribe.tsx @@ -1,5 +1,5 @@ import { useSearchParams } from "react-router-dom"; -import { useCheckUnsubscribeStatus } from "@/lib/query"; +import { useCheckUnsubscribeStatus } from "@/components/unsubscribe/hooks"; import { Button, Stack, Title, Text, Loader, Group } from "@mantine/core"; import { useState } from "react"; import { Logo } from "@/components/common/Logo";