From c02f3f1684d78a2480c3b7ba3e1b2819a23541ed Mon Sep 17 00:00:00 2001 From: Jack D Date: Wed, 11 Mar 2026 15:52:31 +0000 Subject: [PATCH] Web local wrappers complete --- .../web/src/app/campaigns/email/[id]/page.tsx | 91 +--- .../hooks/useEmailCampaignEditorConvex.ts | 85 ++++ apps/web/src/app/help/[slug]/page.tsx | 69 +-- .../src/app/help/hooks/useHelpCenterConvex.ts | 159 +++++++ apps/web/src/app/help/page.tsx | 64 +-- .../app/inbox/InboxConversationListPane.tsx | 11 +- .../useInboxConversationListPaneConvex.ts | 16 + .../onboarding/hooks/useOnboardingConvex.ts | 73 +++ apps/web/src/app/onboarding/page.tsx | 77 +--- .../hooks/useOutboundMessagesPageConvex.ts | 75 ++++ apps/web/src/app/outbound/page.tsx | 80 +--- apps/web/src/app/reports/ai/page.tsx | 83 +--- .../src/app/reports/conversations/page.tsx | 73 +-- apps/web/src/app/reports/csat/page.tsx | 48 +- .../src/app/reports/hooks/useReportsConvex.ts | 226 ++++++++++ apps/web/src/app/reports/page.tsx | 51 +-- apps/web/src/app/reports/team/page.tsx | 56 +-- .../segments/hooks/useSegmentsPageConvex.ts | 99 +++++ apps/web/src/app/segments/page.tsx | 102 +---- apps/web/src/app/settings/AIAgentSection.tsx | 52 +-- apps/web/src/app/settings/AuditLogViewer.tsx | 128 +----- .../settings/AutomationSettingsSection.tsx | 34 +- .../src/app/settings/HomeSettingsSection.tsx | 39 +- .../src/app/settings/MobileDevicesSection.tsx | 31 +- .../settings/NotificationSettingsSection.tsx | 57 +-- .../settings/SecurityIdentitySettingsCard.tsx | 43 +- .../app/settings/SecuritySettingsSection.tsx | 30 +- .../app/settings/SignedSessionsSettings.tsx | 18 +- .../hooks/useSettingsSectionsConvex.ts | 417 ++++++++++++++++++ .../app/settings/useTeamMembersSettings.ts | 70 +-- .../snippets/hooks/useSnippetsPageConvex.ts | 52 +++ apps/web/src/app/snippets/page.tsx | 48 +- apps/web/src/app/surveys/[id]/page.tsx | 82 +--- .../src/app/surveys/hooks/useSurveysConvex.ts | 127 ++++++ apps/web/src/app/surveys/page.tsx | 71 +-- apps/web/src/app/tickets/[id]/page.tsx | 100 +---- apps/web/src/app/tickets/forms/page.tsx | 65 +-- .../src/app/tickets/hooks/useTicketsConvex.ts | 270 ++++++++++++ apps/web/src/app/tickets/page.tsx | 88 +--- apps/web/src/app/tours/[id]/page.tsx | 166 +------ .../web/src/app/tours/hooks/useToursConvex.ts | 171 +++++++ apps/web/src/app/tours/page.tsx | 60 +-- apps/web/src/app/typeHardeningGuard.test.ts | 358 +++++++-------- apps/web/src/app/visitors/[id]/page.tsx | 62 +-- .../app/visitors/hooks/useVisitorsConvex.ts | 128 ++++++ apps/web/src/app/visitors/page.tsx | 56 +-- apps/web/src/components/AppSidebar.tsx | 26 +- .../src/components/AudienceRuleBuilder.tsx | 24 +- apps/web/src/components/SuggestionsPanel.tsx | 56 +-- apps/web/src/components/WorkspaceSelector.tsx | 12 +- .../components/hooks/useAppSidebarConvex.ts | 40 ++ .../hooks/useAudienceRuleBuilderConvex.ts | 37 ++ .../hooks/useSuggestionsPanelConvex.ts | 57 +++ .../hooks/useWorkspaceSelectorConvex.ts | 18 + apps/web/src/contexts/AuthContext.tsx | 40 +- apps/web/src/contexts/hooks/useAuthConvex.ts | 61 +++ .../tasks.md | 18 +- 57 files changed, 2487 insertions(+), 2263 deletions(-) create mode 100644 apps/web/src/app/campaigns/hooks/useEmailCampaignEditorConvex.ts create mode 100644 apps/web/src/app/help/hooks/useHelpCenterConvex.ts create mode 100644 apps/web/src/app/inbox/hooks/useInboxConversationListPaneConvex.ts create mode 100644 apps/web/src/app/onboarding/hooks/useOnboardingConvex.ts create mode 100644 apps/web/src/app/outbound/hooks/useOutboundMessagesPageConvex.ts create mode 100644 apps/web/src/app/reports/hooks/useReportsConvex.ts create mode 100644 apps/web/src/app/segments/hooks/useSegmentsPageConvex.ts create mode 100644 apps/web/src/app/settings/hooks/useSettingsSectionsConvex.ts create mode 100644 apps/web/src/app/snippets/hooks/useSnippetsPageConvex.ts create mode 100644 apps/web/src/app/surveys/hooks/useSurveysConvex.ts create mode 100644 apps/web/src/app/tickets/hooks/useTicketsConvex.ts create mode 100644 apps/web/src/app/tours/hooks/useToursConvex.ts create mode 100644 apps/web/src/app/visitors/hooks/useVisitorsConvex.ts create mode 100644 apps/web/src/components/hooks/useAppSidebarConvex.ts create mode 100644 apps/web/src/components/hooks/useAudienceRuleBuilderConvex.ts create mode 100644 apps/web/src/components/hooks/useSuggestionsPanelConvex.ts create mode 100644 apps/web/src/components/hooks/useWorkspaceSelectorConvex.ts create mode 100644 apps/web/src/contexts/hooks/useAuthConvex.ts diff --git a/apps/web/src/app/campaigns/email/[id]/page.tsx b/apps/web/src/app/campaigns/email/[id]/page.tsx index a9015ba..c306157 100644 --- a/apps/web/src/app/campaigns/email/[id]/page.tsx +++ b/apps/web/src/app/campaigns/email/[id]/page.tsx @@ -2,8 +2,6 @@ import { useState, useEffect } from "react"; import { useParams, useRouter } from "next/navigation"; -import { useQuery, useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { appConfirm } from "@/lib/appConfirm"; import { AppLayout } from "@/components/AppLayout"; import { Button, Input } from "@opencom/ui"; @@ -13,98 +11,15 @@ import type { Id } from "@opencom/convex/dataModel"; import { useAuth } from "@/contexts/AuthContext"; import { AudienceRuleBuilder, type AudienceRule } from "@/components/AudienceRuleBuilder"; import { sanitizeHtml } from "@/lib/sanitizeHtml"; - -type EmailCampaignRecord = { - _id: Id<"emailCampaigns">; - name: string; - subject: string; - previewText?: string; - content: string; - status: string; - audienceRules?: AudienceRule | null; - targeting?: AudienceRule | null; -}; - -type EmailCampaignStats = { - total: number; - pending: number; - sent: number; - delivered: number; - opened: number; - clicked: number; - bounced: number; - unsubscribed: number; - openRate: number; - clickRate: number; - bounceRate: number; -}; - -type UpdateCampaignArgs = { - id: Id<"emailCampaigns">; - name?: string; - subject?: string; - previewText?: string; - content?: string; - templateId?: Id<"emailTemplates">; - senderId?: Id<"users">; - targeting?: AudienceRule; - schedule?: { - type: "immediate" | "scheduled"; - scheduledAt?: number; - timezone?: string; - }; -}; - -type SendCampaignArgs = { - id: Id<"emailCampaigns">; -}; - -type SendCampaignResult = { - recipientCount: number; -}; - -const CAMPAIGN_QUERY = makeFunctionReference< - "query", - { id: Id<"emailCampaigns"> }, - EmailCampaignRecord | null ->("emailCampaigns:get"); - -const CAMPAIGN_STATS_QUERY = makeFunctionReference< - "query", - { id: Id<"emailCampaigns"> }, - EmailCampaignStats ->("emailCampaigns:getStats"); - -const EVENT_NAMES_QUERY = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - string[] ->("events:getDistinctNames"); - -const UPDATE_CAMPAIGN_REF = makeFunctionReference< - "mutation", - UpdateCampaignArgs, - Id<"emailCampaigns"> ->("emailCampaigns:update"); - -const SEND_CAMPAIGN_REF = makeFunctionReference<"mutation", SendCampaignArgs, SendCampaignResult>( - "emailCampaigns:send" -); +import { useEmailCampaignEditorConvex } from "../../hooks/useEmailCampaignEditorConvex"; function EmailCampaignEditor() { const params = useParams(); const router = useRouter(); const campaignId = params.id as Id<"emailCampaigns">; const { activeWorkspace } = useAuth(); - - const campaign = useQuery(CAMPAIGN_QUERY, { id: campaignId }); - const stats = useQuery(CAMPAIGN_STATS_QUERY, { id: campaignId }); - const eventNames = useQuery( - EVENT_NAMES_QUERY, - activeWorkspace?._id ? { workspaceId: activeWorkspace._id } : "skip" - ); - const updateCampaign = useMutation(UPDATE_CAMPAIGN_REF); - const sendCampaign = useMutation(SEND_CAMPAIGN_REF); + const { campaign, eventNames, sendCampaign, stats, updateCampaign } = + useEmailCampaignEditorConvex(campaignId, activeWorkspace?._id); const [name, setName] = useState(""); const [subject, setSubject] = useState(""); diff --git a/apps/web/src/app/campaigns/hooks/useEmailCampaignEditorConvex.ts b/apps/web/src/app/campaigns/hooks/useEmailCampaignEditorConvex.ts new file mode 100644 index 0000000..1328a72 --- /dev/null +++ b/apps/web/src/app/campaigns/hooks/useEmailCampaignEditorConvex.ts @@ -0,0 +1,85 @@ +"use client"; + +import type { Id } from "@opencom/convex/dataModel"; +import type { AudienceRule } from "@/components/AudienceRuleBuilder"; +import { + useWebMutation, + useWebQuery, + webMutationRef, + webQueryRef, +} from "@/lib/convex/hooks"; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type CampaignArgs = { + id: Id<"emailCampaigns">; +}; + +type UpdateCampaignArgs = { + id: Id<"emailCampaigns">; + name?: string; + subject?: string; + previewText?: string; + content?: string; + templateId?: Id<"emailTemplates">; + senderId?: Id<"users">; + targeting?: AudienceRule; + schedule?: { + type: "immediate" | "scheduled"; + scheduledAt?: number; + timezone?: string; + }; +}; + +const CAMPAIGN_QUERY_REF = webQueryRef< + CampaignArgs, + { + _id: Id<"emailCampaigns">; + name: string; + subject: string; + previewText?: string; + content: string; + status: string; + audienceRules?: AudienceRule | null; + targeting?: AudienceRule | null; + } | null +>("emailCampaigns:get"); +const CAMPAIGN_STATS_QUERY_REF = webQueryRef< + CampaignArgs, + { + total: number; + pending: number; + sent: number; + delivered: number; + opened: number; + clicked: number; + bounced: number; + unsubscribed: number; + openRate: number; + clickRate: number; + bounceRate: number; + } +>("emailCampaigns:getStats"); +const EVENT_NAMES_QUERY_REF = webQueryRef("events:getDistinctNames"); +const UPDATE_CAMPAIGN_REF = webMutationRef>( + "emailCampaigns:update" +); +const SEND_CAMPAIGN_REF = webMutationRef< + { id: Id<"emailCampaigns"> }, + { recipientCount: number } +>("emailCampaigns:send"); + +export function useEmailCampaignEditorConvex( + campaignId: Id<"emailCampaigns">, + workspaceId?: Id<"workspaces"> | null +) { + return { + campaign: useWebQuery(CAMPAIGN_QUERY_REF, { id: campaignId }), + eventNames: useWebQuery(EVENT_NAMES_QUERY_REF, workspaceId ? { workspaceId } : "skip"), + sendCampaign: useWebMutation(SEND_CAMPAIGN_REF), + stats: useWebQuery(CAMPAIGN_STATS_QUERY_REF, { id: campaignId }), + updateCampaign: useWebMutation(UPDATE_CAMPAIGN_REF), + }; +} diff --git a/apps/web/src/app/help/[slug]/page.tsx b/apps/web/src/app/help/[slug]/page.tsx index 32a1bd3..0192a30 100644 --- a/apps/web/src/app/help/[slug]/page.tsx +++ b/apps/web/src/app/help/[slug]/page.tsx @@ -1,59 +1,21 @@ "use client"; import { useParams } from "next/navigation"; -import { useQuery, useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; -import type { Id } from "@opencom/convex/dataModel"; import { useAuthOptional } from "@/contexts/AuthContext"; import { Button } from "@opencom/ui"; import { ArrowLeft, ThumbsUp, ThumbsDown, MessageCircle } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; import { parseMarkdown } from "@/lib/parseMarkdown"; - -const publicWorkspaceContextQuery = makeFunctionReference< - "query", - Record, - { _id?: Id<"workspaces">; helpCenterAccessPolicy?: string } | null ->("workspaces:getPublicWorkspaceContext"); - -const articleBySlugQuery = makeFunctionReference< - "query", - { slug: string; workspaceId: Id<"workspaces"> }, - { - _id: Id<"articles">; - slug: string; - title: string; - content: string; - renderedContent?: string; - status?: string; - visibility?: string; - collectionId?: Id<"collections">; - } | null ->("articles:get"); - -const collectionGetQuery = makeFunctionReference< - "query", - { id: Id<"collections"> }, - { _id: Id<"collections">; slug?: string; name: string } | null ->("collections:get"); - -const articleFeedbackStatsQuery = makeFunctionReference< - "query", - { articleId: Id<"articles"> }, - { helpful: number; total: number } | null ->("articles:getFeedbackStats"); - -const submitArticleFeedbackRef = makeFunctionReference< - "mutation", - { articleId: Id<"articles">; helpful: boolean }, - null ->("articles:submitFeedback"); +import { + useHelpArticlePageConvex, + useHelpWorkspaceContextConvex, +} from "../hooks/useHelpCenterConvex"; export default function ArticlePage() { const params = useParams(); const auth = useAuthOptional(); - const workspaceContext = useQuery(publicWorkspaceContextQuery, {}); + const { workspaceContext } = useHelpWorkspaceContextConvex(); const slug = params.slug as string; const [feedbackSubmitted, setFeedbackSubmitted] = useState(false); const [renderedContent, setRenderedContent] = useState(""); @@ -63,25 +25,12 @@ export default function ArticlePage() { const isRestricted = !isAuthenticated && workspaceContext?.helpCenterAccessPolicy === "restricted"; const shouldFetchArticle = Boolean(workspaceId && !isRestricted); - - const article = useQuery( - articleBySlugQuery, - shouldFetchArticle && workspaceId ? { slug, workspaceId } : "skip" + const { article, collection, feedbackStats, submitFeedback } = useHelpArticlePageConvex( + slug, + shouldFetchArticle && workspaceId ? workspaceId : undefined ); const publicArticleId = - article && article.visibility !== "internal" ? (article._id as Id<"articles">) : null; - - const collection = useQuery( - collectionGetQuery, - article?.collectionId ? { id: article.collectionId } : "skip" - ); - - const feedbackStats = useQuery( - articleFeedbackStatsQuery, - publicArticleId ? { articleId: publicArticleId } : "skip" - ); - - const submitFeedback = useMutation(submitArticleFeedbackRef); + article && article.visibility !== "internal" ? article._id : null; const handleFeedback = async (helpful: boolean) => { if (!publicArticleId || feedbackSubmitted) return; diff --git a/apps/web/src/app/help/hooks/useHelpCenterConvex.ts b/apps/web/src/app/help/hooks/useHelpCenterConvex.ts new file mode 100644 index 0000000..4d57698 --- /dev/null +++ b/apps/web/src/app/help/hooks/useHelpCenterConvex.ts @@ -0,0 +1,159 @@ +"use client"; + +import type { Id } from "@opencom/convex/dataModel"; +import { + useWebMutation, + useWebQuery, + webMutationRef, + webQueryRef, +} from "@/lib/convex/hooks"; + +type PublicWorkspaceContext = { + _id?: Id<"workspaces">; + helpCenterAccessPolicy?: string; +} | null; + +type CollectionSummary = { + _id: Id<"collections">; + name: string; + slug?: string; + icon?: string; + description?: string; + publishedArticleCount: number; +}; + +type ArticleSearchResult = { + _id: Id<"articles">; + slug: string; + title: string; + content: string; +}; + +type PublicArticleRecord = { + _id: Id<"articles">; + slug: string; + title: string; +}; + +type ArticleDetailRecord = { + _id: Id<"articles">; + slug: string; + title: string; + content: string; + renderedContent?: string; + status?: string; + visibility?: string; + collectionId?: Id<"collections">; +} | null; + +type CollectionRecord = { + _id: Id<"collections">; + slug?: string; + name: string; +} | null; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type ArticleBySlugArgs = WorkspaceArgs & { + slug: string; +}; + +type PublicCollectionsArgs = WorkspaceArgs & { + publicOnly: true; +}; + +type ArticleSearchArgs = WorkspaceArgs & { + query: string; + publishedOnly: true; + visibility: "public"; +}; + +type ArticlesListArgs = WorkspaceArgs & { + status: "published"; + visibility: "public"; +}; + +type CollectionArgs = { + id: Id<"collections">; +}; + +type ArticleFeedbackArgs = { + articleId: Id<"articles">; +}; + +type SubmitArticleFeedbackArgs = ArticleFeedbackArgs & { + helpful: boolean; +}; + +const PUBLIC_WORKSPACE_CONTEXT_QUERY_REF = webQueryRef, PublicWorkspaceContext>( + "workspaces:getPublicWorkspaceContext" +); +const COLLECTIONS_LIST_QUERY_REF = webQueryRef( + "collections:listHierarchy" +); +const ARTICLES_SEARCH_QUERY_REF = webQueryRef( + "articles:search" +); +const ARTICLES_LIST_QUERY_REF = webQueryRef( + "articles:list" +); +const ARTICLE_BY_SLUG_QUERY_REF = webQueryRef( + "articles:get" +); +const COLLECTION_GET_QUERY_REF = webQueryRef("collections:get"); +const ARTICLE_FEEDBACK_STATS_QUERY_REF = webQueryRef< + ArticleFeedbackArgs, + { helpful: number; total: number } | null +>("articles:getFeedbackStats"); +const SUBMIT_ARTICLE_FEEDBACK_REF = webMutationRef( + "articles:submitFeedback" +); + +export function useHelpWorkspaceContextConvex() { + return { + workspaceContext: useWebQuery(PUBLIC_WORKSPACE_CONTEXT_QUERY_REF, {}), + }; +} + +export function useHelpCenterPageConvex(workspaceId?: Id<"workspaces">, searchQuery?: string) { + return { + collections: useWebQuery( + COLLECTIONS_LIST_QUERY_REF, + workspaceId ? { workspaceId, publicOnly: true } : "skip" + ), + publishedArticles: useWebQuery( + ARTICLES_LIST_QUERY_REF, + workspaceId ? { workspaceId, status: "published", visibility: "public" } : "skip" + ), + searchResults: useWebQuery( + ARTICLES_SEARCH_QUERY_REF, + workspaceId && searchQuery && searchQuery.length >= 2 + ? { workspaceId, query: searchQuery, publishedOnly: true, visibility: "public" } + : "skip" + ), + }; +} + +export function useHelpArticlePageConvex(slug: string, workspaceId?: Id<"workspaces">) { + const article = useWebQuery( + ARTICLE_BY_SLUG_QUERY_REF, + workspaceId ? { slug, workspaceId } : "skip" + ); + const publicArticleId = + article && article.visibility !== "internal" ? (article._id as Id<"articles">) : null; + + return { + article, + collection: useWebQuery( + COLLECTION_GET_QUERY_REF, + article?.collectionId ? { id: article.collectionId } : "skip" + ), + feedbackStats: useWebQuery( + ARTICLE_FEEDBACK_STATS_QUERY_REF, + publicArticleId ? { articleId: publicArticleId } : "skip" + ), + submitFeedback: useWebMutation(SUBMIT_ARTICLE_FEEDBACK_REF), + }; +} diff --git a/apps/web/src/app/help/page.tsx b/apps/web/src/app/help/page.tsx index d5a6535..f3c730a 100644 --- a/apps/web/src/app/help/page.tsx +++ b/apps/web/src/app/help/page.tsx @@ -1,74 +1,26 @@ "use client"; import { useState } from "react"; -import { useQuery } from "convex/react"; -import { makeFunctionReference } from "convex/server"; -import type { Id } from "@opencom/convex/dataModel"; import { useAuthOptional } from "@/contexts/AuthContext"; import { Input } from "@opencom/ui"; import { Search, FileText, FolderOpen } from "lucide-react"; import Link from "next/link"; - -const publicWorkspaceContextQuery = makeFunctionReference< - "query", - Record, - { _id?: Id<"workspaces">; helpCenterAccessPolicy?: string } | null ->("workspaces:getPublicWorkspaceContext"); - -const collectionsListQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces">; publicOnly: true }, - Array<{ - _id: Id<"collections">; - name: string; - slug?: string; - icon?: string; - description?: string; - publishedArticleCount: number; - }> ->("collections:listHierarchy"); - -const articlesSearchQuery = makeFunctionReference< - "query", - { - workspaceId: Id<"workspaces">; - query: string; - publishedOnly: true; - visibility: "public"; - }, - Array<{ _id: Id<"articles">; slug: string; title: string; content: string }> ->("articles:search"); - -const articlesListQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces">; status: "published"; visibility: "public" }, - Array<{ _id: Id<"articles">; slug: string; title: string }> ->("articles:list"); +import { + useHelpCenterPageConvex, + useHelpWorkspaceContextConvex, +} from "./hooks/useHelpCenterConvex"; export default function HelpCenterPage() { const auth = useAuthOptional(); - const workspaceContext = useQuery(publicWorkspaceContextQuery, {}); const [searchQuery, setSearchQuery] = useState(""); + const { workspaceContext } = useHelpWorkspaceContextConvex(); const isAuthenticated = auth?.isAuthenticated ?? false; const workspaceId = auth?.activeWorkspace?._id ?? workspaceContext?._id; const isRestricted = !isAuthenticated && workspaceContext?.helpCenterAccessPolicy === "restricted"; - - const collections = useQuery( - collectionsListQuery, - workspaceId ? { workspaceId, publicOnly: true } : "skip" - ); - - const searchResults = useQuery( - articlesSearchQuery, - workspaceId && searchQuery.length >= 2 - ? { workspaceId, query: searchQuery, publishedOnly: true, visibility: "public" } - : "skip" - ); - - const publishedArticles = useQuery( - articlesListQuery, - workspaceId ? { workspaceId, status: "published", visibility: "public" } : "skip" + const { collections, publishedArticles, searchResults } = useHelpCenterPageConvex( + workspaceId, + searchQuery ); const collectionsWithArticles = collections?.filter( diff --git a/apps/web/src/app/inbox/InboxConversationListPane.tsx b/apps/web/src/app/inbox/InboxConversationListPane.tsx index c091333..89a2911 100644 --- a/apps/web/src/app/inbox/InboxConversationListPane.tsx +++ b/apps/web/src/app/inbox/InboxConversationListPane.tsx @@ -1,21 +1,14 @@ "use client"; -import { useQuery } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { Card } from "@opencom/ui"; import { Bot, Circle, Mail, MessageSquare, ShieldAlert } from "lucide-react"; import type { Id } from "@opencom/convex/dataModel"; import { ResponsiveSecondaryRegion } from "@/components/ResponsiveLayout"; +import { useInboxConversationListPaneConvex } from "./hooks/useInboxConversationListPaneConvex"; import { type InboxAiWorkflowFilter, type InboxConversation } from "./inboxRenderTypes"; -const VISITOR_ONLINE_QUERY = makeFunctionReference< - "query", - { visitorId: Id<"visitors"> }, - boolean ->("visitors:isOnline"); - function PresenceIndicator({ visitorId }: { visitorId: Id<"visitors"> }): React.JSX.Element { - const isOnline = useQuery(VISITOR_ONLINE_QUERY, { visitorId }); + const { isOnline } = useInboxConversationListPaneConvex(visitorId); return ( ; +}; + +const VISITOR_ONLINE_QUERY_REF = webQueryRef("visitors:isOnline"); + +export function useInboxConversationListPaneConvex(visitorId: Id<"visitors">) { + return { + isOnline: useWebQuery(VISITOR_ONLINE_QUERY_REF, { visitorId }), + }; +} diff --git a/apps/web/src/app/onboarding/hooks/useOnboardingConvex.ts b/apps/web/src/app/onboarding/hooks/useOnboardingConvex.ts new file mode 100644 index 0000000..871f555 --- /dev/null +++ b/apps/web/src/app/onboarding/hooks/useOnboardingConvex.ts @@ -0,0 +1,73 @@ +"use client"; + +import type { Id } from "@opencom/convex/dataModel"; +import { + useWebMutation, + useWebQuery, + webMutationRef, + webQueryRef, +} from "@/lib/convex/hooks"; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type CompleteWidgetStepArgs = { + workspaceId: Id<"workspaces">; + token?: string; +}; + +export type HostedOnboardingState = { + status?: string; + verificationToken?: string | null; + isWidgetVerified?: boolean; +} | null; + +export type HostedOnboardingIntegrationSignals = { + integrations?: Array<{ + id: string; + clientType: string; + clientVersion?: string; + detectedAt?: number | null; + isActiveNow?: boolean; + matchesCurrentVerificationWindow?: boolean; + origin?: string; + currentUrl?: string; + clientIdentifier?: string; + lastSeenAt?: number | null; + activeSessionCount?: number; + }>; +} | null; + +const HOSTED_ONBOARDING_STATE_QUERY_REF = webQueryRef( + "workspaces:getHostedOnboardingState" +); +const ONBOARDING_INTEGRATION_SIGNALS_QUERY_REF = webQueryRef< + WorkspaceArgs, + HostedOnboardingIntegrationSignals +>("workspaces:getHostedOnboardingIntegrationSignals"); +const START_HOSTED_ONBOARDING_REF = webMutationRef( + "workspaces:startHostedOnboarding" +); +const ISSUE_VERIFICATION_TOKEN_REF = webMutationRef( + "workspaces:issueHostedOnboardingVerificationToken" +); +const COMPLETE_WIDGET_STEP_REF = webMutationRef( + "workspaces:completeHostedOnboardingWidgetStep" +); + +export function useOnboardingConvex(workspaceId?: Id<"workspaces"> | null) { + return { + completeWidgetStep: useWebMutation(COMPLETE_WIDGET_STEP_REF), + integrationSignals: useWebQuery( + ONBOARDING_INTEGRATION_SIGNALS_QUERY_REF, + workspaceId ? { workspaceId } : "skip" + ), + issueVerificationToken: useWebMutation(ISSUE_VERIFICATION_TOKEN_REF), + onboardingState: useWebQuery( + HOSTED_ONBOARDING_STATE_QUERY_REF, + workspaceId ? { workspaceId } : "skip" + ), + startHostedOnboarding: useWebMutation(START_HOSTED_ONBOARDING_REF), + }; +} diff --git a/apps/web/src/app/onboarding/page.tsx b/apps/web/src/app/onboarding/page.tsx index 3e40031..fa9a435 100644 --- a/apps/web/src/app/onboarding/page.tsx +++ b/apps/web/src/app/onboarding/page.tsx @@ -2,71 +2,17 @@ import Link from "next/link"; import { useEffect, useRef, useState } from "react"; -import { useMutation, useQuery } from "convex/react"; -import { makeFunctionReference } from "convex/server"; -import type { Id } from "@opencom/convex/dataModel"; import { Button, Card } from "@opencom/ui"; import { AppLayout } from "@/components/AppLayout"; import { WidgetInstallGuide } from "@/components/WidgetInstallGuide"; import { useAuth } from "@/contexts/AuthContext"; import { useBackend } from "@/contexts/BackendContext"; +import { useOnboardingConvex } from "./hooks/useOnboardingConvex"; type VerificationStatus = "idle" | "checking" | "success" | "error"; const VERIFY_TIMEOUT_MS = 15000; -type HostedOnboardingState = { - status?: string; - verificationToken?: string | null; - isWidgetVerified?: boolean; -} | null; - -type HostedOnboardingIntegrationSignals = { - integrations?: Array<{ - id: string; - clientType: string; - clientVersion?: string; - detectedAt?: number | null; - isActiveNow?: boolean; - matchesCurrentVerificationWindow?: boolean; - origin?: string; - currentUrl?: string; - clientIdentifier?: string; - lastSeenAt?: number | null; - activeSessionCount?: number; - }>; -} | null; - -const HOSTED_ONBOARDING_STATE_QUERY = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - HostedOnboardingState ->("workspaces:getHostedOnboardingState"); - -const ONBOARDING_INTEGRATION_SIGNALS_QUERY = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - HostedOnboardingIntegrationSignals ->("workspaces:getHostedOnboardingIntegrationSignals"); - -const START_HOSTED_ONBOARDING_REF = makeFunctionReference< - "mutation", - { workspaceId: Id<"workspaces"> }, - unknown ->("workspaces:startHostedOnboarding"); - -const ISSUE_VERIFICATION_TOKEN_REF = makeFunctionReference< - "mutation", - { workspaceId: Id<"workspaces"> }, - { token: string } ->("workspaces:issueHostedOnboardingVerificationToken"); - -const COMPLETE_WIDGET_STEP_REF = makeFunctionReference< - "mutation", - { workspaceId: Id<"workspaces">; token?: string }, - { success: boolean } ->("workspaces:completeHostedOnboardingWidgetStep"); - function formatTimestamp(value: number | null | undefined): string { if (typeof value !== "number" || !Number.isFinite(value)) { return "unknown"; @@ -98,20 +44,13 @@ function OnboardingContent(): React.JSX.Element { const [tokenModeEnabled, setTokenModeEnabled] = useState(false); const startRequestedRef = useRef(false); const verifyTimeoutRef = useRef | null>(null); - - const onboardingState = useQuery( - HOSTED_ONBOARDING_STATE_QUERY, - activeWorkspace?._id ? { workspaceId: activeWorkspace._id } : "skip" - ); - - const integrationSignals = useQuery( - ONBOARDING_INTEGRATION_SIGNALS_QUERY, - activeWorkspace?._id ? { workspaceId: activeWorkspace._id } : "skip" - ); - - const startHostedOnboarding = useMutation(START_HOSTED_ONBOARDING_REF); - const issueVerificationToken = useMutation(ISSUE_VERIFICATION_TOKEN_REF); - const completeWidgetStep = useMutation(COMPLETE_WIDGET_STEP_REF); + const { + completeWidgetStep, + integrationSignals, + issueVerificationToken, + onboardingState, + startHostedOnboarding, + } = useOnboardingConvex(activeWorkspace?._id); useEffect(() => { if (!onboardingState?.verificationToken) { diff --git a/apps/web/src/app/outbound/hooks/useOutboundMessagesPageConvex.ts b/apps/web/src/app/outbound/hooks/useOutboundMessagesPageConvex.ts new file mode 100644 index 0000000..4b3b8d4 --- /dev/null +++ b/apps/web/src/app/outbound/hooks/useOutboundMessagesPageConvex.ts @@ -0,0 +1,75 @@ +"use client"; + +import type { Id } from "@opencom/convex/dataModel"; +import type { OutboundMessageStatus, OutboundMessageType } from "@opencom/types"; +import { + useWebMutation, + useWebQuery, + webMutationRef, + webQueryRef, +} from "@/lib/convex/hooks"; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type MessageArgs = { + id: Id<"outboundMessages">; +}; + +const OUTBOUND_MESSAGES_LIST_QUERY_REF = webQueryRef< + WorkspaceArgs & { + type?: OutboundMessageType; + status?: OutboundMessageStatus; + }, + Array<{ + _id: Id<"outboundMessages">; + name: string; + type: OutboundMessageType; + status: OutboundMessageStatus; + createdAt: number; + content: { text?: string; title?: string; body?: string }; + }> +>("outboundMessages:list"); +const CREATE_OUTBOUND_MESSAGE_REF = webMutationRef< + WorkspaceArgs & { + type: OutboundMessageType; + name: string; + content: + | { text: string } + | { + title: string; + body: string; + buttons: Array<{ text: string; action: "open_new_conversation" | "dismiss" }>; + } + | { text: string; style: string; dismissible: boolean }; + targeting?: unknown; + triggers?: unknown; + frequency?: unknown; + scheduling?: unknown; + priority?: number; + }, + Id<"outboundMessages"> +>("outboundMessages:create"); +const DELETE_OUTBOUND_MESSAGE_REF = webMutationRef("outboundMessages:remove"); +const ACTIVATE_OUTBOUND_MESSAGE_REF = webMutationRef( + "outboundMessages:activate" +); +const PAUSE_OUTBOUND_MESSAGE_REF = webMutationRef("outboundMessages:pause"); + +export function useOutboundMessagesPageConvex( + workspaceId?: Id<"workspaces"> | null, + type?: OutboundMessageType, + status?: OutboundMessageStatus +) { + return { + activateMessage: useWebMutation(ACTIVATE_OUTBOUND_MESSAGE_REF), + createMessage: useWebMutation(CREATE_OUTBOUND_MESSAGE_REF), + deleteMessage: useWebMutation(DELETE_OUTBOUND_MESSAGE_REF), + messages: useWebQuery( + OUTBOUND_MESSAGES_LIST_QUERY_REF, + workspaceId ? { workspaceId, type, status } : "skip" + ), + pauseMessage: useWebMutation(PAUSE_OUTBOUND_MESSAGE_REF), + }; +} diff --git a/apps/web/src/app/outbound/page.tsx b/apps/web/src/app/outbound/page.tsx index e77a64b..fbd4c94 100644 --- a/apps/web/src/app/outbound/page.tsx +++ b/apps/web/src/app/outbound/page.tsx @@ -1,9 +1,7 @@ "use client"; import { useState } from "react"; -import { useQuery, useMutation } from "convex/react"; import { useRouter } from "next/navigation"; -import { makeFunctionReference } from "convex/server"; import { appConfirm } from "@/lib/appConfirm"; import { useAuth } from "@/contexts/AuthContext"; import { AppLayout } from "@/components/AppLayout"; @@ -12,6 +10,7 @@ import { Pencil, Trash2, Play, Pause, Search, MessageSquare, Bell, Flag } from " import Link from "next/link"; import type { Id } from "@opencom/convex/dataModel"; import type { OutboundMessageStatus, OutboundMessageType } from "@opencom/types"; +import { useOutboundMessagesPageConvex } from "./hooks/useOutboundMessagesPageConvex"; import { OUTBOUND_MESSAGE_STATUS_OPTIONS, OUTBOUND_MESSAGE_TYPE_OPTIONS, @@ -19,83 +18,18 @@ import { getOutboundMessageStatusBadgeClass, } from "./outboundMessageUi"; -const OUTBOUND_MESSAGES_LIST_QUERY = makeFunctionReference< - "query", - { - workspaceId: Id<"workspaces">; - type?: OutboundMessageType; - status?: OutboundMessageStatus; - }, - Array<{ - _id: Id<"outboundMessages">; - name: string; - type: OutboundMessageType; - status: OutboundMessageStatus; - createdAt: number; - content: { text?: string; title?: string; body?: string }; - }> ->("outboundMessages:list"); - -const CREATE_OUTBOUND_MESSAGE_REF = makeFunctionReference< - "mutation", - { - workspaceId: Id<"workspaces">; - type: OutboundMessageType; - name: string; - content: - | { text: string } - | { - title: string; - body: string; - buttons: Array<{ text: string; action: "open_new_conversation" | "dismiss" }>; - } - | { text: string; style: string; dismissible: boolean }; - targeting?: unknown; - triggers?: unknown; - frequency?: unknown; - scheduling?: unknown; - priority?: number; - }, - Id<"outboundMessages"> ->("outboundMessages:create"); - -const DELETE_OUTBOUND_MESSAGE_REF = makeFunctionReference< - "mutation", - { id: Id<"outboundMessages"> }, - null ->("outboundMessages:remove"); - -const ACTIVATE_OUTBOUND_MESSAGE_REF = makeFunctionReference< - "mutation", - { id: Id<"outboundMessages"> }, - null ->("outboundMessages:activate"); - -const PAUSE_OUTBOUND_MESSAGE_REF = makeFunctionReference< - "mutation", - { id: Id<"outboundMessages"> }, - null ->("outboundMessages:pause"); - function OutboundContent() { const router = useRouter(); const { activeWorkspace } = useAuth(); const [searchQuery, setSearchQuery] = useState(""); const [typeFilter, setTypeFilter] = useState<"all" | OutboundMessageType>("all"); const [statusFilter, setStatusFilter] = useState<"all" | OutboundMessageStatus>("all"); - - const messages = useQuery(OUTBOUND_MESSAGES_LIST_QUERY, activeWorkspace?._id - ? { - workspaceId: activeWorkspace._id, - type: typeFilter === "all" ? undefined : typeFilter, - status: statusFilter === "all" ? undefined : statusFilter, - } - : "skip"); - - const createMessage = useMutation(CREATE_OUTBOUND_MESSAGE_REF); - const deleteMessage = useMutation(DELETE_OUTBOUND_MESSAGE_REF); - const activateMessage = useMutation(ACTIVATE_OUTBOUND_MESSAGE_REF); - const pauseMessage = useMutation(PAUSE_OUTBOUND_MESSAGE_REF); + const { activateMessage, createMessage, deleteMessage, messages, pauseMessage } = + useOutboundMessagesPageConvex( + activeWorkspace?._id, + typeFilter === "all" ? undefined : typeFilter, + statusFilter === "all" ? undefined : statusFilter + ); const handleCreate = async (type: OutboundMessageType) => { if (!activeWorkspace?._id) return; diff --git a/apps/web/src/app/reports/ai/page.tsx b/apps/web/src/app/reports/ai/page.tsx index 1295a9a..944073f 100644 --- a/apps/web/src/app/reports/ai/page.tsx +++ b/apps/web/src/app/reports/ai/page.tsx @@ -1,70 +1,12 @@ "use client"; import { useState, useMemo } from "react"; -import { useQuery } from "convex/react"; -import { makeFunctionReference } from "convex/server"; -import type { Id } from "@opencom/convex/dataModel"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button } from "@opencom/ui"; import { Bot, Download, ArrowLeft, TrendingUp, Clock, AlertTriangle, Zap } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; import { AppLayout } from "@/components/AppLayout"; import Link from "next/link"; - -const aiAgentMetricsQuery = makeFunctionReference< - "query", - { - workspaceId: Id<"workspaces">; - startDate: number; - endDate: number; - granularity: "day" | "week" | "month"; - }, - { - totalResponses: number; - resolvedByAI: number; - handedOff: number; - resolutionRate: number; - avgResponseTimeMs: number; - satisfactionRate: number; - totalTokensUsed?: number; - avgConfidence?: number; - handoffRate: number; - trendByPeriod: Array<{ - period: string; - totalResponses: number; - resolutionRate: number; - satisfactionRate: number; - }>; - } | null ->("reporting:getAiAgentMetrics"); - -const aiVsHumanComparisonQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces">; startDate: number; endDate: number }, - { - ai: { - conversationCount: number; - avgResponseTimeMs: number; - csatResponseCount: number; - avgCsatRating: number; - }; - human: { - conversationCount: number; - avgResponseTimeMs: number; - csatResponseCount: number; - avgCsatRating: number; - }; - } | null ->("reporting:getAiVsHumanComparison"); - -const knowledgeGapsQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces">; startDate: number; endDate: number; limit: number }, - Array<{ - query: string; - count: number; - confidence: number; - }> ->("reporting:getKnowledgeGaps"); +import { useAiReportConvex } from "../hooks/useReportsConvex"; function formatDuration(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms`; @@ -83,24 +25,11 @@ function AiReportContent() { const s = n - (dateRange === "7d" ? 7 : dateRange === "30d" ? 30 : 90) * 24 * 60 * 60 * 1000; return { now: n, startDate: s }; }, [dateRange]); - - const aiMetrics = useQuery( - aiAgentMetricsQuery, - activeWorkspace?._id - ? { workspaceId: activeWorkspace._id, startDate, endDate: now, granularity } - : "skip" - ); - - const comparison = useQuery( - aiVsHumanComparisonQuery, - activeWorkspace?._id ? { workspaceId: activeWorkspace._id, startDate, endDate: now } : "skip" - ); - - const knowledgeGaps = useQuery( - knowledgeGapsQuery, - activeWorkspace?._id - ? { workspaceId: activeWorkspace._id, startDate, endDate: now, limit: 20 } - : "skip" + const { aiMetrics, comparison, knowledgeGaps } = useAiReportConvex( + activeWorkspace?._id, + startDate, + now, + granularity ); const handleExportCSV = () => { diff --git a/apps/web/src/app/reports/conversations/page.tsx b/apps/web/src/app/reports/conversations/page.tsx index bf3f1b9..c7a331e 100644 --- a/apps/web/src/app/reports/conversations/page.tsx +++ b/apps/web/src/app/reports/conversations/page.tsx @@ -1,42 +1,12 @@ "use client"; import { useMemo, useState } from "react"; -import { useQuery } from "convex/react"; -import { makeFunctionReference } from "convex/server"; -import type { Id } from "@opencom/convex/dataModel"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button } from "@opencom/ui"; import { MessageSquare, Clock, CheckCircle, Download, ArrowLeft } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; import { AppLayout } from "@/components/AppLayout"; import Link from "next/link"; - -const conversationMetricsQuery = makeFunctionReference< - "query", - { - workspaceId: Id<"workspaces">; - startDate: number; - endDate: number; - granularity: "day" | "week" | "month"; - }, - { - total: number; - volumeByPeriod: Array<{ period: string; count: number }>; - byStatus: { open: number; closed: number; snoozed: number }; - byChannel: { chat: number; email: number }; - } | null ->("reporting:getConversationMetrics"); - -const responseTimeMetricsQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces">; startDate: number; endDate: number }, - { averageMs: number; medianMs: number; p95Ms: number; p90Ms: number } | null ->("reporting:getResponseTimeMetrics"); - -const resolutionTimeMetricsQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces">; startDate: number; endDate: number }, - { averageMs: number; medianMs: number } | null ->("reporting:getResolutionTimeMetrics"); +import { useConversationsReportConvex } from "../hooks/useReportsConvex"; function formatDuration(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms`; @@ -56,40 +26,13 @@ function ConversationsReportContent() { const startDate = endDate - days * 24 * 60 * 60 * 1000; return { startDate, endDate }; }, [dateRange]); - - const conversationMetrics = useQuery( - conversationMetricsQuery, - activeWorkspace?._id - ? { - workspaceId: activeWorkspace._id, - startDate: dateWindow.startDate, - endDate: dateWindow.endDate, - granularity, - } - : "skip" - ); - - const responseTimeMetrics = useQuery( - responseTimeMetricsQuery, - activeWorkspace?._id - ? { - workspaceId: activeWorkspace._id, - startDate: dateWindow.startDate, - endDate: dateWindow.endDate, - } - : "skip" - ); - - const resolutionTimeMetrics = useQuery( - resolutionTimeMetricsQuery, - activeWorkspace?._id - ? { - workspaceId: activeWorkspace._id, - startDate: dateWindow.startDate, - endDate: dateWindow.endDate, - } - : "skip" - ); + const { conversationMetrics, resolutionTimeMetrics, responseTimeMetrics } = + useConversationsReportConvex( + activeWorkspace?._id, + dateWindow.startDate, + dateWindow.endDate, + granularity + ); const handleExportCSV = () => { if (!conversationMetrics) return; diff --git a/apps/web/src/app/reports/csat/page.tsx b/apps/web/src/app/reports/csat/page.tsx index 352e6be..3209eb1 100644 --- a/apps/web/src/app/reports/csat/page.tsx +++ b/apps/web/src/app/reports/csat/page.tsx @@ -1,42 +1,12 @@ "use client"; import { useMemo, useState } from "react"; -import { useQuery } from "convex/react"; -import { makeFunctionReference } from "convex/server"; -import type { Id } from "@opencom/convex/dataModel"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button } from "@opencom/ui"; import { Star, Download, ArrowLeft, TrendingUp, TrendingDown } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; import { AppLayout } from "@/components/AppLayout"; import Link from "next/link"; - -const csatMetricsQuery = makeFunctionReference< - "query", - { - workspaceId: Id<"workspaces">; - startDate: number; - endDate: number; - granularity: "day" | "week" | "month"; - }, - { - averageRating: number; - totalResponses: number; - satisfactionRate: number; - ratingDistribution: Record; - trendByPeriod: Array<{ period: string; averageRating: number; count: number }>; - } | null ->("reporting:getCsatMetrics"); - -const csatByAgentQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces">; startDate: number; endDate: number }, - Array<{ - agentId: string; - agentName: string; - averageRating: number; - totalResponses: number; - }> ->("reporting:getCsatByAgent"); +import { useCsatReportConvex } from "../hooks/useReportsConvex"; function CsatReportContent() { const { activeWorkspace } = useAuth(); @@ -46,17 +16,11 @@ function CsatReportContent() { const now = useMemo(() => Date.now(), []); const startDate = now - (dateRange === "7d" ? 7 : dateRange === "30d" ? 30 : 90) * 24 * 60 * 60 * 1000; - - const csatMetrics = useQuery( - csatMetricsQuery, - activeWorkspace?._id - ? { workspaceId: activeWorkspace._id, startDate, endDate: now, granularity } - : "skip" - ); - - const csatByAgent = useQuery( - csatByAgentQuery, - activeWorkspace?._id ? { workspaceId: activeWorkspace._id, startDate, endDate: now } : "skip" + const { csatByAgent, csatMetrics } = useCsatReportConvex( + activeWorkspace?._id, + startDate, + now, + granularity ); const handleExportCSV = () => { diff --git a/apps/web/src/app/reports/hooks/useReportsConvex.ts b/apps/web/src/app/reports/hooks/useReportsConvex.ts new file mode 100644 index 0000000..6acc8c1 --- /dev/null +++ b/apps/web/src/app/reports/hooks/useReportsConvex.ts @@ -0,0 +1,226 @@ +"use client"; + +import type { Id } from "@opencom/convex/dataModel"; +import { useWebQuery, webQueryRef } from "@/lib/convex/hooks"; + +type DateRangeArgs = { + workspaceId: Id<"workspaces">; + startDate: number; + endDate: number; +}; + +type GranularDateRangeArgs = DateRangeArgs & { + granularity: "day" | "week" | "month"; +}; + +const DASHBOARD_SUMMARY_QUERY_REF = webQueryRef< + DateRangeArgs, + { + totalConversations: number; + openConversations: number; + closedConversations: number; + avgResponseTimeMs: number; + avgResolutionTimeMs: number; + } | null +>("reporting:getDashboardSummary"); +const CSAT_METRICS_QUERY_REF = webQueryRef< + GranularDateRangeArgs, + { + averageRating: number; + totalResponses: number; + satisfactionRate: number; + ratingDistribution: Record; + trendByPeriod: Array<{ period: string; averageRating: number; count: number }>; + } | null +>("reporting:getCsatMetrics"); +const AI_AGENT_METRICS_QUERY_REF = webQueryRef< + GranularDateRangeArgs, + { + totalResponses: number; + resolvedByAI: number; + handedOff: number; + resolutionRate: number; + avgResponseTimeMs: number; + satisfactionRate: number; + totalTokensUsed?: number; + avgConfidence?: number; + handoffRate: number; + trendByPeriod: Array<{ + period: string; + totalResponses: number; + resolutionRate: number; + satisfactionRate: number; + }>; + } | null +>("reporting:getAiAgentMetrics"); +const CONVERSATION_METRICS_QUERY_REF = webQueryRef< + GranularDateRangeArgs, + { + total: number; + volumeByPeriod: Array<{ period: string; count: number }>; + byStatus: { open: number; closed: number; snoozed: number }; + byChannel: { chat: number; email: number }; + } | null +>("reporting:getConversationMetrics"); +const RESPONSE_TIME_METRICS_QUERY_REF = webQueryRef< + DateRangeArgs, + { averageMs: number; medianMs: number; p95Ms: number; p90Ms: number } | null +>("reporting:getResponseTimeMetrics"); +const RESOLUTION_TIME_METRICS_QUERY_REF = webQueryRef< + DateRangeArgs, + { averageMs: number; medianMs: number } | null +>("reporting:getResolutionTimeMetrics"); +const AI_VS_HUMAN_COMPARISON_QUERY_REF = webQueryRef< + DateRangeArgs, + { + ai: { + conversationCount: number; + avgResponseTimeMs: number; + csatResponseCount: number; + avgCsatRating: number; + }; + human: { + conversationCount: number; + avgResponseTimeMs: number; + csatResponseCount: number; + avgCsatRating: number; + }; + } | null +>("reporting:getAiVsHumanComparison"); +const KNOWLEDGE_GAPS_QUERY_REF = webQueryRef< + DateRangeArgs & { limit: number }, + Array<{ + query: string; + count: number; + confidence: number; + }> +>("reporting:getKnowledgeGaps"); +const AGENT_METRICS_QUERY_REF = webQueryRef< + DateRangeArgs, + Array<{ + agentId: string; + agentName: string; + conversationsHandled: number; + resolved: number; + avgResponseTimeMs: number; + avgResolutionTimeMs: number; + }> +>("reporting:getAgentMetrics"); +const AGENT_WORKLOAD_DISTRIBUTION_QUERY_REF = webQueryRef< + { workspaceId: Id<"workspaces"> }, + { + total: number; + unassigned: number; + distribution: Array<{ agentId: string; agentName: string; openConversations: number }>; + } | null +>("reporting:getAgentWorkloadDistribution"); +const CSAT_BY_AGENT_QUERY_REF = webQueryRef< + DateRangeArgs, + Array<{ + agentId: string; + agentName: string; + averageRating: number; + totalResponses: number; + }> +>("reporting:getCsatByAgent"); + +export function useReportsPageConvex( + workspaceId?: Id<"workspaces">, + startDate?: number, + endDate?: number +) { + const args = workspaceId && startDate !== undefined && endDate !== undefined + ? { workspaceId, startDate, endDate } + : null; + + return { + aiMetrics: useWebQuery(AI_AGENT_METRICS_QUERY_REF, args ? { ...args, granularity: "day" } : "skip"), + csatMetrics: useWebQuery(CSAT_METRICS_QUERY_REF, args ? { ...args, granularity: "day" } : "skip"), + summary: useWebQuery(DASHBOARD_SUMMARY_QUERY_REF, args ?? "skip"), + }; +} + +export function useAiReportConvex( + workspaceId?: Id<"workspaces">, + startDate?: number, + endDate?: number, + granularity: "day" | "week" | "month" = "day" +) { + const rangeArgs = + workspaceId && startDate !== undefined && endDate !== undefined + ? { workspaceId, startDate, endDate } + : null; + + return { + aiMetrics: useWebQuery( + AI_AGENT_METRICS_QUERY_REF, + rangeArgs ? { ...rangeArgs, granularity } : "skip" + ), + comparison: useWebQuery(AI_VS_HUMAN_COMPARISON_QUERY_REF, rangeArgs ?? "skip"), + knowledgeGaps: useWebQuery( + KNOWLEDGE_GAPS_QUERY_REF, + rangeArgs ? { ...rangeArgs, limit: 20 } : "skip" + ), + }; +} + +export function useConversationsReportConvex( + workspaceId?: Id<"workspaces">, + startDate?: number, + endDate?: number, + granularity: "day" | "week" | "month" = "day" +) { + const rangeArgs = + workspaceId && startDate !== undefined && endDate !== undefined + ? { workspaceId, startDate, endDate } + : null; + + return { + conversationMetrics: useWebQuery( + CONVERSATION_METRICS_QUERY_REF, + rangeArgs ? { ...rangeArgs, granularity } : "skip" + ), + resolutionTimeMetrics: useWebQuery(RESOLUTION_TIME_METRICS_QUERY_REF, rangeArgs ?? "skip"), + responseTimeMetrics: useWebQuery(RESPONSE_TIME_METRICS_QUERY_REF, rangeArgs ?? "skip"), + }; +} + +export function useTeamReportConvex( + workspaceId?: Id<"workspaces">, + startDate?: number, + endDate?: number +) { + const rangeArgs = + workspaceId && startDate !== undefined && endDate !== undefined + ? { workspaceId, startDate, endDate } + : null; + + return { + agentMetrics: useWebQuery(AGENT_METRICS_QUERY_REF, rangeArgs ?? "skip"), + csatByAgent: useWebQuery(CSAT_BY_AGENT_QUERY_REF, rangeArgs ?? "skip"), + workloadDistribution: useWebQuery( + AGENT_WORKLOAD_DISTRIBUTION_QUERY_REF, + workspaceId ? { workspaceId } : "skip" + ), + }; +} + +export function useCsatReportConvex( + workspaceId?: Id<"workspaces">, + startDate?: number, + endDate?: number, + granularity: "day" | "week" | "month" = "day" +) { + const rangeArgs = + workspaceId && startDate !== undefined && endDate !== undefined + ? { workspaceId, startDate, endDate } + : null; + + return { + csatByAgent: useWebQuery(CSAT_BY_AGENT_QUERY_REF, rangeArgs ?? "skip"), + csatMetrics: useWebQuery( + CSAT_METRICS_QUERY_REF, + rangeArgs ? { ...rangeArgs, granularity } : "skip" + ), + }; +} diff --git a/apps/web/src/app/reports/page.tsx b/apps/web/src/app/reports/page.tsx index 9b8c0b3..ffb549e 100644 --- a/apps/web/src/app/reports/page.tsx +++ b/apps/web/src/app/reports/page.tsx @@ -1,9 +1,6 @@ "use client"; import { useState, useMemo } from "react"; -import { useQuery } from "convex/react"; -import { makeFunctionReference } from "convex/server"; -import type { Id } from "@opencom/convex/dataModel"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@opencom/ui"; import { MessageSquare, @@ -18,35 +15,7 @@ import { import { useAuth } from "@/contexts/AuthContext"; import { AppLayout, AppPageShell } from "@/components/AppLayout"; import Link from "next/link"; - -const dashboardSummaryQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces">; startDate: number; endDate: number }, - { - totalConversations: number; - openConversations: number; - closedConversations: number; - avgResponseTimeMs: number; - avgResolutionTimeMs: number; - } | null ->("reporting:getDashboardSummary"); - -const csatMetricsQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces">; startDate: number; endDate: number }, - { averageRating: number; totalResponses: number } | null ->("reporting:getCsatMetrics"); - -const aiAgentMetricsQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces">; startDate: number; endDate: number }, - { - totalResponses: number; - resolutionRate: number; - handoffRate: number; - satisfactionRate: number; - } | null ->("reporting:getAiAgentMetrics"); +import { useReportsPageConvex } from "./hooks/useReportsConvex"; function formatDuration(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms`; @@ -111,20 +80,10 @@ function ReportsContent() { const now = useMemo(() => Date.now(), []); const startDate = now - (dateRange === "7d" ? 7 : dateRange === "30d" ? 30 : 90) * 24 * 60 * 60 * 1000; - - const summary = useQuery( - dashboardSummaryQuery, - activeWorkspace?._id ? { workspaceId: activeWorkspace._id, startDate, endDate: now } : "skip" - ); - - const csatMetrics = useQuery( - csatMetricsQuery, - activeWorkspace?._id ? { workspaceId: activeWorkspace._id, startDate, endDate: now } : "skip" - ); - - const aiMetrics = useQuery( - aiAgentMetricsQuery, - activeWorkspace?._id ? { workspaceId: activeWorkspace._id, startDate, endDate: now } : "skip" + const { aiMetrics, csatMetrics, summary } = useReportsPageConvex( + activeWorkspace?._id, + startDate, + now ); if (!activeWorkspace) { diff --git a/apps/web/src/app/reports/team/page.tsx b/apps/web/src/app/reports/team/page.tsx index cb1006e..bbfbcf6 100644 --- a/apps/web/src/app/reports/team/page.tsx +++ b/apps/web/src/app/reports/team/page.tsx @@ -1,48 +1,12 @@ "use client"; import { useState } from "react"; -import { useQuery } from "convex/react"; -import { makeFunctionReference } from "convex/server"; -import type { Id } from "@opencom/convex/dataModel"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, Button } from "@opencom/ui"; import { Users, Clock, CheckCircle, Download, ArrowLeft } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; import { AppLayout } from "@/components/AppLayout"; import Link from "next/link"; - -const agentMetricsQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces">; startDate: number; endDate: number }, - Array<{ - agentId: string; - agentName: string; - conversationsHandled: number; - resolved: number; - avgResponseTimeMs: number; - avgResolutionTimeMs: number; - }> ->("reporting:getAgentMetrics"); - -const agentWorkloadDistributionQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - { - total: number; - unassigned: number; - distribution: Array<{ agentId: string; agentName: string; openConversations: number }>; - } | null ->("reporting:getAgentWorkloadDistribution"); - -const csatByAgentQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces">; startDate: number; endDate: number }, - Array<{ - agentId: string; - agentName: string; - averageRating: number; - totalResponses: number; - }> ->("reporting:getCsatByAgent"); +import { useTeamReportConvex } from "../hooks/useReportsConvex"; function formatDuration(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms`; @@ -58,20 +22,10 @@ function TeamReportContent() { const now = Date.now(); const startDate = now - (dateRange === "7d" ? 7 : dateRange === "30d" ? 30 : 90) * 24 * 60 * 60 * 1000; - - const agentMetrics = useQuery( - agentMetricsQuery, - activeWorkspace?._id ? { workspaceId: activeWorkspace._id, startDate, endDate: now } : "skip" - ); - - const workloadDistribution = useQuery( - agentWorkloadDistributionQuery, - activeWorkspace?._id ? { workspaceId: activeWorkspace._id } : "skip" - ); - - const csatByAgent = useQuery( - csatByAgentQuery, - activeWorkspace?._id ? { workspaceId: activeWorkspace._id, startDate, endDate: now } : "skip" + const { agentMetrics, csatByAgent, workloadDistribution } = useTeamReportConvex( + activeWorkspace?._id, + startDate, + now ); const handleExportCSV = () => { diff --git a/apps/web/src/app/segments/hooks/useSegmentsPageConvex.ts b/apps/web/src/app/segments/hooks/useSegmentsPageConvex.ts new file mode 100644 index 0000000..417e930 --- /dev/null +++ b/apps/web/src/app/segments/hooks/useSegmentsPageConvex.ts @@ -0,0 +1,99 @@ +"use client"; + +import type { Id } from "@opencom/convex/dataModel"; +import type { InlineAudienceRule } from "@/lib/audienceRules"; +import { + useWebMutation, + useWebQuery, + webMutationRef, + webQueryRef, +} from "@/lib/convex/hooks"; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type SegmentAudienceRulesInput = InlineAudienceRule; + +type SegmentIdArgs = { + id: Id<"segments">; +}; + +type CreateSegmentArgs = WorkspaceArgs & { + name: string; + description?: string; + audienceRules: SegmentAudienceRulesInput; +}; + +type UpdateSegmentArgs = { + id: Id<"segments">; + name?: string; + description?: string; + audienceRules?: SegmentAudienceRulesInput; +}; + +const SEGMENTS_LIST_QUERY_REF = webQueryRef< + WorkspaceArgs, + Array<{ _id: Id<"segments">; name: string; description?: string; audienceRules: unknown }> +>("segments:list"); +const SEGMENT_PREVIEW_QUERY_REF = webQueryRef< + WorkspaceArgs & { audienceRules: SegmentAudienceRulesInput }, + { matching: number; total: number } | null +>("segments:preview"); +const SEGMENT_USAGE_QUERY_REF = webQueryRef< + SegmentIdArgs, + Array<{ type: string; name: string }> +>("segments:getUsage"); +const SEGMENT_GET_QUERY_REF = webQueryRef< + SegmentIdArgs, + { + _id: Id<"segments">; + name: string; + description?: string; + audienceRules: unknown; + } | null +>("segments:get"); +const EVENT_NAMES_QUERY_REF = webQueryRef("events:getDistinctNames"); +const CREATE_SEGMENT_REF = webMutationRef>("segments:create"); +const UPDATE_SEGMENT_REF = webMutationRef("segments:update"); +const DELETE_SEGMENT_REF = webMutationRef("segments:remove"); + +export function useSegmentsListConvex(workspaceId?: Id<"workspaces">) { + return { + segments: useWebQuery(SEGMENTS_LIST_QUERY_REF, workspaceId ? { workspaceId } : "skip"), + }; +} + +export function useSegmentCardConvex( + workspaceId: Id<"workspaces">, + segmentId: Id<"segments">, + audienceRules: SegmentAudienceRulesInput | null +) { + return { + preview: useWebQuery( + SEGMENT_PREVIEW_QUERY_REF, + audienceRules ? { workspaceId, audienceRules } : "skip" + ), + usage: useWebQuery(SEGMENT_USAGE_QUERY_REF, { id: segmentId }), + }; +} + +export function useSegmentModalConvex( + workspaceId: Id<"workspaces">, + segmentId?: Id<"segments"> +) { + return { + createSegment: useWebMutation(CREATE_SEGMENT_REF), + eventNames: useWebQuery(EVENT_NAMES_QUERY_REF, { workspaceId }), + existingSegment: useWebQuery(SEGMENT_GET_QUERY_REF, segmentId ? { id: segmentId } : "skip"), + updateSegment: useWebMutation(UPDATE_SEGMENT_REF), + }; +} + +export function useDeleteSegmentConvex(segmentId: Id<"segments">) { + return { + deleteSegment: useWebMutation(DELETE_SEGMENT_REF), + segment: useWebQuery(SEGMENT_GET_QUERY_REF, { id: segmentId }), + usage: useWebQuery(SEGMENT_USAGE_QUERY_REF, { id: segmentId }), + }; +} diff --git a/apps/web/src/app/segments/page.tsx b/apps/web/src/app/segments/page.tsx index b47d627..4461f92 100644 --- a/apps/web/src/app/segments/page.tsx +++ b/apps/web/src/app/segments/page.tsx @@ -1,8 +1,6 @@ "use client"; import { useState } from "react"; -import { useQuery, useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { useAuth } from "@/contexts/AuthContext"; import { Button, Input } from "@opencom/ui"; import { Plus, Trash2, Edit2, Users, AlertTriangle, Layers } from "lucide-react"; @@ -14,77 +12,19 @@ import { toInlineAudienceRuleFromBuilder, type InlineAudienceRule, } from "@/lib/audienceRules"; +import { + useDeleteSegmentConvex, + useSegmentCardConvex, + useSegmentModalConvex, + useSegmentsListConvex, +} from "./hooks/useSegmentsPageConvex"; type SegmentAudienceRulesInput = InlineAudienceRule; -const segmentsListQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - Array<{ _id: Id<"segments">; name: string; description?: string; audienceRules: unknown }> ->("segments:list"); - -const segmentPreviewQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces">; audienceRules: SegmentAudienceRulesInput }, - { matching: number; total: number } | null ->("segments:preview"); - -const segmentUsageQuery = makeFunctionReference< - "query", - { id: Id<"segments"> }, - Array<{ type: string; name: string }> ->("segments:getUsage"); - -const segmentGetQuery = makeFunctionReference< - "query", - { id: Id<"segments"> }, - { - _id: Id<"segments">; - name: string; - description?: string; - audienceRules: unknown; - } | null ->("segments:get"); - -const eventNamesQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - string[] ->("events:getDistinctNames"); - -const createSegmentRef = makeFunctionReference< - "mutation", - { - workspaceId: Id<"workspaces">; - name: string; - description?: string; - audienceRules: SegmentAudienceRulesInput; - }, - Id<"segments"> ->("segments:create"); - -const updateSegmentRef = makeFunctionReference< - "mutation", - { - id: Id<"segments">; - name?: string; - description?: string; - audienceRules?: SegmentAudienceRulesInput; - }, - null ->("segments:update"); - -const deleteSegmentRef = makeFunctionReference< - "mutation", - { id: Id<"segments"> }, - null ->("segments:remove"); - function SegmentsContent() { const { user } = useAuth(); const workspaceId = user?.workspaceId as Id<"workspaces"> | undefined; - - const segments = useQuery(segmentsListQuery, workspaceId ? { workspaceId } : "skip"); + const { segments } = useSegmentsListConvex(workspaceId); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [editingSegment, setEditingSegment] = useState | null>(null); const [deleteConfirmId, setDeleteConfirmId] = useState | null>(null); @@ -171,18 +111,12 @@ function SegmentCard({ onDelete: () => void; }) { const parsedAudienceRules = toInlineAudienceRule(segment.audienceRules); - const preview = useQuery( - segmentPreviewQuery, - parsedAudienceRules - ? { - workspaceId, - audienceRules: parsedAudienceRules as SegmentAudienceRulesInput, - } - : "skip" + const { preview, usage } = useSegmentCardConvex( + workspaceId, + segment._id, + parsedAudienceRules as SegmentAudienceRulesInput | null ); - const usage = useQuery(segmentUsageQuery, { id: segment._id }); - return (
; onClose: () => void; }) { - const existingSegment = useQuery(segmentGetQuery, segmentId ? { id: segmentId } : "skip"); - - const eventNames = useQuery(eventNamesQuery, { workspaceId }); - - const createSegment = useMutation(createSegmentRef); - const updateSegment = useMutation(updateSegmentRef); + const { createSegment, eventNames, existingSegment, updateSegment } = useSegmentModalConvex( + workspaceId, + segmentId + ); const [name, setName] = useState(existingSegment?.name || ""); const [description, setDescription] = useState(existingSegment?.description || ""); @@ -367,9 +299,7 @@ function DeleteConfirmModal({ segmentId: Id<"segments">; onClose: () => void; }) { - const segment = useQuery(segmentGetQuery, { id: segmentId }); - const usage = useQuery(segmentUsageQuery, { id: segmentId }); - const deleteSegment = useMutation(deleteSegmentRef); + const { deleteSegment, segment, usage } = useDeleteSegmentConvex(segmentId); const [isDeleting, setIsDeleting] = useState(false); const handleDelete = async () => { diff --git a/apps/web/src/app/settings/AIAgentSection.tsx b/apps/web/src/app/settings/AIAgentSection.tsx index 14f20ec..738d87b 100644 --- a/apps/web/src/app/settings/AIAgentSection.tsx +++ b/apps/web/src/app/settings/AIAgentSection.tsx @@ -1,65 +1,17 @@ "use client"; import { useState, useEffect } from "react"; -import { useQuery, useMutation } from "convex/react"; import { Button, Card, Input } from "@opencom/ui"; import { AlertTriangle, Bot } from "lucide-react"; -import { makeFunctionReference } from "convex/server"; import type { Id } from "@opencom/convex/dataModel"; - -const aiSettingsQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - { - enabled: boolean; - model: string; - confidenceThreshold: number; - knowledgeSources: string[]; - personality?: string; - handoffMessage?: string; - suggestionsEnabled?: boolean; - embeddingModel?: string; - lastConfigError?: { - message: string; - code: string; - provider?: string; - model?: string; - } | null; - } | null ->("aiAgent:getSettings"); - -const availableModelsQuery = makeFunctionReference< - "query", - Record, - Array<{ id: string; name: string; provider: string }> ->("aiAgent:listAvailableModels"); - -const updateAiSettingsRef = makeFunctionReference< - "mutation", - { - workspaceId: Id<"workspaces">; - enabled?: boolean; - model?: string; - confidenceThreshold?: number; - knowledgeSources?: Array<"articles" | "internalArticles" | "snippets">; - personality?: string; - handoffMessage?: string; - suggestionsEnabled?: boolean; - embeddingModel?: string; - }, - null ->("aiAgent:updateSettings"); +import { useAIAgentSectionConvex } from "./hooks/useSettingsSectionsConvex"; export function AIAgentSection({ workspaceId, }: { workspaceId?: Id<"workspaces">; }): React.JSX.Element | null { - const aiSettings = useQuery(aiSettingsQuery, workspaceId ? { workspaceId } : "skip"); - - const availableModels = useQuery(availableModelsQuery, {}); - - const updateSettings = useMutation(updateAiSettingsRef); + const { aiSettings, availableModels, updateSettings } = useAIAgentSectionConvex(workspaceId); const [enabled, setEnabled] = useState(false); const [model, setModel] = useState("openai/gpt-5-nano"); diff --git a/apps/web/src/app/settings/AuditLogViewer.tsx b/apps/web/src/app/settings/AuditLogViewer.tsx index 1a81abd..9209924 100644 --- a/apps/web/src/app/settings/AuditLogViewer.tsx +++ b/apps/web/src/app/settings/AuditLogViewer.tsx @@ -1,70 +1,9 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { useMutation, useQuery } from "convex/react"; import { Button } from "@opencom/ui"; -import { makeFunctionReference } from "convex/server"; import type { Id } from "@opencom/convex/dataModel"; - -const AUDIT_ACCESS_QUERY = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - { status: "unauthenticated" | "ok" | "forbidden"; canRead?: boolean; canExport?: boolean } ->("auditLogs:getAccess"); - -const AUDIT_LOGS_LIST_QUERY = makeFunctionReference< - "query", - { - workspaceId: Id<"workspaces">; - action?: string; - actorId?: Id<"users">; - resourceType?: string; - resourceId?: string; - startTime: number; - endTime: number; - limit: number; - }, - Array<{ - _id: string; - action: string; - timestamp: number; - actorId?: string; - actorType?: string; - actorName?: string; - actorEmail?: string; - resourceType?: string; - resourceId?: string; - metadata?: unknown; - details?: unknown; - }> ->("auditLogs:list"); - -const AUDIT_ACTIONS_QUERY = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - string[] ->("auditLogs:getActions"); - -const EXPORT_LOGS_QUERY = makeFunctionReference< - "query", - { - workspaceId: Id<"workspaces">; - action?: string; - actorId?: Id<"users">; - resourceType?: string; - resourceId?: string; - startTime: number; - endTime: number; - format: "json"; - }, - { data: unknown; count: number } ->("auditLogs:exportLogs"); - -const LOG_EXPORT_REF = makeFunctionReference< - "mutation", - { workspaceId: Id<"workspaces">; exportType: string; recordCount: number }, - unknown ->("auditLogs:logExport"); +import { useAuditLogViewerConvex } from "./hooks/useSettingsSectionsConvex"; export function AuditLogViewer({ workspaceId, @@ -88,51 +27,28 @@ export function AuditLogViewer({ endTime: now, }; }, [timeRangeDays]); - - const auditAccess = useQuery(AUDIT_ACCESS_QUERY, showViewer ? { workspaceId } : "skip"); - - const isAuditUnauthenticated = auditAccess?.status === "unauthenticated"; - const canReadAuditLogs = auditAccess?.status === "ok" ? auditAccess.canRead : false; - const canExportAuditLogs = auditAccess?.status === "ok" ? auditAccess.canExport : false; - - const auditLogs = useQuery( - AUDIT_LOGS_LIST_QUERY, - showViewer && canReadAuditLogs - ? { - workspaceId, - action: actionFilter || undefined, - actorId: (actorFilter.trim() as Id<"users">) || undefined, - resourceType: resourceTypeFilter.trim() || undefined, - resourceId: resourceIdFilter.trim() || undefined, - startTime, - endTime, - limit: 100, - } - : "skip" - ); - - const availableActions = useQuery( - AUDIT_ACTIONS_QUERY, - showViewer && canReadAuditLogs ? { workspaceId } : "skip" + const { + auditAccess, + auditLogs, + availableActions, + canExportAuditLogs, + canReadAuditLogs, + exportLogs, + logExportMutation, + } = useAuditLogViewerConvex( + workspaceId, + showViewer, + { + action: actionFilter || undefined, + actorId: (actorFilter.trim() as Id<"users">) || undefined, + resourceType: resourceTypeFilter.trim() || undefined, + resourceId: resourceIdFilter.trim() || undefined, + startTime, + endTime, + }, + isExporting ); - - const exportLogs = useQuery( - EXPORT_LOGS_QUERY, - isExporting && canReadAuditLogs && canExportAuditLogs - ? { - workspaceId, - action: actionFilter || undefined, - actorId: (actorFilter.trim() as Id<"users">) || undefined, - resourceType: resourceTypeFilter.trim() || undefined, - resourceId: resourceIdFilter.trim() || undefined, - startTime, - endTime, - format: "json", - } - : "skip" - ); - - const logExportMutation = useMutation(LOG_EXPORT_REF); + const isAuditUnauthenticated = auditAccess?.status === "unauthenticated"; const handleExport = async () => { if (isAuditUnauthenticated) { diff --git a/apps/web/src/app/settings/AutomationSettingsSection.tsx b/apps/web/src/app/settings/AutomationSettingsSection.tsx index ea713f6..590a73a 100644 --- a/apps/web/src/app/settings/AutomationSettingsSection.tsx +++ b/apps/web/src/app/settings/AutomationSettingsSection.tsx @@ -1,46 +1,18 @@ "use client"; import { useState, useEffect } from "react"; -import { useQuery, useMutation } from "convex/react"; import { Button, Card } from "@opencom/ui"; import { Zap } from "lucide-react"; -import { makeFunctionReference } from "convex/server"; import type { Id } from "@opencom/convex/dataModel"; - -const automationSettingsQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - { - suggestArticlesEnabled: boolean; - showReplyTimeEnabled: boolean; - collectEmailEnabled: boolean; - askForRatingEnabled: boolean; - } | null ->("automationSettings:get"); - -const upsertAutomationSettingsRef = makeFunctionReference< - "mutation", - { - workspaceId: Id<"workspaces">; - suggestArticlesEnabled?: boolean; - showReplyTimeEnabled?: boolean; - collectEmailEnabled?: boolean; - askForRatingEnabled?: boolean; - }, - null ->("automationSettings:upsert"); +import { useAutomationSettingsSectionConvex } from "./hooks/useSettingsSectionsConvex"; export function AutomationSettingsSection({ workspaceId, }: { workspaceId?: Id<"workspaces">; }): React.JSX.Element | null { - const automationSettings = useQuery( - automationSettingsQuery, - workspaceId ? { workspaceId } : "skip" - ); - - const upsertSettings = useMutation(upsertAutomationSettingsRef); + const { automationSettings, upsertSettings } = + useAutomationSettingsSectionConvex(workspaceId); const [suggestArticlesEnabled, setSuggestArticlesEnabled] = useState(false); const [showReplyTimeEnabled, setShowReplyTimeEnabled] = useState(false); diff --git a/apps/web/src/app/settings/HomeSettingsSection.tsx b/apps/web/src/app/settings/HomeSettingsSection.tsx index b0c0aeb..35a0323 100644 --- a/apps/web/src/app/settings/HomeSettingsSection.tsx +++ b/apps/web/src/app/settings/HomeSettingsSection.tsx @@ -1,7 +1,6 @@ "use client"; import { useState, useEffect } from "react"; -import { useQuery, useMutation } from "convex/react"; import { Button, Card } from "@opencom/ui"; import { normalizeUnknownError, type ErrorFeedbackMessage } from "@opencom/web-shared"; import { @@ -9,42 +8,15 @@ import { normalizeHomeTabs, type HomeCard, type HomeCardType, - type HomeConfig, type HomeDefaultSpace, type HomeTab, type HomeTabId, type HomeVisibility, } from "@opencom/types"; import { Home, Plus, X, GripVertical, Search, MessageSquare, FileText, Bell } from "lucide-react"; -import { makeFunctionReference } from "convex/server"; import type { Id } from "@opencom/convex/dataModel"; import { ErrorFeedbackBanner } from "@/components/ErrorFeedbackBanner"; - -const homeConfigQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - HomeConfig | null ->("messengerSettings:getHomeConfig"); - -const updateHomeConfigRef = makeFunctionReference< - "mutation", - { - workspaceId: Id<"workspaces">; - homeConfig: { - enabled: boolean; - cards: HomeCard[]; - defaultSpace: HomeDefaultSpace; - tabs: HomeTab[]; - }; - }, - null ->("messengerSettings:updateHomeConfig"); - -const toggleHomeEnabledRef = makeFunctionReference< - "mutation", - { workspaceId: Id<"workspaces">; enabled: boolean }, - null ->("messengerSettings:toggleHomeEnabled"); +import { useHomeSettingsSectionConvex } from "./hooks/useSettingsSectionsConvex"; // Card type definitions for Home settings const CARD_TYPES = [ @@ -130,13 +102,8 @@ export function HomeSettingsSection({ }: { workspaceId?: Id<"workspaces">; }): React.JSX.Element | null { - const homeConfig = useQuery( - homeConfigQuery, - workspaceId ? { workspaceId } : "skip" - ) as HomeConfig | undefined; - - const updateHomeConfig = useMutation(updateHomeConfigRef); - const toggleHomeEnabled = useMutation(toggleHomeEnabledRef); + const { homeConfig, toggleHomeEnabled, updateHomeConfig } = + useHomeSettingsSectionConvex(workspaceId); const [enabled, setEnabled] = useState(false); const [cards, setCards] = useState([]); diff --git a/apps/web/src/app/settings/MobileDevicesSection.tsx b/apps/web/src/app/settings/MobileDevicesSection.tsx index 8b8b356..75d641b 100644 --- a/apps/web/src/app/settings/MobileDevicesSection.tsx +++ b/apps/web/src/app/settings/MobileDevicesSection.tsx @@ -1,44 +1,17 @@ "use client"; -import { useQuery } from "convex/react"; import { Card } from "@opencom/ui"; import { Smartphone } from "lucide-react"; -import { makeFunctionReference } from "convex/server"; import type { Id } from "@opencom/convex/dataModel"; import { formatVisitorIdentityLabel } from "@/lib/visitorIdentity"; - -const visitorPushTokenStatsQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - { total: number; ios: number; android: number; uniqueVisitors: number } | null ->("visitorPushTokens:getStats"); - -const visitorPushTokensWithInfoQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - Array<{ - _id: string; - platform: "ios" | "android"; - visitorId?: Id<"visitors">; - visitorReadableId?: string; - visitorName?: string; - visitorEmail?: string; - updatedAt: number; - token: string; - }> ->("visitorPushTokens:listWithVisitorInfo"); +import { useMobileDevicesSectionConvex } from "./hooks/useSettingsSectionsConvex"; export function MobileDevicesSection({ workspaceId, }: { workspaceId?: Id<"workspaces">; }): React.JSX.Element | null { - const stats = useQuery(visitorPushTokenStatsQuery, workspaceId ? { workspaceId } : "skip"); - - const devices = useQuery( - visitorPushTokensWithInfoQuery, - workspaceId ? { workspaceId } : "skip" - ); + const { devices, stats } = useMobileDevicesSectionConvex(workspaceId); if (!workspaceId) return null; diff --git a/apps/web/src/app/settings/NotificationSettingsSection.tsx b/apps/web/src/app/settings/NotificationSettingsSection.tsx index 184e671..648154a 100644 --- a/apps/web/src/app/settings/NotificationSettingsSection.tsx +++ b/apps/web/src/app/settings/NotificationSettingsSection.tsx @@ -1,11 +1,9 @@ "use client"; import { useEffect, useState } from "react"; -import { useMutation, useQuery } from "convex/react"; import { Bell } from "lucide-react"; import { Button, Card } from "@opencom/ui"; import { normalizeUnknownError, type ErrorFeedbackMessage } from "@opencom/web-shared"; -import { makeFunctionReference } from "convex/server"; import type { Id } from "@opencom/convex/dataModel"; import { broadcastInboxCuePreferencesUpdated, @@ -13,46 +11,7 @@ import { saveInboxCuePreferences, } from "@/lib/inboxNotificationCues"; import { ErrorFeedbackBanner } from "@/components/ErrorFeedbackBanner"; - -const myNotificationPreferencesQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - { - effective: { - newVisitorMessageEmail: boolean; - newVisitorMessagePush: boolean; - }; - } | null ->("notificationSettings:getMyPreferences"); - -const workspaceNotificationDefaultsQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - { - newVisitorMessageEmail: boolean; - newVisitorMessagePush: boolean; - } | null ->("notificationSettings:getWorkspaceDefaults"); - -const updateMyNotificationPreferencesRef = makeFunctionReference< - "mutation", - { - workspaceId: Id<"workspaces">; - newVisitorMessageEmail?: boolean; - newVisitorMessagePush?: boolean; - }, - null ->("notificationSettings:updateMyPreferences"); - -const updateWorkspaceNotificationDefaultsRef = makeFunctionReference< - "mutation", - { - workspaceId: Id<"workspaces">; - newVisitorMessageEmail?: boolean; - newVisitorMessagePush?: boolean; - }, - null ->("notificationSettings:updateWorkspaceDefaults"); +import { useNotificationSettingsSectionConvex } from "./hooks/useSettingsSectionsConvex"; interface NotificationSettingsSectionProps { workspaceId?: Id<"workspaces">; @@ -63,18 +22,8 @@ export function NotificationSettingsSection({ workspaceId, isAdmin, }: NotificationSettingsSectionProps): React.JSX.Element | null { - const myPreferences = useQuery( - myNotificationPreferencesQuery, - workspaceId ? { workspaceId } : "skip" - ); - - const workspaceDefaults = useQuery( - workspaceNotificationDefaultsQuery, - workspaceId && isAdmin ? { workspaceId } : "skip" - ); - - const updateMyPreferences = useMutation(updateMyNotificationPreferencesRef); - const updateWorkspaceDefaults = useMutation(updateWorkspaceNotificationDefaultsRef); + const { myPreferences, updateMyPreferences, updateWorkspaceDefaults, workspaceDefaults } = + useNotificationSettingsSectionConvex(workspaceId, isAdmin); const [myEmailEnabled, setMyEmailEnabled] = useState(true); const [myPushEnabled, setMyPushEnabled] = useState(true); diff --git a/apps/web/src/app/settings/SecurityIdentitySettingsCard.tsx b/apps/web/src/app/settings/SecurityIdentitySettingsCard.tsx index fdf4514..c45f2bc 100644 --- a/apps/web/src/app/settings/SecurityIdentitySettingsCard.tsx +++ b/apps/web/src/app/settings/SecurityIdentitySettingsCard.tsx @@ -1,56 +1,19 @@ "use client"; import { useState } from "react"; -import { useMutation, useQuery } from "convex/react"; import { Button } from "@opencom/ui"; import { Check, Copy } from "lucide-react"; -import { makeFunctionReference } from "convex/server"; import type { Id } from "@opencom/convex/dataModel"; import { appConfirm } from "@/lib/appConfirm"; - -const identitySettingsQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - { enabled: boolean; mode: "optional" | "required" } | null ->("identityVerification:getSettings"); -const identitySecretQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - { secret?: string | null } | null ->("identityVerification:getSecret"); -const enableIdentityRef = makeFunctionReference< - "mutation", - { workspaceId: Id<"workspaces"> }, - { secret?: string | null } ->("identityVerification:enable"); -const disableIdentityRef = makeFunctionReference< - "mutation", - { workspaceId: Id<"workspaces">; confirmDisable: boolean }, - null ->("identityVerification:disable"); -const updateIdentityModeRef = makeFunctionReference< - "mutation", - { workspaceId: Id<"workspaces">; mode: "optional" | "required" }, - null ->("identityVerification:updateMode"); -const rotateIdentitySecretRef = makeFunctionReference< - "mutation", - { workspaceId: Id<"workspaces"> }, - { secret?: string | null } ->("identityVerification:rotateSecret"); +import { useSecurityIdentitySettingsCardConvex } from "./hooks/useSettingsSectionsConvex"; export function SecurityIdentitySettingsCard({ workspaceId, }: { workspaceId: Id<"workspaces">; }): React.JSX.Element { - const identitySettings = useQuery(identitySettingsQuery, { workspaceId }); - const identitySecret = useQuery(identitySecretQuery, { workspaceId }); - - const enableIdentity = useMutation(enableIdentityRef); - const disableIdentity = useMutation(disableIdentityRef); - const updateMode = useMutation(updateIdentityModeRef); - const rotateSecret = useMutation(rotateIdentitySecretRef); + const { disableIdentity, enableIdentity, identitySecret, identitySettings, rotateSecret, updateMode } = + useSecurityIdentitySettingsCardConvex(workspaceId); const [showSecret, setShowSecret] = useState(false); const [showDisableConfirm, setShowDisableConfirm] = useState(false); diff --git a/apps/web/src/app/settings/SecuritySettingsSection.tsx b/apps/web/src/app/settings/SecuritySettingsSection.tsx index e1d01f7..0157160 100644 --- a/apps/web/src/app/settings/SecuritySettingsSection.tsx +++ b/apps/web/src/app/settings/SecuritySettingsSection.tsx @@ -1,47 +1,23 @@ "use client"; -import { useMutation, useQuery } from "convex/react"; import { Card } from "@opencom/ui"; import { Shield } from "lucide-react"; -import { makeFunctionReference } from "convex/server"; import type { Id } from "@opencom/convex/dataModel"; import { AuditLogViewer } from "./AuditLogViewer"; +import { useSecuritySettingsSectionConvex } from "./hooks/useSettingsSectionsConvex"; import { SecurityIdentitySettingsCard } from "./SecurityIdentitySettingsCard"; import { SignedSessionsSettings } from "./SignedSessionsSettings"; -const auditAccessQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - { status: "unauthenticated" | "forbidden" | "ok"; canManageSecurity?: boolean } | null ->("auditLogs:getAccess"); - -const auditLogSettingsQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - { retentionDays: number } | null ->("auditLogs:getSettings"); - -const updateAuditSettingsRef = makeFunctionReference< - "mutation", - { workspaceId: Id<"workspaces">; retentionDays: number }, - null ->("auditLogs:updateSettings"); - export function SecuritySettingsSection({ workspaceId, }: { workspaceId?: Id<"workspaces">; }): React.JSX.Element | null { - const auditAccess = useQuery(auditAccessQuery, workspaceId ? { workspaceId } : "skip"); + const { auditAccess, auditLogSettings, updateAuditSettings } = + useSecuritySettingsSectionConvex(workspaceId); const isSecurityUnauthenticated = auditAccess?.status === "unauthenticated"; const canManageSecurity = auditAccess?.status === "ok" ? auditAccess.canManageSecurity : false; - const auditLogSettings = useQuery( - auditLogSettingsQuery, - workspaceId && canManageSecurity ? { workspaceId } : "skip" - ); - const updateAuditSettings = useMutation(updateAuditSettingsRef); - if (!workspaceId) { return null; } diff --git a/apps/web/src/app/settings/SignedSessionsSettings.tsx b/apps/web/src/app/settings/SignedSessionsSettings.tsx index 908c175..3d6ef65 100644 --- a/apps/web/src/app/settings/SignedSessionsSettings.tsx +++ b/apps/web/src/app/settings/SignedSessionsSettings.tsx @@ -1,8 +1,7 @@ "use client"; -import { useMutation, useQuery } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import type { Id } from "@opencom/convex/dataModel"; +import { useSignedSessionsSettingsConvex } from "./hooks/useSettingsSectionsConvex"; const SESSION_LIFETIME_OPTIONS = [ { value: 1 * 60 * 60 * 1000, label: "1 hour" }, @@ -13,25 +12,12 @@ const SESSION_LIFETIME_OPTIONS = [ { value: 7 * 24 * 60 * 60 * 1000, label: "7 days" }, ]; -const widgetSessionSettingsQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - { sessionLifetimeMs: number } | null ->("widgetSessions:getSettings"); - -const updateWidgetSessionSettingsRef = makeFunctionReference< - "mutation", - { workspaceId: Id<"workspaces">; sessionLifetimeMs: number }, - null ->("widgetSessions:updateSettings"); - export function SignedSessionsSettings({ workspaceId, }: { workspaceId: Id<"workspaces">; }): React.JSX.Element { - const settings = useQuery(widgetSessionSettingsQuery, { workspaceId }); - const updateSettings = useMutation(updateWidgetSessionSettingsRef); + const { settings, updateSettings } = useSignedSessionsSettingsConvex(workspaceId); const handleLifetimeChange = async (sessionLifetimeMs: number) => { try { diff --git a/apps/web/src/app/settings/hooks/useSettingsSectionsConvex.ts b/apps/web/src/app/settings/hooks/useSettingsSectionsConvex.ts new file mode 100644 index 0000000..c1711aa --- /dev/null +++ b/apps/web/src/app/settings/hooks/useSettingsSectionsConvex.ts @@ -0,0 +1,417 @@ +"use client"; + +import type { Id } from "@opencom/convex/dataModel"; +import type { HomeCard, HomeConfig, HomeDefaultSpace, HomeTab } from "@opencom/types"; +import { + useWebAction, + useWebMutation, + useWebQuery, + webActionRef, + webMutationRef, + webQueryRef, +} from "@/lib/convex/hooks"; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type AuditAccessRecord = { + status: "unauthenticated" | "forbidden" | "ok"; + canManageSecurity?: boolean; + canRead?: boolean; + canExport?: boolean; +} | null; + +type AuditLogFilters = { + action?: string; + actorId?: Id<"users">; + resourceType?: string; + resourceId?: string; + startTime: number; + endTime: number; +}; + +type InviteToWorkspaceArgs = { + workspaceId: Id<"workspaces">; + email: string; + role: "admin" | "agent" | "viewer"; + baseUrl: string; +}; + +type InviteToWorkspaceResult = { + status: "added" | "invited"; +}; + +type UpdateRoleArgs = { + membershipId: Id<"workspaceMembers">; + role: "admin" | "agent" | "viewer"; +}; + +type RemoveMemberArgs = { + membershipId: Id<"workspaceMembers">; +}; + +type CancelInvitationArgs = { + invitationId: Id<"workspaceInvitations">; +}; + +type TransferOwnershipArgs = { + workspaceId: Id<"workspaces">; + newOwnerId: Id<"users">; +}; + +type SuccessResponse = { + success: boolean; +}; + +const AI_SETTINGS_QUERY_REF = webQueryRef< + WorkspaceArgs, + { + enabled: boolean; + model: string; + confidenceThreshold: number; + knowledgeSources: string[]; + personality?: string; + handoffMessage?: string; + suggestionsEnabled?: boolean; + embeddingModel?: string; + lastConfigError?: { + message: string; + code: string; + provider?: string; + model?: string; + } | null; + } | null +>("aiAgent:getSettings"); +const AVAILABLE_MODELS_QUERY_REF = webQueryRef< + Record, + Array<{ id: string; name: string; provider: string }> +>("aiAgent:listAvailableModels"); +const UPDATE_AI_SETTINGS_REF = webMutationRef< + { + workspaceId: Id<"workspaces">; + enabled?: boolean; + model?: string; + confidenceThreshold?: number; + knowledgeSources?: Array<"articles" | "internalArticles" | "snippets">; + personality?: string; + handoffMessage?: string; + suggestionsEnabled?: boolean; + embeddingModel?: string; + }, + null +>("aiAgent:updateSettings"); +const AUTOMATION_SETTINGS_QUERY_REF = webQueryRef< + WorkspaceArgs, + { + suggestArticlesEnabled: boolean; + showReplyTimeEnabled: boolean; + collectEmailEnabled: boolean; + askForRatingEnabled: boolean; + } | null +>("automationSettings:get"); +const UPSERT_AUTOMATION_SETTINGS_REF = webMutationRef< + { + workspaceId: Id<"workspaces">; + suggestArticlesEnabled?: boolean; + showReplyTimeEnabled?: boolean; + collectEmailEnabled?: boolean; + askForRatingEnabled?: boolean; + }, + null +>("automationSettings:upsert"); +const HOME_CONFIG_QUERY_REF = webQueryRef( + "messengerSettings:getHomeConfig" +); +const UPDATE_HOME_CONFIG_REF = webMutationRef< + { + workspaceId: Id<"workspaces">; + homeConfig: { + enabled: boolean; + cards: HomeCard[]; + defaultSpace: HomeDefaultSpace; + tabs: HomeTab[]; + }; + }, + null +>("messengerSettings:updateHomeConfig"); +const TOGGLE_HOME_ENABLED_REF = webMutationRef< + { workspaceId: Id<"workspaces">; enabled: boolean }, + null +>("messengerSettings:toggleHomeEnabled"); +const VISITOR_PUSH_TOKEN_STATS_QUERY_REF = webQueryRef< + WorkspaceArgs, + { total: number; ios: number; android: number; uniqueVisitors: number } | null +>("visitorPushTokens:getStats"); +const VISITOR_PUSH_TOKENS_WITH_INFO_QUERY_REF = webQueryRef< + WorkspaceArgs, + Array<{ + _id: string; + platform: "ios" | "android"; + visitorId?: Id<"visitors">; + visitorReadableId?: string; + visitorName?: string; + visitorEmail?: string; + updatedAt: number; + token: string; + }> +>("visitorPushTokens:listWithVisitorInfo"); +const MY_NOTIFICATION_PREFERENCES_QUERY_REF = webQueryRef< + WorkspaceArgs, + { + effective: { + newVisitorMessageEmail: boolean; + newVisitorMessagePush: boolean; + }; + } | null +>("notificationSettings:getMyPreferences"); +const WORKSPACE_NOTIFICATION_DEFAULTS_QUERY_REF = webQueryRef< + WorkspaceArgs, + { + newVisitorMessageEmail: boolean; + newVisitorMessagePush: boolean; + } | null +>("notificationSettings:getWorkspaceDefaults"); +const UPDATE_MY_NOTIFICATION_PREFERENCES_REF = webMutationRef< + { + workspaceId: Id<"workspaces">; + newVisitorMessageEmail?: boolean; + newVisitorMessagePush?: boolean; + }, + null +>("notificationSettings:updateMyPreferences"); +const UPDATE_WORKSPACE_NOTIFICATION_DEFAULTS_REF = webMutationRef< + { + workspaceId: Id<"workspaces">; + newVisitorMessageEmail?: boolean; + newVisitorMessagePush?: boolean; + }, + null +>("notificationSettings:updateWorkspaceDefaults"); +const IDENTITY_SETTINGS_QUERY_REF = webQueryRef< + WorkspaceArgs, + { enabled: boolean; mode: "optional" | "required" } | null +>("identityVerification:getSettings"); +const IDENTITY_SECRET_QUERY_REF = webQueryRef< + WorkspaceArgs, + { secret?: string | null } | null +>("identityVerification:getSecret"); +const ENABLE_IDENTITY_REF = webMutationRef< + WorkspaceArgs, + { secret?: string | null } +>("identityVerification:enable"); +const DISABLE_IDENTITY_REF = webMutationRef< + { workspaceId: Id<"workspaces">; confirmDisable: boolean }, + null +>("identityVerification:disable"); +const UPDATE_IDENTITY_MODE_REF = webMutationRef< + { workspaceId: Id<"workspaces">; mode: "optional" | "required" }, + null +>("identityVerification:updateMode"); +const ROTATE_IDENTITY_SECRET_REF = webMutationRef< + WorkspaceArgs, + { secret?: string | null } +>("identityVerification:rotateSecret"); +const AUDIT_ACCESS_QUERY_REF = webQueryRef("auditLogs:getAccess"); +const AUDIT_LOG_SETTINGS_QUERY_REF = webQueryRef< + WorkspaceArgs, + { retentionDays: number } | null +>("auditLogs:getSettings"); +const UPDATE_AUDIT_SETTINGS_REF = webMutationRef< + { workspaceId: Id<"workspaces">; retentionDays: number }, + null +>("auditLogs:updateSettings"); +const AUDIT_LOGS_LIST_QUERY_REF = webQueryRef< + WorkspaceArgs & + AuditLogFilters & { + limit: number; + }, + Array<{ + _id: string; + action: string; + timestamp: number; + actorId?: string; + actorType?: string; + actorName?: string; + actorEmail?: string; + resourceType?: string; + resourceId?: string; + metadata?: unknown; + details?: unknown; + }> +>("auditLogs:list"); +const AUDIT_ACTIONS_QUERY_REF = webQueryRef("auditLogs:getActions"); +const EXPORT_LOGS_QUERY_REF = webQueryRef< + WorkspaceArgs & + AuditLogFilters & { + format: "json"; + }, + { data: unknown; count: number } +>("auditLogs:exportLogs"); +const LOG_EXPORT_REF = webMutationRef< + { workspaceId: Id<"workspaces">; exportType: string; recordCount: number }, + unknown +>("auditLogs:logExport"); +const WIDGET_SESSION_SETTINGS_QUERY_REF = webQueryRef< + WorkspaceArgs, + { sessionLifetimeMs: number } | null +>("widgetSessions:getSettings"); +const UPDATE_WIDGET_SESSION_SETTINGS_REF = webMutationRef< + { workspaceId: Id<"workspaces">; sessionLifetimeMs: number }, + null +>("widgetSessions:updateSettings"); +const INVITE_TO_WORKSPACE_REF = webActionRef< + InviteToWorkspaceArgs, + InviteToWorkspaceResult +>("workspaceMembers:inviteToWorkspace"); +const UPDATE_ROLE_REF = webMutationRef( + "workspaceMembers:updateRole" +); +const REMOVE_MEMBER_REF = webMutationRef( + "workspaceMembers:remove" +); +const CANCEL_INVITATION_REF = webMutationRef( + "workspaceMembers:cancelInvitation" +); +const TRANSFER_OWNERSHIP_REF = webMutationRef( + "workspaceMembers:transferOwnership" +); + +export function useAIAgentSectionConvex(workspaceId?: Id<"workspaces">) { + return { + aiSettings: useWebQuery(AI_SETTINGS_QUERY_REF, workspaceId ? { workspaceId } : "skip"), + availableModels: useWebQuery(AVAILABLE_MODELS_QUERY_REF, {}), + updateSettings: useWebMutation(UPDATE_AI_SETTINGS_REF), + }; +} + +export function useAutomationSettingsSectionConvex(workspaceId?: Id<"workspaces">) { + return { + automationSettings: useWebQuery( + AUTOMATION_SETTINGS_QUERY_REF, + workspaceId ? { workspaceId } : "skip" + ), + upsertSettings: useWebMutation(UPSERT_AUTOMATION_SETTINGS_REF), + }; +} + +export function useHomeSettingsSectionConvex(workspaceId?: Id<"workspaces">) { + return { + homeConfig: useWebQuery(HOME_CONFIG_QUERY_REF, workspaceId ? { workspaceId } : "skip"), + toggleHomeEnabled: useWebMutation(TOGGLE_HOME_ENABLED_REF), + updateHomeConfig: useWebMutation(UPDATE_HOME_CONFIG_REF), + }; +} + +export function useMobileDevicesSectionConvex(workspaceId?: Id<"workspaces">) { + return { + devices: useWebQuery( + VISITOR_PUSH_TOKENS_WITH_INFO_QUERY_REF, + workspaceId ? { workspaceId } : "skip" + ), + stats: useWebQuery(VISITOR_PUSH_TOKEN_STATS_QUERY_REF, workspaceId ? { workspaceId } : "skip"), + }; +} + +export function useNotificationSettingsSectionConvex( + workspaceId?: Id<"workspaces">, + isAdmin = false +) { + return { + myPreferences: useWebQuery( + MY_NOTIFICATION_PREFERENCES_QUERY_REF, + workspaceId ? { workspaceId } : "skip" + ), + updateMyPreferences: useWebMutation(UPDATE_MY_NOTIFICATION_PREFERENCES_REF), + updateWorkspaceDefaults: useWebMutation(UPDATE_WORKSPACE_NOTIFICATION_DEFAULTS_REF), + workspaceDefaults: useWebQuery( + WORKSPACE_NOTIFICATION_DEFAULTS_QUERY_REF, + workspaceId && isAdmin ? { workspaceId } : "skip" + ), + }; +} + +export function useSecurityIdentitySettingsCardConvex(workspaceId: Id<"workspaces">) { + return { + disableIdentity: useWebMutation(DISABLE_IDENTITY_REF), + enableIdentity: useWebMutation(ENABLE_IDENTITY_REF), + identitySecret: useWebQuery(IDENTITY_SECRET_QUERY_REF, { workspaceId }), + identitySettings: useWebQuery(IDENTITY_SETTINGS_QUERY_REF, { workspaceId }), + rotateSecret: useWebMutation(ROTATE_IDENTITY_SECRET_REF), + updateMode: useWebMutation(UPDATE_IDENTITY_MODE_REF), + }; +} + +export function useSecuritySettingsSectionConvex(workspaceId?: Id<"workspaces">) { + const auditAccess = useWebQuery(AUDIT_ACCESS_QUERY_REF, workspaceId ? { workspaceId } : "skip"); + const canManageSecurity = + workspaceId && auditAccess?.status === "ok" ? auditAccess.canManageSecurity : false; + + return { + auditAccess, + auditLogSettings: useWebQuery( + AUDIT_LOG_SETTINGS_QUERY_REF, + workspaceId && canManageSecurity ? { workspaceId } : "skip" + ), + updateAuditSettings: useWebMutation(UPDATE_AUDIT_SETTINGS_REF), + }; +} + +export function useSignedSessionsSettingsConvex(workspaceId: Id<"workspaces">) { + return { + settings: useWebQuery(WIDGET_SESSION_SETTINGS_QUERY_REF, { workspaceId }), + updateSettings: useWebMutation(UPDATE_WIDGET_SESSION_SETTINGS_REF), + }; +} + +export function useAuditLogViewerConvex( + workspaceId: Id<"workspaces">, + showViewer: boolean, + filters: AuditLogFilters, + isExporting: boolean +) { + const auditAccess = useWebQuery(AUDIT_ACCESS_QUERY_REF, showViewer ? { workspaceId } : "skip"); + const canReadAuditLogs = auditAccess?.status === "ok" ? auditAccess.canRead : false; + const canExportAuditLogs = auditAccess?.status === "ok" ? auditAccess.canExport : false; + + return { + auditAccess, + auditLogs: useWebQuery( + AUDIT_LOGS_LIST_QUERY_REF, + showViewer && canReadAuditLogs + ? { + workspaceId, + ...filters, + limit: 100, + } + : "skip" + ), + availableActions: useWebQuery( + AUDIT_ACTIONS_QUERY_REF, + showViewer && canReadAuditLogs ? { workspaceId } : "skip" + ), + canExportAuditLogs, + canReadAuditLogs, + exportLogs: useWebQuery( + EXPORT_LOGS_QUERY_REF, + isExporting && canReadAuditLogs && canExportAuditLogs + ? { + workspaceId, + ...filters, + format: "json", + } + : "skip" + ), + logExportMutation: useWebMutation(LOG_EXPORT_REF), + }; +} + +export function useTeamMembersSettingsConvex() { + return { + cancelInvitation: useWebMutation(CANCEL_INVITATION_REF), + inviteToWorkspace: useWebAction(INVITE_TO_WORKSPACE_REF), + removeMember: useWebMutation(REMOVE_MEMBER_REF), + transferOwnership: useWebMutation(TRANSFER_OWNERSHIP_REF), + updateRole: useWebMutation(UPDATE_ROLE_REF), + }; +} diff --git a/apps/web/src/app/settings/useTeamMembersSettings.ts b/apps/web/src/app/settings/useTeamMembersSettings.ts index 6d0b2b7..628ca96 100644 --- a/apps/web/src/app/settings/useTeamMembersSettings.ts +++ b/apps/web/src/app/settings/useTeamMembersSettings.ts @@ -1,10 +1,9 @@ "use client"; -import { makeFunctionReference } from "convex/server"; import { useState } from "react"; -import { useAction, useMutation } from "convex/react"; import { appConfirm } from "@/lib/appConfirm"; import type { Id } from "@opencom/convex/dataModel"; +import { useTeamMembersSettingsConvex } from "./hooks/useSettingsSectionsConvex"; type WorkspaceMemberRole = "owner" | "admin" | "agent" | "viewer"; @@ -52,65 +51,6 @@ export interface TeamMembersSettingsController { handleCancelInvitation: (invitationId: Id<"workspaceInvitations">) => Promise; } -type InviteToWorkspaceArgs = { - workspaceId: Id<"workspaces">; - email: string; - role: "admin" | "agent" | "viewer"; - baseUrl: string; -}; - -type InviteToWorkspaceResult = { - status: "added" | "invited"; -}; - -type UpdateRoleArgs = { - membershipId: Id<"workspaceMembers">; - role: "admin" | "agent" | "viewer"; -}; - -type SuccessResponse = { - success: boolean; -}; - -type RemoveMemberArgs = { - membershipId: Id<"workspaceMembers">; -}; - -type CancelInvitationArgs = { - invitationId: Id<"workspaceInvitations">; -}; - -type TransferOwnershipArgs = { - workspaceId: Id<"workspaces">; - newOwnerId: Id<"users">; -}; - -const INVITE_TO_WORKSPACE_REF = makeFunctionReference< - "action", - InviteToWorkspaceArgs, - InviteToWorkspaceResult ->("workspaceMembers:inviteToWorkspace"); - -const UPDATE_ROLE_REF = makeFunctionReference<"mutation", UpdateRoleArgs, SuccessResponse>( - "workspaceMembers:updateRole" -); - -const REMOVE_MEMBER_REF = makeFunctionReference<"mutation", RemoveMemberArgs, SuccessResponse>( - "workspaceMembers:remove" -); - -const CANCEL_INVITATION_REF = makeFunctionReference< - "mutation", - CancelInvitationArgs, - SuccessResponse ->("workspaceMembers:cancelInvitation"); - -const TRANSFER_OWNERSHIP_REF = makeFunctionReference< - "mutation", - TransferOwnershipArgs, - SuccessResponse ->("workspaceMembers:transferOwnership"); - export function useTeamMembersSettings({ workspaceId, onError, @@ -124,12 +64,8 @@ export function useTeamMembersSettings({ const [showTransferOwnership, setShowTransferOwnership] = useState(false); const [transferTargetId, setTransferTargetId] = useState | null>(null); const [showRoleConfirm, setShowRoleConfirm] = useState(null); - - const inviteToWorkspace = useAction(INVITE_TO_WORKSPACE_REF); - const updateRole = useMutation(UPDATE_ROLE_REF); - const removeMember = useMutation(REMOVE_MEMBER_REF); - const cancelInvitation = useMutation(CANCEL_INVITATION_REF); - const transferOwnership = useMutation(TRANSFER_OWNERSHIP_REF); + const { cancelInvitation, inviteToWorkspace, removeMember, transferOwnership, updateRole } = + useTeamMembersSettingsConvex(); const handleInvite = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/apps/web/src/app/snippets/hooks/useSnippetsPageConvex.ts b/apps/web/src/app/snippets/hooks/useSnippetsPageConvex.ts new file mode 100644 index 0000000..7f5b1b2 --- /dev/null +++ b/apps/web/src/app/snippets/hooks/useSnippetsPageConvex.ts @@ -0,0 +1,52 @@ +"use client"; + +import type { Id } from "@opencom/convex/dataModel"; +import { + useWebMutation, + useWebQuery, + webMutationRef, + webQueryRef, +} from "@/lib/convex/hooks"; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type CreateSnippetArgs = WorkspaceArgs & { + name: string; + content: string; + shortcut?: string; +}; + +type UpdateSnippetArgs = { + id: Id<"snippets">; + name?: string; + content?: string; + shortcut?: string; +}; + +type DeleteSnippetArgs = { + id: Id<"snippets">; +}; + +const SNIPPETS_LIST_QUERY_REF = webQueryRef< + WorkspaceArgs, + Array<{ + _id: Id<"snippets">; + name: string; + content: string; + shortcut?: string; + }> +>("snippets:list"); +const CREATE_SNIPPET_REF = webMutationRef>("snippets:create"); +const UPDATE_SNIPPET_REF = webMutationRef("snippets:update"); +const DELETE_SNIPPET_REF = webMutationRef("snippets:remove"); + +export function useSnippetsPageConvex(workspaceId?: Id<"workspaces"> | null) { + return { + createSnippet: useWebMutation(CREATE_SNIPPET_REF), + deleteSnippet: useWebMutation(DELETE_SNIPPET_REF), + snippets: useWebQuery(SNIPPETS_LIST_QUERY_REF, workspaceId ? { workspaceId } : "skip"), + updateSnippet: useWebMutation(UPDATE_SNIPPET_REF), + }; +} diff --git a/apps/web/src/app/snippets/page.tsx b/apps/web/src/app/snippets/page.tsx index 4d0f5d7..ce71043 100644 --- a/apps/web/src/app/snippets/page.tsx +++ b/apps/web/src/app/snippets/page.tsx @@ -1,48 +1,13 @@ "use client"; import { useState } from "react"; -import { useQuery, useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { appConfirm } from "@/lib/appConfirm"; import { useAuth } from "@/contexts/AuthContext"; import { AppLayout } from "@/components/AppLayout"; import { Button, Input } from "@opencom/ui"; import { Plus, Pencil, Trash2, MessageSquare, Search } from "lucide-react"; import type { Id } from "@opencom/convex/dataModel"; - -const snippetsListQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - Array<{ - _id: Id<"snippets">; - name: string; - content: string; - shortcut?: string; - }> ->("snippets:list"); - -const createSnippetRef = makeFunctionReference< - "mutation", - { - workspaceId: Id<"workspaces">; - name: string; - content: string; - shortcut?: string; - }, - Id<"snippets"> ->("snippets:create"); - -const updateSnippetRef = makeFunctionReference< - "mutation", - { id: Id<"snippets">; name?: string; content?: string; shortcut?: string }, - null ->("snippets:update"); - -const deleteSnippetRef = makeFunctionReference< - "mutation", - { id: Id<"snippets"> }, - null ->("snippets:remove"); +import { useSnippetsPageConvex } from "./hooks/useSnippetsPageConvex"; interface SnippetFormData { name: string; @@ -60,15 +25,8 @@ function SnippetsContent() { content: "", shortcut: "", }); - - const snippets = useQuery( - snippetsListQuery, - activeWorkspace?._id ? { workspaceId: activeWorkspace._id } : "skip" - ); - - const createSnippet = useMutation(createSnippetRef); - const updateSnippet = useMutation(updateSnippetRef); - const deleteSnippet = useMutation(deleteSnippetRef); + const { createSnippet, deleteSnippet, snippets, updateSnippet } = + useSnippetsPageConvex(activeWorkspace?._id); const handleOpenModal = (snippet?: NonNullable[number]) => { if (snippet) { diff --git a/apps/web/src/app/surveys/[id]/page.tsx b/apps/web/src/app/surveys/[id]/page.tsx index 1584a60..71f81da 100644 --- a/apps/web/src/app/surveys/[id]/page.tsx +++ b/apps/web/src/app/surveys/[id]/page.tsx @@ -2,8 +2,6 @@ import { useState, useEffect } from "react"; import { useParams } from "next/navigation"; -import { useQuery, useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { useAuth } from "@/contexts/AuthContext"; import { AppLayout } from "@/components/AppLayout"; import { Button, Input } from "@opencom/ui"; @@ -24,9 +22,9 @@ import { SurveyBuilderTab } from "./SurveyBuilderTab"; import { SurveyTargetingTab } from "./SurveyTargetingTab"; import { SurveySettingsTab } from "./SurveySettingsTab"; import { SurveyAnalyticsTab } from "./SurveyAnalyticsTab"; +import { useSurveyBuilderConvex } from "../hooks/useSurveysConvex"; import { type Question, - type QuestionAnalytics, type SurveyEditorTab, type SurveyFrequency, type SurveyScheduling, @@ -34,86 +32,12 @@ import { } from "./surveyEditorTypes"; import { useSurveyQuestionEditor } from "./useSurveyQuestionEditor"; -type SurveyRecord = { - _id: Id<"surveys">; - name: string; - description?: string; - format: "small" | "large"; - questions: Question[]; - introStep?: { title: string; description?: string; buttonText?: string } | null; - thankYouStep?: { title: string; description?: string; buttonText?: string } | null; - showProgressBar?: boolean; - showDismissButton?: boolean; - audienceRules?: unknown; - triggers?: SurveyTriggers; - frequency?: SurveyFrequency; - scheduling?: SurveyScheduling; - status: "draft" | "active" | "paused" | "archived"; -}; - -type SurveyAnalyticsRecord = { - impressions: { shown: number; started: number }; - totalResponses: number; - responseRate: number; - questionAnalytics: Record; -}; - -type UpdateSurveyArgs = { - id: Id<"surveys">; - name?: string; - description?: string; - format?: "small" | "large"; - questions?: Question[]; - introStep?: { title: string; description?: string; buttonText?: string }; - thankYouStep?: { title: string; description?: string; buttonText?: string }; - showProgressBar?: boolean; - showDismissButton?: boolean; - audienceRules?: unknown; - triggers?: SurveyTriggers; - frequency?: SurveyFrequency; - scheduling?: SurveyScheduling; -}; - -const surveyQuery = makeFunctionReference< - "query", - { id: Id<"surveys"> }, - SurveyRecord | null ->("surveys:get"); - -const surveyAnalyticsQuery = makeFunctionReference< - "query", - { surveyId: Id<"surveys"> }, - SurveyAnalyticsRecord | undefined ->("surveys:getAnalytics"); - -const updateSurveyRef = makeFunctionReference<"mutation", UpdateSurveyArgs, null>("surveys:update"); -const activateSurveyRef = makeFunctionReference< - "mutation", - { id: Id<"surveys"> }, - null ->("surveys:activate"); -const pauseSurveyRef = makeFunctionReference< - "mutation", - { id: Id<"surveys"> }, - null ->("surveys:pause"); -const exportSurveyResponsesCsvRef = makeFunctionReference< - "mutation", - { surveyId: Id<"surveys"> }, - { csv: string; fileName: string } ->("surveys:exportResponsesCsv"); - function SurveyBuilderContent() { const params = useParams(); const { activeWorkspace } = useAuth(); const surveyId = params.id as Id<"surveys">; - - const survey = useQuery(surveyQuery, { id: surveyId }) as SurveyRecord | null | undefined; - const analytics = useQuery(surveyAnalyticsQuery, { surveyId }) as SurveyAnalyticsRecord | undefined; - const updateSurvey = useMutation(updateSurveyRef); - const activateSurvey = useMutation(activateSurveyRef); - const pauseSurvey = useMutation(pauseSurveyRef); - const exportResponsesCsv = useMutation(exportSurveyResponsesCsvRef); + const { activateSurvey, analytics, exportResponsesCsv, pauseSurvey, survey, updateSurvey } = + useSurveyBuilderConvex(surveyId); const [activeTab, setActiveTab] = useState("builder"); const [name, setName] = useState(""); diff --git a/apps/web/src/app/surveys/hooks/useSurveysConvex.ts b/apps/web/src/app/surveys/hooks/useSurveysConvex.ts new file mode 100644 index 0000000..634f772 --- /dev/null +++ b/apps/web/src/app/surveys/hooks/useSurveysConvex.ts @@ -0,0 +1,127 @@ +"use client"; + +import type { Id } from "@opencom/convex/dataModel"; +import type { + Question, + QuestionAnalytics, + SurveyFrequency, + SurveyScheduling, + SurveyTriggers, +} from "../[id]/surveyEditorTypes"; +import { + useWebMutation, + useWebQuery, + webMutationRef, + webQueryRef, +} from "@/lib/convex/hooks"; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type SurveyArgs = { + id: Id<"surveys">; +}; + +type SurveyStatus = "draft" | "active" | "paused" | "archived"; + +const SURVEYS_LIST_QUERY_REF = webQueryRef< + WorkspaceArgs & { status?: SurveyStatus }, + Array<{ + _id: Id<"surveys">; + name: string; + description?: string; + format: string; + questions: unknown[]; + status: SurveyStatus; + createdAt: number; + }> +>("surveys:list"); +const CREATE_SURVEY_REF = webMutationRef< + WorkspaceArgs & { name: string; format: string }, + Id<"surveys"> +>("surveys:create"); +const DELETE_SURVEY_REF = webMutationRef("surveys:remove"); +const ACTIVATE_SURVEY_REF = webMutationRef("surveys:activate"); +const PAUSE_SURVEY_REF = webMutationRef("surveys:pause"); +const DUPLICATE_SURVEY_REF = webMutationRef | null>( + "surveys:duplicate" +); +const SURVEY_QUERY_REF = webQueryRef< + SurveyArgs, + { + _id: Id<"surveys">; + name: string; + description?: string; + format: "small" | "large"; + questions: Question[]; + introStep?: { title: string; description?: string; buttonText?: string } | null; + thankYouStep?: { title: string; description?: string; buttonText?: string } | null; + showProgressBar?: boolean; + showDismissButton?: boolean; + audienceRules?: unknown; + triggers?: SurveyTriggers; + frequency?: SurveyFrequency; + scheduling?: SurveyScheduling; + status: SurveyStatus; + } | null +>("surveys:get"); +const SURVEY_ANALYTICS_QUERY_REF = webQueryRef< + { surveyId: Id<"surveys"> }, + { + impressions: { shown: number; started: number }; + totalResponses: number; + responseRate: number; + questionAnalytics: Record; + } | undefined +>("surveys:getAnalytics"); +const UPDATE_SURVEY_REF = webMutationRef< + { + id: Id<"surveys">; + name?: string; + description?: string; + format?: "small" | "large"; + questions?: Question[]; + introStep?: { title: string; description?: string; buttonText?: string }; + thankYouStep?: { title: string; description?: string; buttonText?: string }; + showProgressBar?: boolean; + showDismissButton?: boolean; + audienceRules?: unknown; + triggers?: SurveyTriggers; + frequency?: SurveyFrequency; + scheduling?: SurveyScheduling; + }, + null +>("surveys:update"); +const EXPORT_SURVEY_RESPONSES_CSV_REF = webMutationRef< + { surveyId: Id<"surveys"> }, + { csv: string; fileName: string } +>("surveys:exportResponsesCsv"); + +export function useSurveysPageConvex( + workspaceId?: Id<"workspaces"> | null, + status?: SurveyStatus +) { + return { + activateSurvey: useWebMutation(ACTIVATE_SURVEY_REF), + createSurvey: useWebMutation(CREATE_SURVEY_REF), + deleteSurvey: useWebMutation(DELETE_SURVEY_REF), + duplicateSurvey: useWebMutation(DUPLICATE_SURVEY_REF), + pauseSurvey: useWebMutation(PAUSE_SURVEY_REF), + surveys: useWebQuery( + SURVEYS_LIST_QUERY_REF, + workspaceId ? { workspaceId, status } : "skip" + ), + }; +} + +export function useSurveyBuilderConvex(surveyId: Id<"surveys">) { + return { + activateSurvey: useWebMutation(ACTIVATE_SURVEY_REF), + analytics: useWebQuery(SURVEY_ANALYTICS_QUERY_REF, { surveyId }), + exportResponsesCsv: useWebMutation(EXPORT_SURVEY_RESPONSES_CSV_REF), + pauseSurvey: useWebMutation(PAUSE_SURVEY_REF), + survey: useWebQuery(SURVEY_QUERY_REF, { id: surveyId }), + updateSurvey: useWebMutation(UPDATE_SURVEY_REF), + }; +} diff --git a/apps/web/src/app/surveys/page.tsx b/apps/web/src/app/surveys/page.tsx index f5b6a44..323b33e 100644 --- a/apps/web/src/app/surveys/page.tsx +++ b/apps/web/src/app/surveys/page.tsx @@ -1,9 +1,7 @@ "use client"; import { useState } from "react"; -import { useQuery, useMutation } from "convex/react"; import { useRouter } from "next/navigation"; -import { makeFunctionReference } from "convex/server"; import { appConfirm } from "@/lib/appConfirm"; import { useAuth } from "@/contexts/AuthContext"; import { AppLayout } from "@/components/AppLayout"; @@ -11,53 +9,7 @@ import { Button, Input } from "@opencom/ui"; import { Plus, Pencil, Trash2, Copy, Play, Pause, Search, ClipboardList } from "lucide-react"; import Link from "next/link"; import type { Id } from "@opencom/convex/dataModel"; - -const surveysListQuery = makeFunctionReference< - "query", - { - workspaceId: Id<"workspaces">; - status?: "draft" | "active" | "paused" | "archived"; - }, - Array<{ - _id: Id<"surveys">; - name: string; - description?: string; - format: string; - questions: unknown[]; - status: "draft" | "active" | "paused" | "archived"; - createdAt: number; - }> ->("surveys:list"); - -const createSurveyRef = makeFunctionReference< - "mutation", - { workspaceId: Id<"workspaces">; name: string; format: string }, - Id<"surveys"> ->("surveys:create"); - -const deleteSurveyRef = makeFunctionReference< - "mutation", - { id: Id<"surveys"> }, - null ->("surveys:remove"); - -const activateSurveyRef = makeFunctionReference< - "mutation", - { id: Id<"surveys"> }, - null ->("surveys:activate"); - -const pauseSurveyRef = makeFunctionReference< - "mutation", - { id: Id<"surveys"> }, - null ->("surveys:pause"); - -const duplicateSurveyRef = makeFunctionReference< - "mutation", - { id: Id<"surveys"> }, - Id<"surveys"> | null ->("surveys:duplicate"); +import { useSurveysPageConvex } from "./hooks/useSurveysConvex"; function SurveysContent() { const router = useRouter(); @@ -66,22 +18,11 @@ function SurveysContent() { const [statusFilter, setStatusFilter] = useState< "all" | "draft" | "active" | "paused" | "archived" >("all"); - - const surveys = useQuery( - surveysListQuery, - activeWorkspace?._id - ? { - workspaceId: activeWorkspace._id, - status: statusFilter === "all" ? undefined : statusFilter, - } - : "skip" - ); - - const createSurvey = useMutation(createSurveyRef); - const deleteSurvey = useMutation(deleteSurveyRef); - const activateSurvey = useMutation(activateSurveyRef); - const pauseSurvey = useMutation(pauseSurveyRef); - const duplicateSurvey = useMutation(duplicateSurveyRef); + const { activateSurvey, createSurvey, deleteSurvey, duplicateSurvey, pauseSurvey, surveys } = + useSurveysPageConvex( + activeWorkspace?._id, + statusFilter === "all" ? undefined : statusFilter + ); const handleCreate = async () => { if (!activeWorkspace?._id) return; diff --git a/apps/web/src/app/tickets/[id]/page.tsx b/apps/web/src/app/tickets/[id]/page.tsx index 7e7ca5f..498f08d 100644 --- a/apps/web/src/app/tickets/[id]/page.tsx +++ b/apps/web/src/app/tickets/[id]/page.tsx @@ -1,8 +1,6 @@ "use client"; import { useState } from "react"; -import { useQuery, useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { Button, Card, Input } from "@opencom/ui"; import { ArrowLeft, @@ -21,92 +19,11 @@ import { AppLayout } from "@/components/AppLayout"; import { formatVisitorIdentityLabel } from "@/lib/visitorIdentity"; import Link from "next/link"; import { useParams } from "next/navigation"; +import { useTicketDetailConvex } from "../hooks/useTicketsConvex"; type TicketStatus = "submitted" | "in_progress" | "waiting_on_customer" | "resolved"; type TicketPriority = "low" | "normal" | "high" | "urgent"; -type TicketCommentRecord = { - _id: Id<"ticketComments">; - authorType: "agent" | "visitor" | "system"; - content: string; - isInternal: boolean; - createdAt: number; -}; - -type TicketDetailRecord = { - _id: Id<"tickets">; - visitorId?: Id<"visitors">; - assigneeId?: Id<"users">; - conversationId?: Id<"conversations">; - subject: string; - description?: string; - status: TicketStatus; - priority: TicketPriority; - createdAt: number; - updatedAt: number; - resolvedAt?: number; - resolutionSummary?: string; - visitor?: { _id: Id<"visitors">; readableId?: string; name?: string; email?: string } | null; - assignee?: { _id: Id<"users">; name?: string; email?: string } | null; - conversation?: { _id: Id<"conversations"> } | null; - comments?: TicketCommentRecord[]; -}; - -type TicketDetailResult = - | { status: "ok"; ticket: TicketDetailRecord } - | { status: "not_found" | "unauthenticated" | "forbidden"; ticket: null }; - -type WorkspaceUserRecord = { - _id: Id<"workspaceMembers">; - userId: Id<"users">; - name?: string; - email?: string; -}; - -const ticketDetailQueryRef = makeFunctionReference< - "query", - { id: Id<"tickets"> }, - TicketDetailResult ->("tickets:getForAdminView"); - -const workspaceUsersQueryRef = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - WorkspaceUserRecord[] ->("workspaceMembers:listByWorkspace"); - -const updateTicketMutationRef = makeFunctionReference< - "mutation", - { - id: Id<"tickets">; - status?: TicketStatus; - priority?: TicketPriority; - assigneeId?: Id<"users">; - teamId?: string; - }, - Id<"tickets"> ->("tickets:update"); - -const addCommentMutationRef = makeFunctionReference< - "mutation", - { - ticketId: Id<"tickets">; - visitorId?: Id<"visitors">; - content: string; - isInternal?: boolean; - authorId?: string; - authorType?: "agent" | "visitor" | "system"; - sessionToken?: string; - }, - Id<"ticketComments"> ->("tickets:addComment"); - -const resolveTicketMutationRef = makeFunctionReference< - "mutation", - { id: Id<"tickets">; resolutionSummary?: string }, - Id<"tickets"> ->("tickets:resolve"); - const statusConfig: Record< TicketStatus, { label: string; color: string; icon: React.ElementType } @@ -137,19 +54,8 @@ function TicketDetailContent(): React.JSX.Element | null { const [isInternal, setIsInternal] = useState(false); const [showResolveModal, setShowResolveModal] = useState(false); const [resolutionSummary, setResolutionSummary] = useState(""); - - const ticketResult = useQuery(ticketDetailQueryRef, ticketId ? { id: ticketId } : "skip") as - | TicketDetailResult - | undefined; - - const workspaceUsers = useQuery( - workspaceUsersQueryRef, - activeWorkspace?._id ? { workspaceId: activeWorkspace._id } : "skip" - ) as WorkspaceUserRecord[] | undefined; - - const updateTicket = useMutation(updateTicketMutationRef); - const addComment = useMutation(addCommentMutationRef); - const resolveTicket = useMutation(resolveTicketMutationRef); + const { addComment, resolveTicket, ticketResult, updateTicket, workspaceUsers } = + useTicketDetailConvex(ticketId, activeWorkspace?._id); const handleStatusChange = async (newStatus: TicketStatus) => { if (!ticketId) return; diff --git a/apps/web/src/app/tickets/forms/page.tsx b/apps/web/src/app/tickets/forms/page.tsx index 6e45891..9ab3860 100644 --- a/apps/web/src/app/tickets/forms/page.tsx +++ b/apps/web/src/app/tickets/forms/page.tsx @@ -1,8 +1,6 @@ "use client"; import { useState, useEffect } from "react"; -import { useQuery, useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { appConfirm } from "@/lib/appConfirm"; import { Button, Input } from "@opencom/ui"; import { @@ -23,6 +21,7 @@ import { useAuth } from "@/contexts/AuthContext"; import { AppLayout } from "@/components/AppLayout"; import Link from "next/link"; import type { Id } from "@opencom/convex/dataModel"; +import { useTicketFormEditorConvex, useTicketFormsPageConvex } from "../hooks/useTicketsConvex"; type FieldType = "text" | "textarea" | "select" | "multi-select" | "number" | "date"; @@ -35,56 +34,6 @@ interface FormField { options?: string[]; } -type TicketFormRecord = { - _id: Id<"ticketForms">; - name: string; - description?: string; - fields: FormField[]; - isDefault: boolean; -}; - -const ticketFormsListQueryRef = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - TicketFormRecord[] ->("ticketForms:list"); - -const ticketFormGetQueryRef = makeFunctionReference< - "query", - { id: Id<"ticketForms"> }, - TicketFormRecord | null ->("ticketForms:get"); - -const createTicketFormMutationRef = makeFunctionReference< - "mutation", - { - workspaceId: Id<"workspaces">; - name: string; - description?: string; - fields: FormField[]; - isDefault?: boolean; - }, - Id<"ticketForms"> ->("ticketForms:create"); - -const updateTicketFormMutationRef = makeFunctionReference< - "mutation", - { - id: Id<"ticketForms">; - name?: string; - description?: string; - fields?: FormField[]; - isDefault?: boolean; - }, - Id<"ticketForms"> ->("ticketForms:update"); - -const deleteTicketFormMutationRef = makeFunctionReference< - "mutation", - { id: Id<"ticketForms"> }, - null ->("ticketForms:remove"); - const fieldTypeConfig: Record = { text: { label: "Short Text", icon: Type }, textarea: { label: "Long Text", icon: AlignLeft }, @@ -102,14 +51,7 @@ function TicketFormsContent(): React.JSX.Element | null { const { user, activeWorkspace } = useAuth(); const [selectedFormId, setSelectedFormId] = useState | null>(null); const [isCreating, setIsCreating] = useState(false); - - const forms = useQuery( - ticketFormsListQueryRef, - activeWorkspace?._id ? { workspaceId: activeWorkspace._id } : "skip" - ) as TicketFormRecord[] | undefined; - - const createForm = useMutation(createTicketFormMutationRef); - const deleteForm = useMutation(deleteTicketFormMutationRef); + const { createForm, deleteForm, forms } = useTicketFormsPageConvex(activeWorkspace?._id); const handleCreateForm = async () => { if (!activeWorkspace?._id) return; @@ -244,8 +186,7 @@ function FormEditor({ formId: Id<"ticketForms">; onDelete: () => void; }): React.JSX.Element | null { - const form = useQuery(ticketFormGetQueryRef, { id: formId }) as TicketFormRecord | null | undefined; - const updateForm = useMutation(updateTicketFormMutationRef); + const { form, updateForm } = useTicketFormEditorConvex(formId); const [name, setName] = useState(""); const [description, setDescription] = useState(""); diff --git a/apps/web/src/app/tickets/hooks/useTicketsConvex.ts b/apps/web/src/app/tickets/hooks/useTicketsConvex.ts new file mode 100644 index 0000000..231831e --- /dev/null +++ b/apps/web/src/app/tickets/hooks/useTicketsConvex.ts @@ -0,0 +1,270 @@ +"use client"; + +import type { Id } from "@opencom/convex/dataModel"; +import { + useWebMutation, + useWebQuery, + webMutationRef, + webQueryRef, +} from "@/lib/convex/hooks"; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type TicketStatus = "submitted" | "in_progress" | "waiting_on_customer" | "resolved"; +type TicketPriority = "low" | "normal" | "high" | "urgent"; + +type TicketArgs = { + id: Id<"tickets">; +}; + +type TicketFormArgs = { + id: Id<"ticketForms">; +}; + +const VISITORS_SEARCH_QUERY_REF = webQueryRef< + WorkspaceArgs & { query: string; limit?: number }, + Array<{ + _id: Id<"visitors">; + readableId?: string; + name?: string; + email?: string; + }> +>("visitors:search"); +const VISITORS_LIST_QUERY_REF = webQueryRef< + WorkspaceArgs & { limit?: number }, + Array<{ + _id: Id<"visitors">; + readableId?: string; + name?: string; + email?: string; + }> +>("visitors:list"); +const TICKETS_LIST_FOR_ADMIN_VIEW_QUERY_REF = webQueryRef< + WorkspaceArgs & { status?: TicketStatus }, + | { + status: "ok"; + tickets: Array<{ + _id: Id<"tickets">; + visitorId?: Id<"visitors">; + assigneeId?: Id<"users">; + subject: string; + description?: string; + status: TicketStatus; + priority: TicketPriority; + createdAt: number; + visitor?: { + _id: Id<"visitors">; + readableId?: string; + name?: string; + email?: string; + } | null; + assignee?: { _id: Id<"users">; name?: string; email?: string } | null; + }>; + } + | { status: "unauthenticated" | "forbidden"; tickets: [] } +>("tickets:listForAdminView"); +const CREATE_TICKET_REF = webMutationRef< + WorkspaceArgs & { + visitorId?: Id<"visitors">; + subject: string; + description?: string; + priority?: TicketPriority; + }, + Id<"tickets"> +>("tickets:create"); +const TICKET_FORMS_LIST_QUERY_REF = webQueryRef< + WorkspaceArgs, + Array<{ + _id: Id<"ticketForms">; + name: string; + description?: string; + fields: Array<{ + id: string; + type: "text" | "textarea" | "select" | "multi-select" | "number" | "date"; + label: string; + placeholder?: string; + required: boolean; + options?: string[]; + }>; + isDefault: boolean; + }> +>("ticketForms:list"); +const TICKET_FORM_GET_QUERY_REF = webQueryRef< + TicketFormArgs, + { + _id: Id<"ticketForms">; + name: string; + description?: string; + fields: Array<{ + id: string; + type: "text" | "textarea" | "select" | "multi-select" | "number" | "date"; + label: string; + placeholder?: string; + required: boolean; + options?: string[]; + }>; + isDefault: boolean; + } | null +>("ticketForms:get"); +const CREATE_TICKET_FORM_REF = webMutationRef< + WorkspaceArgs & { + name: string; + description?: string; + fields: Array<{ + id: string; + type: "text" | "textarea" | "select" | "multi-select" | "number" | "date"; + label: string; + placeholder?: string; + required: boolean; + options?: string[]; + }>; + isDefault?: boolean; + }, + Id<"ticketForms"> +>("ticketForms:create"); +const UPDATE_TICKET_FORM_REF = webMutationRef< + { + id: Id<"ticketForms">; + name?: string; + description?: string; + fields?: Array<{ + id: string; + type: "text" | "textarea" | "select" | "multi-select" | "number" | "date"; + label: string; + placeholder?: string; + required: boolean; + options?: string[]; + }>; + isDefault?: boolean; + }, + Id<"ticketForms"> +>("ticketForms:update"); +const DELETE_TICKET_FORM_REF = webMutationRef("ticketForms:remove"); +const TICKET_DETAIL_QUERY_REF = webQueryRef< + TicketArgs, + | { + status: "ok"; + ticket: { + _id: Id<"tickets">; + visitorId?: Id<"visitors">; + assigneeId?: Id<"users">; + conversationId?: Id<"conversations">; + subject: string; + description?: string; + status: TicketStatus; + priority: TicketPriority; + createdAt: number; + updatedAt: number; + resolvedAt?: number; + resolutionSummary?: string; + visitor?: { + _id: Id<"visitors">; + readableId?: string; + name?: string; + email?: string; + } | null; + assignee?: { _id: Id<"users">; name?: string; email?: string } | null; + conversation?: { _id: Id<"conversations"> } | null; + comments?: Array<{ + _id: Id<"ticketComments">; + authorType: "agent" | "visitor" | "system"; + content: string; + isInternal: boolean; + createdAt: number; + }>; + }; + } + | { status: "not_found" | "unauthenticated" | "forbidden"; ticket: null } +>("tickets:getForAdminView"); +const WORKSPACE_USERS_QUERY_REF = webQueryRef< + WorkspaceArgs, + Array<{ + _id: Id<"workspaceMembers">; + userId: Id<"users">; + name?: string; + email?: string; + }> +>("workspaceMembers:listByWorkspace"); +const UPDATE_TICKET_REF = webMutationRef< + { + id: Id<"tickets">; + status?: TicketStatus; + priority?: TicketPriority; + assigneeId?: Id<"users">; + teamId?: string; + }, + Id<"tickets"> +>("tickets:update"); +const ADD_COMMENT_REF = webMutationRef< + { + ticketId: Id<"tickets">; + visitorId?: Id<"visitors">; + content: string; + isInternal?: boolean; + authorId?: string; + authorType?: "agent" | "visitor" | "system"; + sessionToken?: string; + }, + Id<"ticketComments"> +>("tickets:addComment"); +const RESOLVE_TICKET_REF = webMutationRef< + { id: Id<"tickets">; resolutionSummary?: string }, + Id<"tickets"> +>("tickets:resolve"); + +export function useTicketsPageConvex( + workspaceId?: Id<"workspaces"> | null, + status?: TicketStatus, + visitorSearchQuery?: string +) { + return { + createTicket: useWebMutation(CREATE_TICKET_REF), + recentVisitors: useWebQuery( + VISITORS_LIST_QUERY_REF, + workspaceId && !visitorSearchQuery ? { workspaceId, limit: 10 } : "skip" + ), + tickets: useWebQuery( + TICKETS_LIST_FOR_ADMIN_VIEW_QUERY_REF, + workspaceId ? { workspaceId, ...(status ? { status } : {}) } : "skip" + ), + visitors: useWebQuery( + VISITORS_SEARCH_QUERY_REF, + workspaceId && visitorSearchQuery && visitorSearchQuery.length >= 2 + ? { workspaceId, query: visitorSearchQuery, limit: 10 } + : "skip" + ), + }; +} + +export function useTicketFormsPageConvex(workspaceId?: Id<"workspaces"> | null) { + return { + createForm: useWebMutation(CREATE_TICKET_FORM_REF), + deleteForm: useWebMutation(DELETE_TICKET_FORM_REF), + forms: useWebQuery(TICKET_FORMS_LIST_QUERY_REF, workspaceId ? { workspaceId } : "skip"), + }; +} + +export function useTicketFormEditorConvex(formId: Id<"ticketForms">) { + return { + form: useWebQuery(TICKET_FORM_GET_QUERY_REF, { id: formId }), + updateForm: useWebMutation(UPDATE_TICKET_FORM_REF), + }; +} + +export function useTicketDetailConvex( + ticketId: Id<"tickets">, + workspaceId?: Id<"workspaces"> | null +) { + return { + addComment: useWebMutation(ADD_COMMENT_REF), + resolveTicket: useWebMutation(RESOLVE_TICKET_REF), + ticketResult: useWebQuery(TICKET_DETAIL_QUERY_REF, ticketId ? { id: ticketId } : "skip"), + updateTicket: useWebMutation(UPDATE_TICKET_REF), + workspaceUsers: useWebQuery( + WORKSPACE_USERS_QUERY_REF, + workspaceId ? { workspaceId } : "skip" + ), + }; +} diff --git a/apps/web/src/app/tickets/page.tsx b/apps/web/src/app/tickets/page.tsx index 522fd2d..660da42 100644 --- a/apps/web/src/app/tickets/page.tsx +++ b/apps/web/src/app/tickets/page.tsx @@ -1,8 +1,6 @@ "use client"; import { useState } from "react"; -import { useQuery, useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { Button, Card, Input } from "@opencom/ui"; import { Ticket, @@ -21,64 +19,11 @@ import { AppLayout, AppPageShell } from "@/components/AppLayout"; import { formatVisitorIdentityLabel } from "@/lib/visitorIdentity"; import Link from "next/link"; import type { Id } from "@opencom/convex/dataModel"; +import { useTicketsPageConvex } from "./hooks/useTicketsConvex"; type TicketStatus = "submitted" | "in_progress" | "waiting_on_customer" | "resolved"; type TicketPriority = "low" | "normal" | "high" | "urgent"; -type VisitorOption = { - _id: Id<"visitors">; - readableId?: string; - name?: string; - email?: string; -}; - -type AdminTicketRecord = { - _id: Id<"tickets">; - visitorId?: Id<"visitors">; - assigneeId?: Id<"users">; - subject: string; - description?: string; - status: TicketStatus; - priority: TicketPriority; - createdAt: number; - visitor?: VisitorOption | null; - assignee?: { _id: Id<"users">; name?: string; email?: string } | null; -}; - -type TicketsListForAdminViewResult = - | { status: "ok"; tickets: AdminTicketRecord[] } - | { status: "unauthenticated" | "forbidden"; tickets: [] }; - -const visitorsSearchQueryRef = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces">; query: string; limit?: number }, - VisitorOption[] ->("visitors:search"); - -const visitorsListQueryRef = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces">; limit?: number }, - VisitorOption[] ->("visitors:list"); - -const ticketsListForAdminViewQueryRef = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces">; status?: TicketStatus }, - TicketsListForAdminViewResult ->("tickets:listForAdminView"); - -const createTicketMutationRef = makeFunctionReference< - "mutation", - { - workspaceId: Id<"workspaces">; - visitorId?: Id<"visitors">; - subject: string; - description?: string; - priority?: TicketPriority; - }, - Id<"tickets"> ->("tickets:create"); - const statusConfig: Record< TicketStatus, { label: string; color: string; icon: React.ElementType } @@ -106,32 +51,11 @@ function TicketsContent(): React.JSX.Element | null { const [newTicketPriority, setNewTicketPriority] = useState("normal"); const [selectedVisitorId, setSelectedVisitorId] = useState | null>(null); const [visitorSearchQuery, setVisitorSearchQuery] = useState(""); - - const visitors = useQuery( - visitorsSearchQueryRef, - activeWorkspace?._id && visitorSearchQuery.length >= 2 - ? { workspaceId: activeWorkspace._id, query: visitorSearchQuery, limit: 10 } - : "skip" - ) as VisitorOption[] | undefined; - - const recentVisitors = useQuery( - visitorsListQueryRef, - activeWorkspace?._id && !visitorSearchQuery - ? { workspaceId: activeWorkspace._id, limit: 10 } - : "skip" - ) as VisitorOption[] | undefined; - - const tickets = useQuery( - ticketsListForAdminViewQueryRef, - activeWorkspace?._id - ? { - workspaceId: activeWorkspace._id, - ...(statusFilter !== "all" && { status: statusFilter }), - } - : "skip" - ) as TicketsListForAdminViewResult | undefined; - - const createTicket = useMutation(createTicketMutationRef); + const { createTicket, recentVisitors, tickets, visitors } = useTicketsPageConvex( + activeWorkspace?._id, + statusFilter === "all" ? undefined : statusFilter, + visitorSearchQuery + ); const handleCreateTicket = async () => { if (!activeWorkspace?._id || !newTicketSubject.trim()) return; diff --git a/apps/web/src/app/tours/[id]/page.tsx b/apps/web/src/app/tours/[id]/page.tsx index 0513f84..8ba3bf5 100644 --- a/apps/web/src/app/tours/[id]/page.tsx +++ b/apps/web/src/app/tours/[id]/page.tsx @@ -2,8 +2,6 @@ import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; -import { useQuery, useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { appConfirm } from "@/lib/appConfirm"; import { Button, Input } from "@opencom/ui"; import { @@ -20,140 +18,16 @@ import type { AudienceRule } from "@/components/AudienceRuleBuilder"; import { TourEditorSettingsPanel } from "./TourEditorSettingsPanel"; import { TourEditorStepsPanel } from "./TourEditorStepsPanel"; import { TourStepModal } from "./TourStepModal"; +import { useTourEditorConvex } from "../hooks/useToursConvex"; import { createDefaultStepData, getNormalizedStepSaveData, toStepFormData, - type SelectorQuality, type StepFormData, type TourDisplayMode, type TourEditorStep, } from "./tourEditorTypes"; -type TourRecord = { - _id: Id<"tours">; - workspaceId: Id<"workspaces">; - name: string; - description?: string; - status: "draft" | "active" | "archived"; - targetingRules?: { pageUrl?: string }; - audienceRules?: AudienceRule | null; - displayMode?: TourDisplayMode; - priority?: number; - buttonColor?: string; - showConfetti?: boolean; - allowSnooze?: boolean; - allowRestart?: boolean; -}; - -type TourStepRecord = TourEditorStep; - -const tourGetQueryRef = makeFunctionReference< - "query", - { id: Id<"tours"> }, - TourRecord | null ->("tours:get"); - -const tourStepsListQueryRef = makeFunctionReference< - "query", - { tourId: Id<"tours"> }, - TourStepRecord[] ->("tourSteps:list"); - -const eventNamesQueryRef = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - string[] ->("events:getDistinctNames"); - -const updateTourMutationRef = makeFunctionReference< - "mutation", - { - id: Id<"tours">; - name?: string; - description?: string; - targetingRules?: { pageUrl?: string }; - audienceRules?: AudienceRule | null; - displayMode?: TourDisplayMode; - priority?: number; - buttonColor?: string; - showConfetti?: boolean; - allowSnooze?: boolean; - allowRestart?: boolean; - }, - null ->("tours:update"); - -const activateTourMutationRef = makeFunctionReference< - "mutation", - { id: Id<"tours"> }, - null ->("tours:activate"); - -const deactivateTourMutationRef = makeFunctionReference< - "mutation", - { id: Id<"tours"> }, - null ->("tours:deactivate"); - -const createStepMutationRef = makeFunctionReference< - "mutation", - { - tourId: Id<"tours">; - type: StepFormData["type"]; - title?: string; - content: string; - elementSelector?: string; - routePath?: string; - selectorQuality?: SelectorQuality; - position: StepFormData["position"]; - size: StepFormData["size"]; - advanceOn: StepFormData["advanceOn"]; - customButtonText?: string; - mediaUrl?: string; - mediaType?: StepFormData["mediaType"]; - }, - Id<"tourSteps"> ->("tourSteps:create"); - -const updateStepMutationRef = makeFunctionReference< - "mutation", - { - id: Id<"tourSteps">; - type?: StepFormData["type"]; - title?: string; - content?: string; - elementSelector?: string; - routePath?: string; - selectorQuality?: SelectorQuality; - position?: StepFormData["position"]; - size?: StepFormData["size"]; - advanceOn?: StepFormData["advanceOn"]; - customButtonText?: string; - mediaUrl?: string; - mediaType?: StepFormData["mediaType"]; - }, - null ->("tourSteps:update"); - -const deleteStepMutationRef = makeFunctionReference< - "mutation", - { id: Id<"tourSteps"> }, - null ->("tourSteps:remove"); - -const reorderStepsMutationRef = makeFunctionReference< - "mutation", - { tourId: Id<"tours">; stepIds: Id<"tourSteps">[] }, - null ->("tourSteps:reorder"); - -const createAuthoringSessionMutationRef = makeFunctionReference< - "mutation", - { tourId: Id<"tours">; stepId?: Id<"tourSteps">; targetUrl: string }, - { token: string } ->("authoringSessions:create"); - export default function TourEditorPage() { const params = useParams(); const tourId = params.id as Id<"tours">; @@ -177,27 +51,19 @@ export default function TourEditorPage() { const { user, isLoading: authLoading } = useAuth(); const canQueryTourData = !authLoading && !!user; - - const tour = useQuery(tourGetQueryRef, canQueryTourData ? { id: tourId } : "skip") as - | TourRecord - | null - | undefined; - const steps = useQuery(tourStepsListQueryRef, canQueryTourData ? { tourId } : "skip") as - | TourStepRecord[] - | undefined; - const eventNames = useQuery( - eventNamesQueryRef, - canQueryTourData && tour?.workspaceId ? { workspaceId: tour.workspaceId } : "skip" - ) as string[] | undefined; - - const updateTour = useMutation(updateTourMutationRef); - const activateTour = useMutation(activateTourMutationRef); - const deactivateTour = useMutation(deactivateTourMutationRef); - const createStep = useMutation(createStepMutationRef); - const updateStep = useMutation(updateStepMutationRef); - const deleteStep = useMutation(deleteStepMutationRef); - const reorderSteps = useMutation(reorderStepsMutationRef); - const createAuthoringSession = useMutation(createAuthoringSessionMutationRef); + const { + activateTour, + createAuthoringSession, + createStep, + deactivateTour, + deleteStep, + eventNames, + reorderSteps, + steps, + tour, + updateStep, + updateTour, + } = useTourEditorConvex(tourId, canQueryTourData); useEffect(() => { if (tour) { @@ -318,13 +184,13 @@ export default function TourEditorPage() { const handleMoveStep = async (stepId: Id<"tourSteps">, direction: "up" | "down") => { if (!steps) return; - const currentIndex = steps.findIndex((s: TourStepRecord) => s._id === stepId); + const currentIndex = steps.findIndex((s: TourEditorStep) => s._id === stepId); if (currentIndex === -1) return; const newIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1; if (newIndex < 0 || newIndex >= steps.length) return; - const newOrder = [...steps.map((s: TourStepRecord) => s._id)]; + const newOrder = [...steps.map((s: TourEditorStep) => s._id)]; [newOrder[currentIndex], newOrder[newIndex]] = [newOrder[newIndex], newOrder[currentIndex]]; await reorderSteps({ tourId, stepIds: newOrder }); diff --git a/apps/web/src/app/tours/hooks/useToursConvex.ts b/apps/web/src/app/tours/hooks/useToursConvex.ts new file mode 100644 index 0000000..30089ae --- /dev/null +++ b/apps/web/src/app/tours/hooks/useToursConvex.ts @@ -0,0 +1,171 @@ +"use client"; + +import type { Id } from "@opencom/convex/dataModel"; +import type { AudienceRule } from "@/components/AudienceRuleBuilder"; +import type { + SelectorQuality, + StepFormData, + TourDisplayMode, + TourEditorStep, +} from "../[id]/tourEditorTypes"; +import { + useWebMutation, + useWebQuery, + webMutationRef, + webQueryRef, +} from "@/lib/convex/hooks"; + +type TourStatus = "draft" | "active" | "archived"; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type TourArgs = { + id: Id<"tours">; +}; + +type TourIdArgs = { + tourId: Id<"tours">; +}; + +const LIST_TOURS_QUERY_REF = webQueryRef< + WorkspaceArgs & { status?: TourStatus }, + Array<{ + _id: Id<"tours">; + name: string; + description?: string; + status: TourStatus; + createdAt: number; + }> +>("tours:list"); +const CREATE_TOUR_REF = webMutationRef< + WorkspaceArgs & { name: string }, + Id<"tours"> +>("tours:create"); +const DELETE_TOUR_REF = webMutationRef("tours:remove"); +const ACTIVATE_TOUR_REF = webMutationRef("tours:activate"); +const DEACTIVATE_TOUR_REF = webMutationRef("tours:deactivate"); +const DUPLICATE_TOUR_REF = webMutationRef>("tours:duplicate"); +const TOUR_GET_QUERY_REF = webQueryRef< + TourArgs, + { + _id: Id<"tours">; + workspaceId: Id<"workspaces">; + name: string; + description?: string; + status: TourStatus; + targetingRules?: { pageUrl?: string }; + audienceRules?: AudienceRule | null; + displayMode?: TourDisplayMode; + priority?: number; + buttonColor?: string; + showConfetti?: boolean; + allowSnooze?: boolean; + allowRestart?: boolean; + } | null +>("tours:get"); +const TOUR_STEPS_LIST_QUERY_REF = webQueryRef("tourSteps:list"); +const EVENT_NAMES_QUERY_REF = webQueryRef("events:getDistinctNames"); +const UPDATE_TOUR_REF = webMutationRef< + { + id: Id<"tours">; + name?: string; + description?: string; + targetingRules?: { pageUrl?: string }; + audienceRules?: AudienceRule | null; + displayMode?: TourDisplayMode; + priority?: number; + buttonColor?: string; + showConfetti?: boolean; + allowSnooze?: boolean; + allowRestart?: boolean; + }, + null +>("tours:update"); +const CREATE_STEP_REF = webMutationRef< + { + tourId: Id<"tours">; + type: StepFormData["type"]; + title?: string; + content: string; + elementSelector?: string; + routePath?: string; + selectorQuality?: SelectorQuality; + position: StepFormData["position"]; + size: StepFormData["size"]; + advanceOn: StepFormData["advanceOn"]; + customButtonText?: string; + mediaUrl?: string; + mediaType?: StepFormData["mediaType"]; + }, + Id<"tourSteps"> +>("tourSteps:create"); +const UPDATE_STEP_REF = webMutationRef< + { + id: Id<"tourSteps">; + type?: StepFormData["type"]; + title?: string; + content?: string; + elementSelector?: string; + routePath?: string; + selectorQuality?: SelectorQuality; + position?: StepFormData["position"]; + size?: StepFormData["size"]; + advanceOn?: StepFormData["advanceOn"]; + customButtonText?: string; + mediaUrl?: string; + mediaType?: StepFormData["mediaType"]; + }, + null +>("tourSteps:update"); +const DELETE_STEP_REF = webMutationRef<{ id: Id<"tourSteps"> }, null>("tourSteps:remove"); +const REORDER_STEPS_REF = webMutationRef< + { tourId: Id<"tours">; stepIds: Id<"tourSteps">[] }, + null +>("tourSteps:reorder"); +const CREATE_AUTHORING_SESSION_REF = webMutationRef< + { tourId: Id<"tours">; stepId?: Id<"tourSteps">; targetUrl: string }, + { token: string } +>("authoringSessions:create"); + +export function useToursPageConvex( + workspaceId?: Id<"workspaces"> | null, + status?: TourStatus +) { + return { + activateTour: useWebMutation(ACTIVATE_TOUR_REF), + createTour: useWebMutation(CREATE_TOUR_REF), + deactivateTour: useWebMutation(DEACTIVATE_TOUR_REF), + deleteTour: useWebMutation(DELETE_TOUR_REF), + duplicateTour: useWebMutation(DUPLICATE_TOUR_REF), + tours: useWebQuery( + LIST_TOURS_QUERY_REF, + workspaceId ? { workspaceId, status } : "skip" + ), + }; +} + +export function useTourEditorConvex( + tourId: Id<"tours">, + canQueryTourData: boolean +) { + const tour = useWebQuery(TOUR_GET_QUERY_REF, canQueryTourData ? { id: tourId } : "skip"); + + return { + activateTour: useWebMutation(ACTIVATE_TOUR_REF), + createAuthoringSession: useWebMutation(CREATE_AUTHORING_SESSION_REF), + createStep: useWebMutation(CREATE_STEP_REF), + deactivateTour: useWebMutation(DEACTIVATE_TOUR_REF), + deleteStep: useWebMutation(DELETE_STEP_REF), + eventNames: useWebQuery( + EVENT_NAMES_QUERY_REF, + canQueryTourData && tour?.workspaceId ? { workspaceId: tour.workspaceId } : "skip" + ), + reorderSteps: useWebMutation(REORDER_STEPS_REF), + steps: useWebQuery(TOUR_STEPS_LIST_QUERY_REF, canQueryTourData ? { tourId } : "skip"), + tour, + updateStep: useWebMutation(UPDATE_STEP_REF), + updateTour: useWebMutation(UPDATE_TOUR_REF), + }; +} diff --git a/apps/web/src/app/tours/page.tsx b/apps/web/src/app/tours/page.tsx index 8261893..4f670d6 100644 --- a/apps/web/src/app/tours/page.tsx +++ b/apps/web/src/app/tours/page.tsx @@ -1,9 +1,7 @@ "use client"; import { useState } from "react"; -import { useQuery, useMutation } from "convex/react"; import { useRouter } from "next/navigation"; -import { makeFunctionReference } from "convex/server"; import { appConfirm } from "@/lib/appConfirm"; import { useAuth } from "@/contexts/AuthContext"; import { AppLayout } from "@/components/AppLayout"; @@ -11,6 +9,7 @@ import { Button, Input } from "@opencom/ui"; import { Plus, Pencil, Trash2, Copy, Play, Pause, Search, Route } from "lucide-react"; import Link from "next/link"; import type { Id } from "@opencom/convex/dataModel"; +import { useToursPageConvex } from "./hooks/useToursConvex"; type TourRecord = { _id: Id<"tours">; @@ -20,63 +19,16 @@ type TourRecord = { createdAt: number; }; -const listToursQueryRef = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces">; status?: "draft" | "active" | "archived" }, - TourRecord[] ->("tours:list"); - -const createTourMutationRef = makeFunctionReference< - "mutation", - { workspaceId: Id<"workspaces">; name: string }, - Id<"tours"> ->("tours:create"); - -const deleteTourMutationRef = makeFunctionReference< - "mutation", - { id: Id<"tours"> }, - null ->("tours:remove"); - -const activateTourMutationRef = makeFunctionReference< - "mutation", - { id: Id<"tours"> }, - null ->("tours:activate"); - -const deactivateTourMutationRef = makeFunctionReference< - "mutation", - { id: Id<"tours"> }, - null ->("tours:deactivate"); - -const duplicateTourMutationRef = makeFunctionReference< - "mutation", - { id: Id<"tours"> }, - Id<"tours"> ->("tours:duplicate"); - function ToursContent() { const router = useRouter(); const { activeWorkspace } = useAuth(); const [searchQuery, setSearchQuery] = useState(""); const [statusFilter, setStatusFilter] = useState<"all" | "draft" | "active" | "archived">("all"); - - const tours = useQuery( - listToursQueryRef, - activeWorkspace?._id - ? { - workspaceId: activeWorkspace._id, - status: statusFilter === "all" ? undefined : statusFilter, - } - : "skip" - ) as TourRecord[] | undefined; - - const createTour = useMutation(createTourMutationRef); - const deleteTour = useMutation(deleteTourMutationRef); - const activateTour = useMutation(activateTourMutationRef); - const deactivateTour = useMutation(deactivateTourMutationRef); - const duplicateTour = useMutation(duplicateTourMutationRef); + const { activateTour, createTour, deactivateTour, deleteTour, duplicateTour, tours } = + useToursPageConvex( + activeWorkspace?._id, + statusFilter === "all" ? undefined : statusFilter + ); const handleCreate = async () => { if (!activeWorkspace?._id) return; diff --git a/apps/web/src/app/typeHardeningGuard.test.ts b/apps/web/src/app/typeHardeningGuard.test.ts index 0a109b8..2191cd2 100644 --- a/apps/web/src/app/typeHardeningGuard.test.ts +++ b/apps/web/src/app/typeHardeningGuard.test.ts @@ -6,82 +6,133 @@ import { describe, expect, it } from "vitest"; const APP_DIR = dirname(fileURLToPath(import.meta.url)); const WEB_SRC_DIR = resolve(APP_DIR, ".."); -const TEAM_MEMBERS_SETTINGS_PATH = resolve(APP_DIR, "settings/useTeamMembersSettings.ts"); -const WEB_CONVEX_ADAPTER_PATH = resolve(APP_DIR, "../lib/convex/hooks.ts"); -const SETTINGS_PAGE_PATH = resolve(APP_DIR, "settings/page.tsx"); -const SETTINGS_PAGE_CONVEX_PATH = resolve(APP_DIR, "settings/hooks/useSettingsPageConvex.ts"); -const SETTINGS_PAGE_CONTROLLER_PATH = resolve(APP_DIR, "settings/hooks/useSettingsPageController.ts"); -const MESSENGER_SETTINGS_SECTION_PATH = resolve(APP_DIR, "settings/MessengerSettingsSection.tsx"); -const MESSENGER_SETTINGS_CONVEX_PATH = resolve( - APP_DIR, - "settings/hooks/useMessengerSettingsConvex.ts" -); -const INBOX_PAGE_PATH = resolve(APP_DIR, "inbox/page.tsx"); -const INBOX_CONVEX_PATH = resolve(APP_DIR, "inbox/hooks/useInboxConvex.ts"); -const ARTICLES_PAGE_PATH = resolve(APP_DIR, "articles/page.tsx"); -const ARTICLES_CONVEX_PATH = resolve(APP_DIR, "articles/hooks/useArticlesAdminConvex.ts"); -const ARTICLE_EDITOR_PAGE_PATH = resolve(APP_DIR, "articles/[id]/page.tsx"); -const ARTICLE_EDITOR_CONVEX_PATH = resolve( - APP_DIR, - "articles/hooks/useArticleEditorConvex.ts" -); -const ARTICLE_COLLECTIONS_PAGE_PATH = resolve(APP_DIR, "articles/collections/page.tsx"); -const ARTICLE_COLLECTIONS_CONVEX_PATH = resolve( - APP_DIR, - "articles/hooks/useArticleCollectionsConvex.ts" -); -const CHECKLISTS_PAGE_PATH = resolve(APP_DIR, "checklists/page.tsx"); -const CHECKLISTS_CONVEX_PATH = resolve(APP_DIR, "checklists/hooks/useChecklistsPageConvex.ts"); -const CHECKLIST_BUILDER_PAGE_PATH = resolve(APP_DIR, "checklists/[id]/page.tsx"); -const CHECKLIST_BUILDER_CONVEX_PATH = resolve( - APP_DIR, - "checklists/hooks/useChecklistBuilderConvex.ts" -); -const CAMPAIGNS_PAGE_PATH = resolve(APP_DIR, "campaigns/page.tsx"); -const CAMPAIGNS_CONVEX_PATH = resolve(APP_DIR, "campaigns/hooks/useCampaignsPageConvex.ts"); -const PUSH_CAMPAIGN_PAGE_PATH = resolve(APP_DIR, "campaigns/push/[id]/page.tsx"); -const PUSH_CAMPAIGN_CONVEX_PATH = resolve( - APP_DIR, - "campaigns/hooks/usePushCampaignEditorConvex.ts" -); -const CAROUSEL_PAGE_PATH = resolve(APP_DIR, "campaigns/carousels/[id]/page.tsx"); -const CAROUSEL_CONVEX_PATH = resolve( - APP_DIR, - "campaigns/hooks/useCarouselEditorConvex.ts" -); -const SERIES_PAGE_PATH = resolve(APP_DIR, "campaigns/series/[id]/page.tsx"); -const SERIES_CONVEX_PATH = resolve(APP_DIR, "campaigns/hooks/useSeriesEditorConvex.ts"); -const TOOLTIPS_PAGE_PATH = resolve(APP_DIR, "tooltips/page.tsx"); -const TOOLTIPS_CONVEX_PATH = resolve(APP_DIR, "tooltips/hooks/useTooltipsConvex.ts"); -const OUTBOUND_PAGE_PATH = resolve(APP_DIR, "outbound/[id]/page.tsx"); -const OUTBOUND_CONTROLLER_PATH = resolve( - APP_DIR, - "outbound/hooks/useOutboundMessageEditorController.ts" -); -const OUTBOUND_CONVEX_PATH = resolve( - APP_DIR, - "outbound/hooks/useOutboundMessageEditorConvex.ts" -); -const EMAIL_CAMPAIGN_PAGE_PATH = resolve(APP_DIR, "campaigns/email/[id]/page.tsx"); +const WEB_CONVEX_ADAPTER_PATH = resolve(WEB_SRC_DIR, "lib/convex/hooks.ts"); +const CONVEX_PROVIDER_PATH = resolve(WEB_SRC_DIR, "components/convex-provider.tsx"); +const APPROVED_TEST_BOUNDARY_FILES = [ + resolve(APP_DIR, "settings/MessengerSettingsSection.test.tsx"), + resolve(APP_DIR, "typeHardeningGuard.test.ts"), +]; +const APPROVED_DIRECT_CONVEX_BOUNDARY_FILES = [ + WEB_CONVEX_ADAPTER_PATH, + CONVEX_PROVIDER_PATH, + ...APPROVED_TEST_BOUNDARY_FILES, +]; + +const WRAPPER_LAYER_FILES = [ + "app/articles/hooks/useArticleCollectionsConvex.ts", + "app/articles/hooks/useArticleEditorConvex.ts", + "app/articles/hooks/useArticlesAdminConvex.ts", + "app/campaigns/hooks/useCampaignsPageConvex.ts", + "app/campaigns/hooks/useCarouselEditorConvex.ts", + "app/campaigns/hooks/useEmailCampaignEditorConvex.ts", + "app/campaigns/hooks/usePushCampaignEditorConvex.ts", + "app/campaigns/hooks/useSeriesEditorConvex.ts", + "app/checklists/hooks/useChecklistBuilderConvex.ts", + "app/checklists/hooks/useChecklistsPageConvex.ts", + "app/help/hooks/useHelpCenterConvex.ts", + "app/inbox/hooks/useInboxConversationListPaneConvex.ts", + "app/inbox/hooks/useInboxConvex.ts", + "app/onboarding/hooks/useOnboardingConvex.ts", + "app/outbound/hooks/useOutboundMessageEditorConvex.ts", + "app/outbound/hooks/useOutboundMessagesPageConvex.ts", + "app/reports/hooks/useReportsConvex.ts", + "app/segments/hooks/useSegmentsPageConvex.ts", + "app/settings/hooks/useMessengerSettingsConvex.ts", + "app/settings/hooks/useSettingsPageConvex.ts", + "app/settings/hooks/useSettingsSectionsConvex.ts", + "app/snippets/hooks/useSnippetsPageConvex.ts", + "app/surveys/hooks/useSurveysConvex.ts", + "app/tickets/hooks/useTicketsConvex.ts", + "app/tooltips/hooks/useTooltipsConvex.ts", + "app/tours/hooks/useToursConvex.ts", + "app/visitors/hooks/useVisitorsConvex.ts", + "components/hooks/useAppSidebarConvex.ts", + "components/hooks/useAudienceRuleBuilderConvex.ts", + "components/hooks/useSuggestionsPanelConvex.ts", + "components/hooks/useWorkspaceSelectorConvex.ts", + "contexts/hooks/useAuthConvex.ts", +].map((path) => resolve(WEB_SRC_DIR, path)); + +const MIGRATED_WEB_CONSUMERS = [ + ["app/articles/[id]/page.tsx", "useArticleEditorConvex"], + ["app/articles/collections/page.tsx", "useArticleCollectionsConvex"], + ["app/articles/page.tsx", "useArticlesAdminConvex"], + ["app/campaigns/carousels/[id]/page.tsx", "useCarouselEditorConvex"], + ["app/campaigns/email/[id]/page.tsx", "useEmailCampaignEditorConvex"], + ["app/campaigns/page.tsx", "useCampaignsPageConvex"], + ["app/campaigns/push/[id]/page.tsx", "usePushCampaignEditorConvex"], + ["app/campaigns/series/[id]/page.tsx", "useSeriesEditorConvex"], + ["app/checklists/[id]/page.tsx", "useChecklistBuilderConvex"], + ["app/checklists/page.tsx", "useChecklistsPageConvex"], + ["app/help/[slug]/page.tsx", "useHelpArticlePageConvex"], + ["app/help/page.tsx", "useHelpCenterPageConvex"], + ["app/inbox/InboxConversationListPane.tsx", "useInboxConversationListPaneConvex"], + ["app/inbox/page.tsx", "useInboxConvex"], + ["app/onboarding/page.tsx", "useOnboardingConvex"], + ["app/outbound/[id]/page.tsx", "useOutboundMessageEditorController"], + ["app/outbound/page.tsx", "useOutboundMessagesPageConvex"], + ["app/reports/ai/page.tsx", "useAiReportConvex"], + ["app/reports/conversations/page.tsx", "useConversationsReportConvex"], + ["app/reports/csat/page.tsx", "useCsatReportConvex"], + ["app/reports/page.tsx", "useReportsPageConvex"], + ["app/reports/team/page.tsx", "useTeamReportConvex"], + ["app/segments/page.tsx", "useSegmentsListConvex"], + ["app/settings/AIAgentSection.tsx", "useAIAgentSectionConvex"], + ["app/settings/AuditLogViewer.tsx", "useAuditLogViewerConvex"], + ["app/settings/AutomationSettingsSection.tsx", "useAutomationSettingsSectionConvex"], + ["app/settings/HomeSettingsSection.tsx", "useHomeSettingsSectionConvex"], + ["app/settings/MessengerSettingsSection.tsx", "useMessengerSettingsConvex"], + ["app/settings/MobileDevicesSection.tsx", "useMobileDevicesSectionConvex"], + ["app/settings/NotificationSettingsSection.tsx", "useNotificationSettingsSectionConvex"], + ["app/settings/SecurityIdentitySettingsCard.tsx", "useSecurityIdentitySettingsCardConvex"], + ["app/settings/SecuritySettingsSection.tsx", "useSecuritySettingsSectionConvex"], + ["app/settings/SignedSessionsSettings.tsx", "useSignedSessionsSettingsConvex"], + ["app/settings/page.tsx", "useSettingsPageController"], + ["app/settings/useTeamMembersSettings.ts", "useTeamMembersSettingsConvex"], + ["app/snippets/page.tsx", "useSnippetsPageConvex"], + ["app/surveys/[id]/page.tsx", "useSurveyBuilderConvex"], + ["app/surveys/page.tsx", "useSurveysPageConvex"], + ["app/tickets/[id]/page.tsx", "useTicketDetailConvex"], + ["app/tickets/forms/page.tsx", "useTicketFormsPageConvex"], + ["app/tickets/page.tsx", "useTicketsPageConvex"], + ["app/tooltips/page.tsx", "useTooltipsConvex"], + ["app/tours/[id]/page.tsx", "useTourEditorConvex"], + ["app/tours/page.tsx", "useToursPageConvex"], + ["app/visitors/[id]/page.tsx", "useVisitorDetailConvex"], + ["app/visitors/page.tsx", "useVisitorsPageConvex"], + ["components/AppSidebar.tsx", "useAppSidebarConvex"], + ["components/AudienceRuleBuilder.tsx", "useAudienceRuleBuilderConvex"], + ["components/SuggestionsPanel.tsx", "useSuggestionsPanelConvex"], + ["components/WorkspaceSelector.tsx", "useWorkspaceSelectorConvex"], + ["contexts/AuthContext.tsx", "useAuthConvex"], +] as const; const COMPONENT_SCOPED_CONVEX_REF_PATTERNS = [ /^\s{2,}(const|let)\s+\w+\s*=\s*(makeFunctionReference|web(?:Query|Mutation|Action)Ref|widget(?:Query|Mutation|Action)Ref)(?:<|\()/, /use(?:Query|Mutation|Action)\(\s*(makeFunctionReference|web(?:Query|Mutation|Action)Ref|widget(?:Query|Mutation|Action)Ref)(?:<|\()/, ]; -function collectSourceFiles(dir: string): string[] { +const DIRECT_CONVEX_IMPORT_PATTERN = /from ["']convex\/react["']/; +const DIRECT_REF_FACTORY_PATTERN = /\bmakeFunctionReference(?:\s*<[\s\S]*?>)?\s*\(/; +const WEB_ADAPTER_HOOK_PATTERN = /\buseWeb(?:Query|Mutation|Action)\b/; +const WEB_ADAPTER_REF_PATTERN = /\bweb(?:Query|Mutation|Action)Ref\b/; + +function collectSourceFiles(dir: string, includeTests = true): string[] { return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { const entryPath = resolve(dir, entry.name); if (entry.isDirectory()) { - return collectSourceFiles(entryPath); + return collectSourceFiles(entryPath, includeTests); } if (!entry.isFile()) { return []; } - if (entry.name.endsWith(".test.ts") || entry.name.endsWith(".test.tsx")) { + if ( + !includeTests && + (entry.name.endsWith(".test.ts") || entry.name.endsWith(".test.tsx")) + ) { return []; } @@ -90,8 +141,33 @@ function collectSourceFiles(dir: string): string[] { }); } +function isApprovedDirectConvexBoundary(filePath: string): boolean { + return APPROVED_DIRECT_CONVEX_BOUNDARY_FILES.includes(filePath); +} + +function findUnexpectedWebDirectConvexBoundaries(): string[] { + return collectSourceFiles(WEB_SRC_DIR).flatMap((filePath) => { + if (isApprovedDirectConvexBoundary(filePath)) { + return []; + } + + const source = readFileSync(filePath, "utf8"); + const violations: string[] = []; + + if (DIRECT_CONVEX_IMPORT_PATTERN.test(source)) { + violations.push(`${relative(WEB_SRC_DIR, filePath)}: direct convex/react import`); + } + + if (DIRECT_REF_FACTORY_PATTERN.test(source)) { + violations.push(`${relative(WEB_SRC_DIR, filePath)}: direct makeFunctionReference call`); + } + + return violations; + }); +} + function findComponentScopedConvexRefs(dir: string): string[] { - return collectSourceFiles(dir).flatMap((filePath) => { + return collectSourceFiles(dir, false).flatMap((filePath) => { const source = readFileSync(filePath, "utf8"); return source.split("\n").flatMap((line, index) => COMPONENT_SCOPED_CONVEX_REF_PATTERNS.some((pattern) => pattern.test(line)) @@ -106,120 +182,33 @@ describe("convex ref hardening guards", () => { expect(findComponentScopedConvexRefs(WEB_SRC_DIR)).toEqual([]); }); - it("keeps settings team-members on fixed refs without generic name helpers", () => { - const source = readFileSync(TEAM_MEMBERS_SETTINGS_PATH, "utf8"); - - expect(source).not.toContain("function getActionRef(name: string)"); - expect(source).not.toContain("function getMutationRef(name: string)"); - expect(source).not.toMatch(/\sas\s+[A-Za-z0-9_]+Fn/g); - expect(source).toContain("INVITE_TO_WORKSPACE_REF"); - expect(source).toContain("UPDATE_ROLE_REF"); + it("keeps direct convex imports and ref factories limited to approved boundaries", () => { + expect(findUnexpectedWebDirectConvexBoundaries()).toEqual([]); }); - it("keeps email campaign mutations free of page-level any/unknown refs", () => { - const source = readFileSync(EMAIL_CAMPAIGN_PAGE_PATH, "utf8"); - - expect(source).not.toMatch(/makeFunctionReference<"mutation",\s*any,\s*unknown>/); - expect(source).toContain("type UpdateCampaignArgs"); - expect(source).toContain("type SendCampaignResult"); + it("keeps the approved March 11, 2026 direct Convex boundaries explicit", () => { + expect( + APPROVED_DIRECT_CONVEX_BOUNDARY_FILES.map((filePath) => relative(WEB_SRC_DIR, filePath)) + ).toEqual([ + "lib/convex/hooks.ts", + "components/convex-provider.tsx", + "app/settings/MessengerSettingsSection.test.tsx", + "app/typeHardeningGuard.test.ts", + ]); }); - it("keeps migrated settings and inbox UI files on local convex wrappers", () => { - const settingsPageSource = readFileSync(SETTINGS_PAGE_PATH, "utf8"); - const settingsPageControllerSource = readFileSync(SETTINGS_PAGE_CONTROLLER_PATH, "utf8"); - const messengerSettingsSectionSource = readFileSync(MESSENGER_SETTINGS_SECTION_PATH, "utf8"); - const inboxPageSource = readFileSync(INBOX_PAGE_PATH, "utf8"); - const articlesPageSource = readFileSync(ARTICLES_PAGE_PATH, "utf8"); - const articleEditorPageSource = readFileSync(ARTICLE_EDITOR_PAGE_PATH, "utf8"); - const articleCollectionsPageSource = readFileSync(ARTICLE_COLLECTIONS_PAGE_PATH, "utf8"); - const checklistsPageSource = readFileSync(CHECKLISTS_PAGE_PATH, "utf8"); - const checklistBuilderPageSource = readFileSync(CHECKLIST_BUILDER_PAGE_PATH, "utf8"); - const campaignsPageSource = readFileSync(CAMPAIGNS_PAGE_PATH, "utf8"); - const pushCampaignPageSource = readFileSync(PUSH_CAMPAIGN_PAGE_PATH, "utf8"); - const carouselPageSource = readFileSync(CAROUSEL_PAGE_PATH, "utf8"); - const seriesPageSource = readFileSync(SERIES_PAGE_PATH, "utf8"); - const tooltipsPageSource = readFileSync(TOOLTIPS_PAGE_PATH, "utf8"); - const outboundPageSource = readFileSync(OUTBOUND_PAGE_PATH, "utf8"); - const outboundControllerSource = readFileSync(OUTBOUND_CONTROLLER_PATH, "utf8"); - - expect(settingsPageSource).not.toContain('from "convex/react"'); - expect(settingsPageSource).not.toContain("makeFunctionReference("); - expect(settingsPageSource).toContain("useSettingsPageController"); - expect(settingsPageControllerSource).toContain("useSettingsPageConvex"); - expect(settingsPageControllerSource).toContain("useTeamMembersSettings"); - - expect(messengerSettingsSectionSource).not.toContain('from "convex/react"'); - expect(messengerSettingsSectionSource).not.toContain("makeFunctionReference("); - expect(messengerSettingsSectionSource).toContain("useMessengerSettingsConvex"); - - expect(inboxPageSource).not.toContain('from "convex/react"'); - expect(inboxPageSource).not.toContain("makeFunctionReference("); - expect(inboxPageSource).toContain("useInboxConvex"); - - expect(articlesPageSource).not.toContain('from "convex/react"'); - expect(articlesPageSource).not.toContain("makeFunctionReference("); - expect(articlesPageSource).toContain("useArticlesAdminConvex"); - - expect(articleEditorPageSource).not.toContain('from "convex/react"'); - expect(articleEditorPageSource).not.toContain("makeFunctionReference("); - expect(articleEditorPageSource).toContain("useArticleEditorConvex"); - - expect(articleCollectionsPageSource).not.toContain('from "convex/react"'); - expect(articleCollectionsPageSource).not.toContain("makeFunctionReference("); - expect(articleCollectionsPageSource).toContain("useArticleCollectionsConvex"); - - expect(checklistsPageSource).not.toContain('from "convex/react"'); - expect(checklistsPageSource).not.toContain("makeFunctionReference("); - expect(checklistsPageSource).toContain("useChecklistsPageConvex"); + it("keeps migrated web consumers on local wrapper or controller hooks", () => { + for (const [relativePath, marker] of MIGRATED_WEB_CONSUMERS) { + const source = readFileSync(resolve(WEB_SRC_DIR, relativePath), "utf8"); - expect(checklistBuilderPageSource).not.toContain('from "convex/react"'); - expect(checklistBuilderPageSource).not.toContain("makeFunctionReference("); - expect(checklistBuilderPageSource).toContain("useChecklistBuilderConvex"); - - expect(campaignsPageSource).not.toContain('from "convex/react"'); - expect(campaignsPageSource).not.toContain("makeFunctionReference("); - expect(campaignsPageSource).toContain("useCampaignsPageConvex"); - - expect(pushCampaignPageSource).not.toContain('from "convex/react"'); - expect(pushCampaignPageSource).not.toContain("makeFunctionReference("); - expect(pushCampaignPageSource).toContain("usePushCampaignEditorConvex"); - - expect(carouselPageSource).not.toContain('from "convex/react"'); - expect(carouselPageSource).not.toContain("makeFunctionReference("); - expect(carouselPageSource).toContain("useCarouselEditorConvex"); - - expect(seriesPageSource).not.toContain('from "convex/react"'); - expect(seriesPageSource).not.toContain("makeFunctionReference("); - expect(seriesPageSource).toContain("useSeriesEditorConvex"); - - expect(tooltipsPageSource).not.toContain('from "convex/react"'); - expect(tooltipsPageSource).not.toContain("makeFunctionReference("); - expect(tooltipsPageSource).not.toContain("function getQueryRef(name: string)"); - expect(tooltipsPageSource).not.toContain("function getMutationRef(name: string)"); - expect(tooltipsPageSource).toContain("useTooltipsConvex"); - - expect(outboundPageSource).not.toContain('from "convex/react"'); - expect(outboundPageSource).not.toContain("makeFunctionReference("); - expect(outboundPageSource).toContain("useOutboundMessageEditorController"); - expect(outboundControllerSource).toContain("useOutboundMessageEditorConvex"); + expect(source).not.toContain('from "convex/react"'); + expect(source).not.toContain("makeFunctionReference("); + expect(source).toContain(marker); + } }); - it("keeps web convex escape hatches centralized in the adapter and wrapper layer", () => { + it("keeps web adapter escape hatches centralized in wrapper files", () => { const adapterSource = readFileSync(WEB_CONVEX_ADAPTER_PATH, "utf8"); - const settingsPageConvexSource = readFileSync(SETTINGS_PAGE_CONVEX_PATH, "utf8"); - const messengerSettingsConvexSource = readFileSync(MESSENGER_SETTINGS_CONVEX_PATH, "utf8"); - const inboxConvexSource = readFileSync(INBOX_CONVEX_PATH, "utf8"); - const articlesConvexSource = readFileSync(ARTICLES_CONVEX_PATH, "utf8"); - const articleEditorConvexSource = readFileSync(ARTICLE_EDITOR_CONVEX_PATH, "utf8"); - const articleCollectionsConvexSource = readFileSync(ARTICLE_COLLECTIONS_CONVEX_PATH, "utf8"); - const checklistsConvexSource = readFileSync(CHECKLISTS_CONVEX_PATH, "utf8"); - const checklistBuilderConvexSource = readFileSync(CHECKLIST_BUILDER_CONVEX_PATH, "utf8"); - const campaignsConvexSource = readFileSync(CAMPAIGNS_CONVEX_PATH, "utf8"); - const pushCampaignConvexSource = readFileSync(PUSH_CAMPAIGN_CONVEX_PATH, "utf8"); - const carouselConvexSource = readFileSync(CAROUSEL_CONVEX_PATH, "utf8"); - const seriesConvexSource = readFileSync(SERIES_CONVEX_PATH, "utf8"); - const tooltipsConvexSource = readFileSync(TOOLTIPS_CONVEX_PATH, "utf8"); - const outboundConvexSource = readFileSync(OUTBOUND_CONVEX_PATH, "utf8"); expect(adapterSource).toContain("export function webQueryRef"); expect(adapterSource).toContain("export function webMutationRef"); @@ -228,34 +217,11 @@ describe("convex ref hardening guards", () => { expect(adapterSource).toContain("export function useWebMutation"); expect(adapterSource).toContain("export function useWebAction"); - expect(settingsPageConvexSource).toContain("useWebQuery"); - expect(settingsPageConvexSource).toContain("useWebMutation"); - expect(messengerSettingsConvexSource).toContain("useWebQuery"); - expect(messengerSettingsConvexSource).toContain("useWebMutation"); - expect(inboxConvexSource).toContain("useWebQuery"); - expect(inboxConvexSource).toContain("useWebMutation"); - expect(inboxConvexSource).toContain("useWebAction"); - expect(articlesConvexSource).toContain("useWebQuery"); - expect(articlesConvexSource).toContain("useWebMutation"); - expect(articleEditorConvexSource).toContain("useWebQuery"); - expect(articleEditorConvexSource).toContain("useWebMutation"); - expect(articleCollectionsConvexSource).toContain("useWebQuery"); - expect(articleCollectionsConvexSource).toContain("useWebMutation"); - expect(checklistsConvexSource).toContain("useWebQuery"); - expect(checklistsConvexSource).toContain("useWebMutation"); - expect(checklistBuilderConvexSource).toContain("useWebQuery"); - expect(checklistBuilderConvexSource).toContain("useWebMutation"); - expect(campaignsConvexSource).toContain("useWebQuery"); - expect(campaignsConvexSource).toContain("useWebMutation"); - expect(pushCampaignConvexSource).toContain("useWebQuery"); - expect(pushCampaignConvexSource).toContain("useWebMutation"); - expect(carouselConvexSource).toContain("useWebQuery"); - expect(carouselConvexSource).toContain("useWebMutation"); - expect(seriesConvexSource).toContain("useWebQuery"); - expect(seriesConvexSource).toContain("useWebMutation"); - expect(tooltipsConvexSource).toContain("useWebQuery"); - expect(tooltipsConvexSource).toContain("useWebMutation"); - expect(outboundConvexSource).toContain("useWebQuery"); - expect(outboundConvexSource).toContain("useWebMutation"); + for (const filePath of WRAPPER_LAYER_FILES) { + const source = readFileSync(filePath, "utf8"); + + expect(WEB_ADAPTER_HOOK_PATTERN.test(source)).toBe(true); + expect(WEB_ADAPTER_REF_PATTERN.test(source)).toBe(true); + } }); }); diff --git a/apps/web/src/app/visitors/[id]/page.tsx b/apps/web/src/app/visitors/[id]/page.tsx index 711042f..afd0870 100644 --- a/apps/web/src/app/visitors/[id]/page.tsx +++ b/apps/web/src/app/visitors/[id]/page.tsx @@ -1,7 +1,5 @@ "use client"; -import { useQuery } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { AppLayout } from "@/components/AppLayout"; import { useAuth } from "@/contexts/AuthContext"; import { Card, Button } from "@opencom/ui"; @@ -10,59 +8,7 @@ import Link from "next/link"; import { useParams } from "next/navigation"; import type { Id } from "@opencom/convex/dataModel"; import { formatVisitorIdentityLabel } from "@/lib/visitorIdentity"; - -type VisitorDetailRecord = { - _id: Id<"visitors">; - readableId?: string; - name?: string; - email?: string; - externalUserId?: string; - sessionId?: string; - lastActiveAt?: number; - isOnline: boolean; - referrer?: string; - currentUrl?: string; - customAttributes?: Record; - location?: { city?: string; region?: string; country?: string }; - device?: { deviceType?: string; os?: string; browser?: string }; -}; - -type VisitorLinkedConversationRecord = { - _id: Id<"conversations">; - subject?: string; - status?: string; - channel?: string; - lastMessagePreview?: string; -}; - -type VisitorLinkedTicketRecord = { - _id: Id<"tickets">; - subject: string; - status: string; - priority: string; -}; - -type VisitorDirectoryDetailResult = - | { - status: "ok"; - visitor: VisitorDetailRecord; - resourceAccess: { conversations: boolean; tickets: boolean }; - linkedConversations: VisitorLinkedConversationRecord[]; - linkedTickets: VisitorLinkedTicketRecord[]; - } - | { - status: "not_found" | "unauthenticated" | "forbidden"; - visitor: null; - resourceAccess: { conversations: boolean; tickets: boolean }; - linkedConversations: []; - linkedTickets: []; - }; - -const visitorDetailQueryRef = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces">; visitorId: Id<"visitors"> }, - VisitorDirectoryDetailResult ->("visitors:getDirectoryDetail"); +import { useVisitorDetailConvex } from "../hooks/useVisitorsConvex"; function unknown(value?: string): string { const normalized = value?.trim(); @@ -76,11 +22,7 @@ function VisitorDetailContent(): React.JSX.Element | null { const { user, activeWorkspace } = useAuth(); const params = useParams(); const visitorId = params.id as Id<"visitors">; - - const detail = useQuery( - visitorDetailQueryRef, - activeWorkspace?._id ? { workspaceId: activeWorkspace._id, visitorId } : "skip" - ) as VisitorDirectoryDetailResult | undefined; + const { detail } = useVisitorDetailConvex(activeWorkspace?._id, visitorId); if (!user || !activeWorkspace) { return null; diff --git a/apps/web/src/app/visitors/hooks/useVisitorsConvex.ts b/apps/web/src/app/visitors/hooks/useVisitorsConvex.ts new file mode 100644 index 0000000..138fffe --- /dev/null +++ b/apps/web/src/app/visitors/hooks/useVisitorsConvex.ts @@ -0,0 +1,128 @@ +"use client"; + +import type { Id } from "@opencom/convex/dataModel"; +import { useWebQuery, webQueryRef } from "@/lib/convex/hooks"; + +type PresenceFilter = "all" | "online" | "offline"; + +type VisitorsDirectoryArgs = { + workspaceId: Id<"workspaces">; + search?: string; + presence?: PresenceFilter; + limit?: number; + offset?: number; +}; + +type VisitorDirectoryRecord = { + _id: Id<"visitors">; + readableId?: string; + name?: string; + email?: string; + externalUserId?: string; + isOnline: boolean; + lastActiveAt?: number; +}; + +export type VisitorDirectoryResult = + | { + status: "ok"; + visitors: VisitorDirectoryRecord[]; + totalCount: number; + hasMore: boolean; + nextOffset: number | null; + } + | { + status: "unauthenticated" | "forbidden"; + visitors: []; + totalCount: 0; + hasMore: false; + nextOffset: null; + }; + +export type VisitorDirectoryDetailResult = + | { + status: "ok"; + visitor: { + _id: Id<"visitors">; + readableId?: string; + name?: string; + email?: string; + externalUserId?: string; + sessionId?: string; + lastActiveAt?: number; + isOnline: boolean; + referrer?: string; + currentUrl?: string; + customAttributes?: Record; + location?: { city?: string; region?: string; country?: string }; + device?: { deviceType?: string; os?: string; browser?: string }; + }; + resourceAccess: { conversations: boolean; tickets: boolean }; + linkedConversations: Array<{ + _id: Id<"conversations">; + subject?: string; + status?: string; + channel?: string; + lastMessagePreview?: string; + }>; + linkedTickets: Array<{ + _id: Id<"tickets">; + subject: string; + status: string; + priority: string; + }>; + } + | { + status: "not_found" | "unauthenticated" | "forbidden"; + visitor: null; + resourceAccess: { conversations: boolean; tickets: boolean }; + linkedConversations: []; + linkedTickets: []; + }; + +type VisitorDetailArgs = { + workspaceId: Id<"workspaces">; + visitorId: Id<"visitors">; +}; + +const VISITORS_DIRECTORY_QUERY_REF = webQueryRef( + "visitors:listDirectory" +); +const VISITOR_DETAIL_QUERY_REF = webQueryRef( + "visitors:getDirectoryDetail" +); + +export function useVisitorsPageConvex( + workspaceId?: Id<"workspaces">, + search?: string, + presence: PresenceFilter = "all", + offset = 0, + limit = 20 +) { + return { + directoryResult: useWebQuery( + VISITORS_DIRECTORY_QUERY_REF, + workspaceId + ? { + workspaceId, + search: search && search.length > 0 ? search : undefined, + presence, + limit, + offset, + } + : "skip" + ), + }; +} + +export function useVisitorDetailConvex( + workspaceId?: Id<"workspaces">, + visitorId?: Id<"visitors"> +) { + return { + detail: useWebQuery( + VISITOR_DETAIL_QUERY_REF, + workspaceId && visitorId ? { workspaceId, visitorId } : "skip" + ), + }; +} diff --git a/apps/web/src/app/visitors/page.tsx b/apps/web/src/app/visitors/page.tsx index c735929..1c88ecd 100644 --- a/apps/web/src/app/visitors/page.tsx +++ b/apps/web/src/app/visitors/page.tsx @@ -1,52 +1,18 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { useQuery } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { AppLayout, AppPageShell } from "@/components/AppLayout"; import { useAuth } from "@/contexts/AuthContext"; import { Card, Input, Button } from "@opencom/ui"; import { Search, UserRound, Circle } from "lucide-react"; import Link from "next/link"; -import type { Id } from "@opencom/convex/dataModel"; import { formatVisitorEmailLabel, formatVisitorIdentityLabel } from "@/lib/visitorIdentity"; +import { useVisitorsPageConvex } from "./hooks/useVisitorsConvex"; const PAGE_SIZE = 20; type PresenceFilter = "all" | "online" | "offline"; -type VisitorDirectoryRecord = { - _id: Id<"visitors">; - readableId?: string; - name?: string; - email?: string; - externalUserId?: string; - isOnline: boolean; - lastActiveAt?: number; -}; - -type VisitorDirectoryResult = - | { - status: "ok"; - visitors: VisitorDirectoryRecord[]; - totalCount: number; - hasMore: boolean; - nextOffset: number | null; - } - | { status: "unauthenticated" | "forbidden"; visitors: []; totalCount: 0; hasMore: false; nextOffset: null }; - -const visitorsDirectoryQueryRef = makeFunctionReference< - "query", - { - workspaceId: Id<"workspaces">; - search?: string; - presence?: PresenceFilter; - limit?: number; - offset?: number; - }, - VisitorDirectoryResult ->("visitors:listDirectory"); - function formatLastActive(timestamp?: number): string { if (!timestamp) { return "Unknown"; @@ -71,19 +37,13 @@ function VisitorsContent(): React.JSX.Element | null { useEffect(() => { setOffset(0); }, [debouncedSearch, presenceFilter, activeWorkspace?._id]); - - const directoryResult = useQuery( - visitorsDirectoryQueryRef, - activeWorkspace?._id - ? { - workspaceId: activeWorkspace._id, - search: debouncedSearch.length > 0 ? debouncedSearch : undefined, - presence: presenceFilter, - limit: PAGE_SIZE, - offset, - } - : "skip" - ) as VisitorDirectoryResult | undefined; + const { directoryResult } = useVisitorsPageConvex( + activeWorkspace?._id, + debouncedSearch, + presenceFilter, + offset, + PAGE_SIZE + ); const pageMeta = useMemo(() => { if (!directoryResult || directoryResult.status !== "ok") { diff --git a/apps/web/src/components/AppSidebar.tsx b/apps/web/src/components/AppSidebar.tsx index ab80933..611e786 100644 --- a/apps/web/src/components/AppSidebar.tsx +++ b/apps/web/src/components/AppSidebar.tsx @@ -3,9 +3,6 @@ import { useEffect, useMemo, useRef } from "react"; import { usePathname } from "next/navigation"; import Link from "next/link"; -import { useQuery } from "convex/react"; -import { makeFunctionReference } from "convex/server"; -import type { Id } from "@opencom/convex/dataModel"; import { Inbox, FileText, @@ -35,6 +32,7 @@ import { loadInboxCuePreferences, } from "@/lib/inboxNotificationCues"; import { playInboxBingSound } from "@/lib/playInboxBingSound"; +import { useAppSidebarConvex } from "@/components/hooks/useAppSidebarConvex"; import { WorkspaceSelector } from "./WorkspaceSelector"; type SidebarNavItem = { @@ -73,18 +71,6 @@ const CORE_NAV_ITEMS: SidebarNavItem[] = [ { href: "/audit-logs", label: "Audit Logs", icon: Shield }, ]; -const INTEGRATION_SIGNALS_QUERY = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - { integrations: Array<{ isActiveNow: boolean }> } ->("workspaces:getHostedOnboardingIntegrationSignals"); - -const SIDEBAR_CONVERSATIONS_QUERY = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - Array<{ _id: string; unreadByAgent?: number }> ->("conversations:list"); - interface AppSidebarProps { className?: string; onNavigate?: () => void; @@ -101,13 +87,9 @@ export function AppSidebar({ const pathname = usePathname(); const { activeWorkspace, logout, user } = useAuth(); const isAdmin = activeWorkspace?.role === "owner" || activeWorkspace?.role === "admin"; - const integrationSignals = useQuery( - INTEGRATION_SIGNALS_QUERY, - activeWorkspace?._id ? { workspaceId: activeWorkspace._id } : "skip" - ); - const sidebarConversations = useQuery( - SIDEBAR_CONVERSATIONS_QUERY, - activeWorkspace?._id && isAdmin ? { workspaceId: activeWorkspace._id } : "skip" + const { integrationSignals, sidebarConversations } = useAppSidebarConvex( + activeWorkspace?._id, + isAdmin ); const inboxCuePreferencesRef = useRef<{ browserNotifications: boolean; diff --git a/apps/web/src/components/AudienceRuleBuilder.tsx b/apps/web/src/components/AudienceRuleBuilder.tsx index 7d4d5f2..af13efb 100644 --- a/apps/web/src/components/AudienceRuleBuilder.tsx +++ b/apps/web/src/components/AudienceRuleBuilder.tsx @@ -1,8 +1,6 @@ "use client"; import { useState } from "react"; -import { useQuery } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { Button, Input } from "@opencom/ui"; import { Plus, Trash2, ChevronDown, GripVertical, Users, Layers } from "lucide-react"; import type { Id } from "@opencom/convex/dataModel"; @@ -16,6 +14,7 @@ import type { PropertyReference, } from "@opencom/types"; import { isAudienceSegmentReference } from "@opencom/types"; +import { useAudienceRuleBuilderConvex } from "@/components/hooks/useAudienceRuleBuilderConvex"; export type SegmentReference = AudienceSegmentReference>; export type AudienceRule = SharedAudienceRule>; @@ -28,18 +27,6 @@ interface AudienceRuleBuilderProps { showSegmentSelector?: boolean; } -const LIST_SEGMENTS_QUERY = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - Array<{ _id: Id<"segments">; name: string }> ->("segments:list"); - -const PREVIEW_AUDIENCE_RULES_QUERY = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces">; audienceRules: AudienceRule }, - { matching: number; total: number } ->("tours:previewAudienceRules"); - const SYSTEM_PROPERTIES = [ { key: "email", label: "Email", type: "string" }, { key: "name", label: "Name", type: "string" }, @@ -524,12 +511,9 @@ export function AudienceRuleBuilder({ const [targetingMode, setTargetingMode] = useState<"custom" | "segment">( isSegmentRule(value) ? "segment" : "custom" ); - - const segments = useQuery(LIST_SEGMENTS_QUERY, workspaceId ? { workspaceId } : "skip"); - - const preview = useQuery( - PREVIEW_AUDIENCE_RULES_QUERY, - workspaceId && isEnabled && value ? { workspaceId, audienceRules: value } : "skip" + const { preview, segments } = useAudienceRuleBuilderConvex( + workspaceId, + isEnabled ? value : null ); const handleToggle = () => { diff --git a/apps/web/src/components/SuggestionsPanel.tsx b/apps/web/src/components/SuggestionsPanel.tsx index 20b64bc..b12c7f8 100644 --- a/apps/web/src/components/SuggestionsPanel.tsx +++ b/apps/web/src/components/SuggestionsPanel.tsx @@ -1,8 +1,6 @@ "use client"; import { useState, useEffect, useCallback } from "react"; -import { useAction, useMutation, useQuery } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { Button, Card } from "@opencom/ui"; import { Sparkles, @@ -15,15 +13,10 @@ import { Loader2, } from "lucide-react"; import type { Id } from "@opencom/convex/dataModel"; - -type Suggestion = { - id: string; - type: "article" | "internalArticle" | "snippet"; - title: string; - snippet: string; - content: string; - score: number; -}; +import { + type SuggestionRecord as Suggestion, + useSuggestionsPanelConvex, +} from "@/components/hooks/useSuggestionsPanelConvex"; interface SuggestionsPanelProps { conversationId: Id<"conversations">; @@ -32,40 +25,6 @@ interface SuggestionsPanelProps { onSuggestionsUpdated?: (count: number) => void; } -const AI_SETTINGS_QUERY = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - { suggestionsEnabled?: boolean } | null ->("aiAgent:getSettings"); - -const GET_SUGGESTIONS_ACTION = makeFunctionReference< - "action", - { conversationId: Id<"conversations">; limit: number }, - Suggestion[] ->("suggestions:getForConversation"); - -const TRACK_USAGE_REF = makeFunctionReference< - "mutation", - { - workspaceId: Id<"workspaces">; - conversationId: Id<"conversations">; - contentType: Suggestion["type"]; - contentId: string; - }, - null ->("suggestions:trackUsage"); - -const TRACK_DISMISSAL_REF = makeFunctionReference< - "mutation", - { - workspaceId: Id<"workspaces">; - conversationId: Id<"conversations">; - contentType: Suggestion["type"]; - contentId: string; - }, - null ->("suggestions:trackDismissal"); - export function SuggestionsPanel({ conversationId, workspaceId, @@ -76,11 +35,8 @@ export function SuggestionsPanel({ const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [dismissedIds, setDismissedIds] = useState>(new Set()); - - const settings = useQuery(AI_SETTINGS_QUERY, { workspaceId }); - const getSuggestions = useAction(GET_SUGGESTIONS_ACTION); - const trackUsage = useMutation(TRACK_USAGE_REF); - const trackDismissal = useMutation(TRACK_DISMISSAL_REF); + const { settings, getSuggestions, trackUsage, trackDismissal } = + useSuggestionsPanelConvex(workspaceId); const fetchSuggestions = useCallback(async () => { if (!settings?.suggestionsEnabled) { diff --git a/apps/web/src/components/WorkspaceSelector.tsx b/apps/web/src/components/WorkspaceSelector.tsx index 9f1e7ee..0dc8c46 100644 --- a/apps/web/src/components/WorkspaceSelector.tsx +++ b/apps/web/src/components/WorkspaceSelector.tsx @@ -1,18 +1,11 @@ "use client"; import { useState } from "react"; -import { useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { Button, Input } from "@opencom/ui"; import { ChevronDown, Plus, Check, Building2 } from "lucide-react"; import type { Id } from "@opencom/convex/dataModel"; import { useAuth } from "@/contexts/AuthContext"; - -const CREATE_WORKSPACE_REF = makeFunctionReference< - "mutation", - { name: string }, - Id<"workspaces"> ->("workspaces:create"); +import { useWorkspaceSelectorConvex } from "@/components/hooks/useWorkspaceSelectorConvex"; export function WorkspaceSelector(): React.JSX.Element | null { const { workspaces, activeWorkspace, switchWorkspace } = useAuth(); @@ -20,8 +13,7 @@ export function WorkspaceSelector(): React.JSX.Element | null { const [showCreateForm, setShowCreateForm] = useState(false); const [newWorkspaceName, setNewWorkspaceName] = useState(""); const [isCreating, setIsCreating] = useState(false); - - const createWorkspace = useMutation(CREATE_WORKSPACE_REF); + const { createWorkspace } = useWorkspaceSelectorConvex(); const handleSelect = async (workspaceId: Id<"workspaces">) => { if (workspaceId !== activeWorkspace?._id) { await switchWorkspace(workspaceId); diff --git a/apps/web/src/components/hooks/useAppSidebarConvex.ts b/apps/web/src/components/hooks/useAppSidebarConvex.ts new file mode 100644 index 0000000..3ca9b6b --- /dev/null +++ b/apps/web/src/components/hooks/useAppSidebarConvex.ts @@ -0,0 +1,40 @@ +"use client"; + +import type { Id } from "@opencom/convex/dataModel"; +import { useWebQuery, webQueryRef } from "@/lib/convex/hooks"; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type IntegrationSignalsRecord = { + integrations: Array<{ isActiveNow: boolean }>; +} | null; + +type SidebarConversationRecord = Array<{ + _id: string; + unreadByAgent?: number; +}>; + +const INTEGRATION_SIGNALS_QUERY_REF = webQueryRef( + "workspaces:getHostedOnboardingIntegrationSignals" +); +const SIDEBAR_CONVERSATIONS_QUERY_REF = webQueryRef( + "conversations:list" +); + +export function useAppSidebarConvex( + workspaceId?: Id<"workspaces"> | null, + shouldLoadConversations = false +) { + return { + integrationSignals: useWebQuery( + INTEGRATION_SIGNALS_QUERY_REF, + workspaceId ? { workspaceId } : "skip" + ), + sidebarConversations: useWebQuery( + SIDEBAR_CONVERSATIONS_QUERY_REF, + workspaceId && shouldLoadConversations ? { workspaceId } : "skip" + ), + }; +} diff --git a/apps/web/src/components/hooks/useAudienceRuleBuilderConvex.ts b/apps/web/src/components/hooks/useAudienceRuleBuilderConvex.ts new file mode 100644 index 0000000..b9cb504 --- /dev/null +++ b/apps/web/src/components/hooks/useAudienceRuleBuilderConvex.ts @@ -0,0 +1,37 @@ +"use client"; + +import type { Id } from "@opencom/convex/dataModel"; +import type { AudienceRuleWithSegment } from "@opencom/types"; +import { useWebQuery, webQueryRef } from "@/lib/convex/hooks"; + +type AudienceRule = AudienceRuleWithSegment>; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type PreviewArgs = WorkspaceArgs & { + audienceRules: AudienceRule; +}; + +const LIST_SEGMENTS_QUERY_REF = webQueryRef< + WorkspaceArgs, + Array<{ _id: Id<"segments">; name: string }> +>("segments:list"); +const PREVIEW_AUDIENCE_RULES_QUERY_REF = webQueryRef< + PreviewArgs, + { matching: number; total: number } +>("tours:previewAudienceRules"); + +export function useAudienceRuleBuilderConvex( + workspaceId?: Id<"workspaces">, + audienceRules?: AudienceRule | null +) { + return { + preview: useWebQuery( + PREVIEW_AUDIENCE_RULES_QUERY_REF, + workspaceId && audienceRules ? { workspaceId, audienceRules } : "skip" + ), + segments: useWebQuery(LIST_SEGMENTS_QUERY_REF, workspaceId ? { workspaceId } : "skip"), + }; +} diff --git a/apps/web/src/components/hooks/useSuggestionsPanelConvex.ts b/apps/web/src/components/hooks/useSuggestionsPanelConvex.ts new file mode 100644 index 0000000..1a3e58e --- /dev/null +++ b/apps/web/src/components/hooks/useSuggestionsPanelConvex.ts @@ -0,0 +1,57 @@ +"use client"; + +import type { Id } from "@opencom/convex/dataModel"; +import { + useWebAction, + useWebMutation, + useWebQuery, + webActionRef, + webMutationRef, + webQueryRef, +} from "@/lib/convex/hooks"; + +export type SuggestionRecord = { + id: string; + type: "article" | "internalArticle" | "snippet"; + title: string; + snippet: string; + content: string; + score: number; +}; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type SuggestionsArgs = { + conversationId: Id<"conversations">; + limit: number; +}; + +type TrackSuggestionArgs = { + workspaceId: Id<"workspaces">; + conversationId: Id<"conversations">; + contentType: SuggestionRecord["type"]; + contentId: string; +}; + +const AI_SETTINGS_QUERY_REF = webQueryRef< + WorkspaceArgs, + { suggestionsEnabled?: boolean } | null +>("aiAgent:getSettings"); +const GET_SUGGESTIONS_ACTION_REF = webActionRef( + "suggestions:getForConversation" +); +const TRACK_USAGE_REF = webMutationRef("suggestions:trackUsage"); +const TRACK_DISMISSAL_REF = webMutationRef( + "suggestions:trackDismissal" +); + +export function useSuggestionsPanelConvex(workspaceId: Id<"workspaces">) { + return { + getSuggestions: useWebAction(GET_SUGGESTIONS_ACTION_REF), + settings: useWebQuery(AI_SETTINGS_QUERY_REF, { workspaceId }), + trackDismissal: useWebMutation(TRACK_DISMISSAL_REF), + trackUsage: useWebMutation(TRACK_USAGE_REF), + }; +} diff --git a/apps/web/src/components/hooks/useWorkspaceSelectorConvex.ts b/apps/web/src/components/hooks/useWorkspaceSelectorConvex.ts new file mode 100644 index 0000000..48837e9 --- /dev/null +++ b/apps/web/src/components/hooks/useWorkspaceSelectorConvex.ts @@ -0,0 +1,18 @@ +"use client"; + +import type { Id } from "@opencom/convex/dataModel"; +import { useWebMutation, webMutationRef } from "@/lib/convex/hooks"; + +type CreateWorkspaceArgs = { + name: string; +}; + +const CREATE_WORKSPACE_REF = webMutationRef>( + "workspaces:create" +); + +export function useWorkspaceSelectorConvex() { + return { + createWorkspace: useWebMutation(CREATE_WORKSPACE_REF), + }; +} diff --git a/apps/web/src/contexts/AuthContext.tsx b/apps/web/src/contexts/AuthContext.tsx index fa9f49d..6b5a96a 100644 --- a/apps/web/src/contexts/AuthContext.tsx +++ b/apps/web/src/contexts/AuthContext.tsx @@ -9,10 +9,9 @@ import { useMemo, type ReactNode, } from "react"; -import { useMutation, useQuery } from "convex/react"; import { useAuthActions } from "@convex-dev/auth/react"; -import { makeFunctionReference } from "convex/server"; import type { Id } from "@opencom/convex/dataModel"; +import { useAuthConvex, useAuthHomeRouteConvex } from "./hooks/useAuthConvex"; export interface User { _id: Id<"users">; @@ -51,29 +50,6 @@ interface AuthContextType { const AuthContext = createContext(null); const ACTIVE_WORKSPACE_KEY = "opencom_active_workspace"; -const currentUserQuery = makeFunctionReference< - "query", - Record, - { - user: User | null; - workspaces: Workspace[]; - } | null ->("auth:currentUser"); -const switchWorkspaceRef = makeFunctionReference< - "mutation", - { workspaceId: Id<"workspaces"> }, - unknown ->("auth:switchWorkspace"); -const completeSignupProfileRef = makeFunctionReference< - "mutation", - { name?: string; workspaceName?: string }, - unknown ->("auth:completeSignupProfile"); -const hostedOnboardingStateQuery = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - { isWidgetVerified: boolean } ->("workspaces:getHostedOnboardingState"); export function AuthProvider({ children }: { children: ReactNode }): React.JSX.Element { const [activeWorkspace, setActiveWorkspace] = useState(null); @@ -81,13 +57,8 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E // Convex Auth hooks const { signIn: convexSignIn, signOut: convexSignOut } = useAuthActions(); - - // Query current user from Convex Auth session - const convexAuthUser = useQuery(currentUserQuery); - - // Workspace mutation - const switchWorkspaceMutation = useMutation(switchWorkspaceRef); - const completeSignupProfileMutation = useMutation(completeSignupProfileRef); + const { completeSignupProfileMutation, convexAuthUser, switchWorkspaceMutation } = + useAuthConvex(); // Derive state from query const user = useMemo(() => (convexAuthUser?.user as User | null) ?? null, [convexAuthUser]); @@ -98,10 +69,7 @@ export function AuthProvider({ children }: { children: ReactNode }): React.JSX.E const isLoading = convexAuthUser === undefined; const isAuthenticated = !!user; const workspaceIdForHomeRouting = activeWorkspace?._id ?? user?.workspaceId ?? null; - const hostedOnboardingState = useQuery( - hostedOnboardingStateQuery, - workspaceIdForHomeRouting ? { workspaceId: workspaceIdForHomeRouting } : "skip" - ); + const { hostedOnboardingState } = useAuthHomeRouteConvex(workspaceIdForHomeRouting); const isHomeRouteLoading = isAuthenticated && !!workspaceIdForHomeRouting && hostedOnboardingState === undefined; const defaultHomePath: "/onboarding" | "/inbox" = diff --git a/apps/web/src/contexts/hooks/useAuthConvex.ts b/apps/web/src/contexts/hooks/useAuthConvex.ts new file mode 100644 index 0000000..173c80c --- /dev/null +++ b/apps/web/src/contexts/hooks/useAuthConvex.ts @@ -0,0 +1,61 @@ +"use client"; + +import type { Id } from "@opencom/convex/dataModel"; +import { + useWebMutation, + useWebQuery, + webMutationRef, + webQueryRef, +} from "@/lib/convex/hooks"; +import type { User, Workspace } from "../AuthContext"; + +type CurrentUserRecord = { + user: User | null; + workspaces: Workspace[]; +} | null; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type SwitchWorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type CompleteSignupProfileArgs = { + name?: string; + workspaceName?: string; +}; + +type HostedOnboardingStateRecord = { + isWidgetVerified: boolean; +}; + +const CURRENT_USER_QUERY_REF = webQueryRef, CurrentUserRecord>( + "auth:currentUser" +); +const SWITCH_WORKSPACE_REF = webMutationRef("auth:switchWorkspace"); +const COMPLETE_SIGNUP_PROFILE_REF = webMutationRef( + "auth:completeSignupProfile" +); +const HOSTED_ONBOARDING_STATE_QUERY_REF = webQueryRef< + WorkspaceArgs, + HostedOnboardingStateRecord +>("workspaces:getHostedOnboardingState"); + +export function useAuthConvex() { + return { + completeSignupProfileMutation: useWebMutation(COMPLETE_SIGNUP_PROFILE_REF), + convexAuthUser: useWebQuery(CURRENT_USER_QUERY_REF, {}), + switchWorkspaceMutation: useWebMutation(SWITCH_WORKSPACE_REF), + }; +} + +export function useAuthHomeRouteConvex(workspaceIdForHomeRouting?: Id<"workspaces"> | null) { + return { + hostedOnboardingState: useWebQuery( + HOSTED_ONBOARDING_STATE_QUERY_REF, + workspaceIdForHomeRouting ? { workspaceId: workspaceIdForHomeRouting } : "skip" + ), + }; +} diff --git a/openspec/changes/expand-web-local-convex-wrapper-hooks/tasks.md b/openspec/changes/expand-web-local-convex-wrapper-hooks/tasks.md index e483857..3c68fb2 100644 --- a/openspec/changes/expand-web-local-convex-wrapper-hooks/tasks.md +++ b/openspec/changes/expand-web-local-convex-wrapper-hooks/tasks.md @@ -1,20 +1,20 @@ ## 1. Inventory and guardrails -- [ ] 1.1 Freeze the March 11, 2026 web direct-import inventory and record the approved provider, adapter, and test exceptions. -- [ ] 1.2 Extend web hardening guard coverage so newly covered files fail verification if they keep direct `convex/react` imports. +- [x] 1.1 Freeze the March 11, 2026 web direct-import inventory and record the approved provider, adapter, and test exceptions. +- [x] 1.2 Extend web hardening guard coverage so newly covered files fail verification if they keep direct `convex/react` imports. ## 2. Migrate shared auth, reporting, and visitor domains -- [ ] 2.1 Add or extend wrapper/controller coverage for `AuthContext`, onboarding/help flows, and shared component consumers such as `AppSidebar`, `AudienceRuleBuilder`, `SuggestionsPanel`, and `WorkspaceSelector`. -- [ ] 2.2 Migrate reporting, visitors, inbox list, snippets, and segments modules away from direct `convex/react` and inline `makeFunctionReference(...)` usage. +- [x] 2.1 Add or extend wrapper/controller coverage for `AuthContext`, onboarding/help flows, and shared component consumers such as `AppSidebar`, `AudienceRuleBuilder`, `SuggestionsPanel`, and `WorkspaceSelector`. +- [x] 2.2 Migrate reporting, visitors, inbox list, snippets, and segments modules away from direct `convex/react` and inline `makeFunctionReference(...)` usage. ## 3. Migrate settings and CRUD admin routes -- [ ] 3.1 Add wrapper/controller coverage for the remaining settings sections and `useTeamMembersSettings.ts`. -- [ ] 3.2 Migrate remaining tours, surveys, tickets, campaigns email, and outbound routes to local wrapper hooks or route controllers. +- [x] 3.1 Add wrapper/controller coverage for the remaining settings sections and `useTeamMembersSettings.ts`. +- [x] 3.2 Migrate remaining tours, surveys, tickets, campaigns email, and outbound routes to local wrapper hooks or route controllers. ## 4. Verification -- [ ] 4.1 Run `pnpm --filter @opencom/web typecheck`. -- [ ] 4.2 Run targeted web tests for touched wrapper and hardening-guard domains. -- [ ] 4.3 Run `openspec validate expand-web-local-convex-wrapper-hooks --strict --no-interactive`. +- [x] 4.1 Run `pnpm --filter @opencom/web typecheck`. +- [x] 4.2 Run targeted web tests for touched wrapper and hardening-guard domains. +- [x] 4.3 Run `openspec validate expand-web-local-convex-wrapper-hooks --strict --no-interactive`.