diff --git a/apps/mobile/app/(app)/conversation/[id].tsx b/apps/mobile/app/(app)/conversation/[id].tsx index ca83a53..77d6ae5 100644 --- a/apps/mobile/app/(app)/conversation/[id].tsx +++ b/apps/mobile/app/(app)/conversation/[id].tsx @@ -11,68 +11,9 @@ import { ActivityIndicator, } from "react-native"; import { useLocalSearchParams } from "expo-router"; -import { useQuery, useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { useAuth } from "../../../src/contexts/AuthContext"; -import type { Id } from "@opencom/convex/dataModel"; - -interface Message { - _id: string; - content: string; - senderType: "user" | "visitor" | "agent" | "bot"; - createdAt: number; -} - -type ConversationRecord = { - _id: Id<"conversations">; - visitorId?: Id<"visitors">; - status: "open" | "closed" | "snoozed"; -}; - -type VisitorRecord = { - _id: Id<"visitors">; - name?: string; - email?: string; - readableId?: string; - location?: { city?: string; country?: string }; - device?: { browser?: string; os?: string }; -}; - -const conversationGetQueryRef = makeFunctionReference< - "query", - { id: Id<"conversations"> }, - ConversationRecord | null ->("conversations:get"); - -const visitorGetQueryRef = makeFunctionReference< - "query", - { id: Id<"visitors"> }, - VisitorRecord | null ->("visitors:get"); - -const messagesListQueryRef = makeFunctionReference< - "query", - { conversationId: Id<"conversations"> }, - Message[] ->("messages:list"); - -const sendMessageMutationRef = makeFunctionReference< - "mutation", - { conversationId: Id<"conversations">; senderId: string; senderType: "agent"; content: string }, - Id<"messages"> ->("messages:send"); - -const updateConversationStatusMutationRef = makeFunctionReference< - "mutation", - { id: Id<"conversations">; status: "open" | "closed" | "snoozed" }, - null ->("conversations:updateStatus"); - -const markConversationReadMutationRef = makeFunctionReference< - "mutation", - { id: Id<"conversations">; readerType: "agent" | "visitor" }, - null ->("conversations:markAsRead"); +import { useConversationConvex } from "../../../src/hooks/convex/useConversationConvex"; +import type { MobileConversationMessage as Message } from "../../../src/hooks/convex/types"; function formatTime(timestamp: number): string { return new Date(timestamp).toLocaleTimeString([], { @@ -86,32 +27,22 @@ export default function ConversationScreen() { const { user } = useAuth(); const [inputText, setInputText] = useState(""); const flatListRef = useRef(null); - - const conversation = useQuery( - conversationGetQueryRef, - id ? { id: id as Id<"conversations"> } : "skip" - ) as ConversationRecord | null | undefined; - - const visitor = useQuery( - visitorGetQueryRef, - conversation?.visitorId ? { id: conversation.visitorId } : "skip" - ) as VisitorRecord | null | undefined; - - const messages = useQuery( - messagesListQueryRef, - id ? { conversationId: id as Id<"conversations"> } : "skip" - ) as Message[] | undefined; - - const sendMessage = useMutation(sendMessageMutationRef); - const updateStatus = useMutation(updateConversationStatusMutationRef); - const markAsRead = useMutation(markConversationReadMutationRef); + const { + resolvedConversationId, + conversation, + visitor, + messages, + sendMessage, + updateConversationStatus: updateStatus, + markConversationRead: markAsRead, + } = useConversationConvex(id); // Mark conversation as read when viewing useEffect(() => { - if (id && conversation) { - markAsRead({ id: id as Id<"conversations">, readerType: "agent" }).catch(console.error); + if (resolvedConversationId && conversation) { + markAsRead({ id: resolvedConversationId, readerType: "agent" }).catch(console.error); } - }, [id, conversation, markAsRead]); + }, [conversation, markAsRead, resolvedConversationId]); useEffect(() => { if (messages && messages.length > 0) { @@ -122,14 +53,14 @@ export default function ConversationScreen() { }, [messages]); const handleSend = async () => { - if (!inputText.trim() || !id || !user) return; + if (!inputText.trim() || !resolvedConversationId || !user) return; const content = inputText.trim(); setInputText(""); try { await sendMessage({ - conversationId: id as Id<"conversations">, + conversationId: resolvedConversationId, senderId: user._id, senderType: "agent", content, @@ -141,10 +72,10 @@ export default function ConversationScreen() { }; const handleStatusChange = async (status: "open" | "closed" | "snoozed") => { - if (!id) return; + if (!resolvedConversationId) return; try { await updateStatus({ - id: id as Id<"conversations">, + id: resolvedConversationId, status, }); } catch (error) { diff --git a/apps/mobile/app/(app)/index.tsx b/apps/mobile/app/(app)/index.tsx index 2276e97..4d9d3cf 100644 --- a/apps/mobile/app/(app)/index.tsx +++ b/apps/mobile/app/(app)/index.tsx @@ -1,50 +1,15 @@ import { View, Text, StyleSheet, FlatList, TouchableOpacity, RefreshControl } from "react-native"; -import { useQuery } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { useAuth } from "../../src/contexts/AuthContext"; import { router } from "expo-router"; import { useState, useCallback } from "react"; -import type { Id } from "@opencom/convex/dataModel"; - -interface ConversationItem { - _id: string; - visitorId?: string; - status: "open" | "closed" | "snoozed"; - lastMessageAt?: number; - createdAt: number; - unreadByAgent?: number; - visitor: { - name?: string; - email?: string; - readableId?: string; - } | null; - lastMessage: { - content: string; - senderType: string; - createdAt: number; - } | null; -} - -type InboxPageResult = - | ConversationItem[] - | { - conversations: ConversationItem[]; - }; - -const visitorIsOnlineQueryRef = makeFunctionReference< - "query", - { visitorId: Id<"visitors"> }, - boolean ->("visitors:isOnline"); - -const inboxListQueryRef = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces">; status?: "open" | "closed" | "snoozed" }, - InboxPageResult ->("conversations:listForInbox"); +import { useInboxConvex, useVisitorPresenceConvex } from "../../src/hooks/convex/useInboxConvex"; +import type { + MobileConversationItem as ConversationItem, + MobileConversationStatus, +} from "../../src/hooks/convex/types"; function PresenceIndicator({ visitorId }: { visitorId: string }) { - const isOnline = useQuery(visitorIsOnlineQueryRef, { visitorId: visitorId as Id<"visitors"> }); + const { isOnline } = useVisitorPresenceConvex(visitorId); return ( ( - undefined - ); - - const inboxPage = useQuery( - inboxListQueryRef, - activeWorkspaceId ? { workspaceId: activeWorkspaceId, status: statusFilter } : "skip" - ) as InboxPageResult | undefined; - const conversations = (Array.isArray(inboxPage) ? inboxPage : inboxPage?.conversations) as - | ConversationItem[] - | undefined; + const [statusFilter, setStatusFilter] = useState(undefined); + const { inboxPage } = useInboxConvex({ workspaceId: activeWorkspaceId, status: statusFilter }); + const conversations = inboxPage?.conversations as ConversationItem[] | undefined; const onRefresh = useCallback(() => { setRefreshing(true); diff --git a/apps/mobile/app/(app)/onboarding.tsx b/apps/mobile/app/(app)/onboarding.tsx index 08ed266..2256b60 100644 --- a/apps/mobile/app/(app)/onboarding.tsx +++ b/apps/mobile/app/(app)/onboarding.tsx @@ -10,69 +10,12 @@ import { } from "react-native"; import * as Clipboard from "expo-clipboard"; import { router } from "expo-router"; -import { useMutation, useQuery } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { useAuth } from "../../src/contexts/AuthContext"; import { useBackend } from "../../src/contexts/BackendContext"; -import type { Id } from "@opencom/convex/dataModel"; +import { useOnboardingConvex } from "../../src/hooks/convex/useOnboardingConvex"; type VerificationStatus = "idle" | "checking" | "success" | "error"; -type HostedOnboardingState = { - status: "not_started" | "started" | "completed"; - isWidgetVerified: boolean; - verificationToken?: string | null; -} | null; - -type HostedOnboardingIntegrationSignals = { - integrations: Array<{ - id: string; - integrationKey: string; - clientType: string; - clientVersion?: string | null; - status: "recognized" | "active" | "inactive"; - isActiveNow: boolean; - matchesCurrentVerificationWindow: boolean; - origin?: string | null; - currentUrl?: string | null; - clientIdentifier?: string | null; - lastSeenAt?: number | null; - activeSessionCount: number; - detectedAt?: number | null; - metadata?: Record | null; - }>; -} | null; - -const hostedOnboardingStateQueryRef = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - HostedOnboardingState ->("workspaces:getHostedOnboardingState"); - -const hostedOnboardingSignalsQueryRef = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - HostedOnboardingIntegrationSignals ->("workspaces:getHostedOnboardingIntegrationSignals"); - -const startHostedOnboardingMutationRef = makeFunctionReference< - "mutation", - { workspaceId: Id<"workspaces"> }, - null ->("workspaces:startHostedOnboarding"); - -const issueVerificationTokenMutationRef = makeFunctionReference< - "mutation", - { workspaceId: Id<"workspaces"> }, - { token: string } ->("workspaces:issueHostedOnboardingVerificationToken"); - -const completeWidgetStepMutationRef = makeFunctionReference< - "mutation", - { workspaceId: Id<"workspaces">; token?: string }, - { success: boolean } ->("workspaces:completeHostedOnboardingWidgetStep"); - const VERIFY_TIMEOUT_MS = 15000; function formatTimestamp(value: number | null | undefined): string { @@ -108,19 +51,13 @@ export default function OnboardingScreen() { const startRequestedRef = useRef(false); const tokenRequestedRef = useRef(false); const verifyTimeoutRef = useRef | null>(null); - - const onboardingState = useQuery( - hostedOnboardingStateQueryRef, - workspaceId ? { workspaceId } : "skip" - ) as HostedOnboardingState | undefined; - const integrationSignals = useQuery( - hostedOnboardingSignalsQueryRef, - workspaceId ? { workspaceId } : "skip" - ) as HostedOnboardingIntegrationSignals | undefined; - - const startHostedOnboarding = useMutation(startHostedOnboardingMutationRef); - const issueVerificationToken = useMutation(issueVerificationTokenMutationRef); - const completeWidgetStep = useMutation(completeWidgetStepMutationRef); + const { + onboardingState, + integrationSignals, + startHostedOnboarding, + issueVerificationToken, + completeWidgetStep, + } = useOnboardingConvex(workspaceId); useEffect(() => { if (!onboardingState?.verificationToken) { @@ -387,7 +324,10 @@ await OpencomSDK.initialize({ - {signal.origin ?? signal.currentUrl ?? signal.clientIdentifier ?? "Unknown source"} + {signal.origin ?? + signal.currentUrl ?? + signal.clientIdentifier ?? + "Unknown source"} {" · Last seen "} {formatTimestamp(signal.lastSeenAt)} {" · Active sessions "} diff --git a/apps/mobile/app/(app)/settings.tsx b/apps/mobile/app/(app)/settings.tsx index 78a3e4d..eae69b4 100644 --- a/apps/mobile/app/(app)/settings.tsx +++ b/apps/mobile/app/(app)/settings.tsx @@ -14,94 +14,10 @@ import { router } from "expo-router"; import { useAuth } from "../../src/contexts/AuthContext"; import { useBackend } from "../../src/contexts/BackendContext"; import { useNotifications } from "../../src/contexts/NotificationContext"; -import { useQuery, useMutation, useAction } from "convex/react"; -import { makeFunctionReference } from "convex/server"; +import { useSettingsConvex } from "../../src/hooks/convex/useSettingsConvex"; import { useEffect, useState } from "react"; import type { Id } from "@opencom/convex/dataModel"; -type NotificationPreferencesRecord = { - muted: boolean; -} | null; - -type WorkspaceRecord = { - _id: Id<"workspaces">; - allowedOrigins?: string[]; - signupMode?: "invite-only" | "domain-allowlist"; - allowedDomains?: string[]; -} | null; - -type WorkspaceMemberRecord = { - _id: Id<"workspaceMembers">; - userId: Id<"users">; - name?: string; - email?: string; - role: "owner" | "admin" | "agent" | "viewer"; -}; - -const myNotificationPreferencesQueryRef = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - NotificationPreferencesRecord ->("notificationSettings:getMyPreferences"); - -const workspaceGetQueryRef = makeFunctionReference< - "query", - { id: Id<"workspaces"> }, - WorkspaceRecord ->("workspaces:get"); - -const workspaceMembersListQueryRef = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - WorkspaceMemberRecord[] ->("workspaceMembers:listByWorkspace"); - -const pushTokensByUserQueryRef = makeFunctionReference< - "query", - { userId: Id<"users"> }, - Array<{ _id: string }> ->("pushTokens:getByUser"); - -const updateAllowedOriginsMutationRef = makeFunctionReference< - "mutation", - { workspaceId: Id<"workspaces">; allowedOrigins: string[] }, - null ->("workspaces:updateAllowedOrigins"); - -const inviteToWorkspaceActionRef = makeFunctionReference< - "action", - { workspaceId: Id<"workspaces">; email: string; role: "admin" | "agent"; baseUrl: string }, - { status: "added" | "invited" } ->("workspaceMembers:inviteToWorkspace"); - -const updateWorkspaceRoleMutationRef = makeFunctionReference< - "mutation", - { membershipId: Id<"workspaceMembers">; role: "admin" | "agent" }, - null ->("workspaceMembers:updateRole"); - -const removeWorkspaceMemberMutationRef = makeFunctionReference< - "mutation", - { membershipId: Id<"workspaceMembers"> }, - null ->("workspaceMembers:remove"); - -const updateSignupSettingsMutationRef = makeFunctionReference< - "mutation", - { - workspaceId: Id<"workspaces">; - signupMode: "invite-only" | "domain-allowlist"; - allowedDomains: string[]; - }, - null ->("workspaces:updateSignupSettings"); - -const updateMyNotificationPreferencesMutationRef = makeFunctionReference< - "mutation", - { workspaceId: Id<"workspaces">; muted: boolean }, - null ->("notificationSettings:updateMyPreferences"); - export default function SettingsScreen() { const { user, logout, workspaces, activeWorkspace, activeWorkspaceId, switchWorkspace } = useAuth(); @@ -123,31 +39,21 @@ export default function SettingsScreen() { const [signupModalVisible, setSignupModalVisible] = useState(false); const [isSwitchingWorkspace, setIsSwitchingWorkspace] = useState(false); const [pendingWorkspaceId, setPendingWorkspaceId] = useState | null>(null); - const myNotificationPreferences = useQuery( - myNotificationPreferencesQueryRef, - activeWorkspaceId ? { workspaceId: activeWorkspaceId } : "skip" - ) as NotificationPreferencesRecord | undefined; - - const workspace = useQuery( - workspaceGetQueryRef, - activeWorkspaceId ? { id: activeWorkspaceId } : "skip" - ) as WorkspaceRecord | undefined; - - const members = useQuery( - workspaceMembersListQueryRef, - activeWorkspaceId ? { workspaceId: activeWorkspaceId } : "skip" - ) as WorkspaceMemberRecord[] | undefined; - const pushTokens = useQuery( - pushTokensByUserQueryRef, - user?._id ? { userId: user._id as Id<"users"> } : "skip" - ) as Array<{ _id: string }> | undefined; - - const updateAllowedOrigins = useMutation(updateAllowedOriginsMutationRef); - const inviteToWorkspace = useAction(inviteToWorkspaceActionRef); - const updateRole = useMutation(updateWorkspaceRoleMutationRef); - const removeMember = useMutation(removeWorkspaceMemberMutationRef); - const updateSignupSettings = useMutation(updateSignupSettingsMutationRef); - const updateMyNotificationPreferences = useMutation(updateMyNotificationPreferencesMutationRef); + const { + myNotificationPreferences, + workspace, + members, + pushTokens, + updateAllowedOrigins, + inviteToWorkspace, + updateRole, + removeMember, + updateSignupSettings, + updateMyNotificationPreferences, + } = useSettingsConvex({ + workspaceId: activeWorkspaceId, + userId: user?._id, + }); const isAdmin = activeWorkspace?.role === "admin" || activeWorkspace?.role === "owner"; diff --git a/apps/mobile/src/contexts/AuthContext.tsx b/apps/mobile/src/contexts/AuthContext.tsx index 1c8c7e4..7f317c5 100644 --- a/apps/mobile/src/contexts/AuthContext.tsx +++ b/apps/mobile/src/contexts/AuthContext.tsx @@ -1,66 +1,12 @@ import React, { createContext, useContext, useCallback, useEffect, useMemo, useState } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; -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 { useBackend } from "./BackendContext"; +import { useAuthContextConvex, useAuthHomeRouteConvex } from "../hooks/convex/useAuthConvex"; +import type { MobileAuthUser as User, MobileWorkspace as Workspace } from "../hooks/convex/types"; import { parseStoredWorkspaceId, resolveActiveWorkspaceId } from "../utils/workspaceSelection"; -interface User { - _id: Id<"users">; - email: string; - name?: string; - workspaceId: Id<"workspaces">; - role: "owner" | "admin" | "agent" | "viewer"; - avatarUrl?: string; -} - -interface Workspace { - _id: Id<"workspaces">; - name: string; - role: "owner" | "admin" | "agent" | "viewer"; - allowedOrigins?: string[]; -} - -type CurrentUserQueryResult = { - user: User | null; - workspaces: Workspace[]; -} | null; - -type HostedOnboardingState = { - isWidgetVerified: boolean; - verificationToken?: string | null; -} | null; - -const currentUserQueryRef = makeFunctionReference<"query", Record, CurrentUserQueryResult>( - "auth:currentUser" -); - -const switchWorkspaceMutationRef = makeFunctionReference< - "mutation", - { workspaceId: Id<"workspaces"> }, - null ->("auth:switchWorkspace"); - -const completeSignupProfileMutationRef = makeFunctionReference< - "mutation", - { name?: string; workspaceName?: string }, - null ->("auth:completeSignupProfile"); - -const unregisterAllPushTokensMutationRef = makeFunctionReference< - "mutation", - Record, - null ->("pushTokens:unregisterAllForCurrentUser"); - -const hostedOnboardingStateQueryRef = makeFunctionReference< - "query", - { workspaceId: Id<"workspaces"> }, - HostedOnboardingState ->("workspaces:getHostedOnboardingState"); - type HomePath = "/workspace" | "/onboarding" | "/inbox"; interface AuthContextType { @@ -94,19 +40,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { // Convex Auth hooks const { signIn: convexSignIn, signOut: convexSignOut } = useAuthActions(); - - // Query current user from Convex Auth session - const convexAuthUser = useQuery(currentUserQueryRef, {}) as CurrentUserQueryResult | undefined; - const switchWorkspaceMutation = useMutation(switchWorkspaceMutationRef); - const completeSignupProfileMutation = useMutation(completeSignupProfileMutationRef); - const unregisterAllPushTokensMutation = useMutation(unregisterAllPushTokensMutationRef); + const { + currentUser: convexAuthUser, + switchWorkspace: switchWorkspaceMutation, + completeSignupProfile: completeSignupProfileMutation, + unregisterAllPushTokens: unregisterAllPushTokensMutation, + } = useAuthContextConvex(); // Derive state from query - const user = useMemo(() => (convexAuthUser?.user as User | null) ?? null, [convexAuthUser]); - const workspaces = useMemo( - () => (convexAuthUser?.workspaces as Workspace[] | undefined) ?? [], - [convexAuthUser] - ); + const user = useMemo(() => convexAuthUser?.user ?? null, [convexAuthUser]); + const workspaces = useMemo(() => convexAuthUser?.workspaces ?? [], [convexAuthUser]); const workspaceIds = useMemo(() => workspaces.map((workspace) => workspace._id), [workspaces]); const workspaceIdsKey = useMemo(() => workspaceIds.join(","), [workspaceIds]); const workspaceStorageKey = useMemo(() => { @@ -194,12 +137,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const shouldRequireWorkspaceSelection = requiresWorkspaceSelection && workspaces.length > 1; const shouldResolveHostedOnboarding = isAuthenticated && !!workspaceIdForHomeRouting && !shouldRequireWorkspaceSelection; - const hostedOnboardingState = useQuery( - hostedOnboardingStateQueryRef, - shouldResolveHostedOnboarding && workspaceIdForHomeRouting - ? { workspaceId: workspaceIdForHomeRouting } - : "skip" - ) as HostedOnboardingState | undefined; + const { hostedOnboardingState } = useAuthHomeRouteConvex( + workspaceIdForHomeRouting, + shouldResolveHostedOnboarding + ); const isHomeRouteLoading = shouldResolveHostedOnboarding && hostedOnboardingState === undefined; const defaultHomePath: HomePath = shouldRequireWorkspaceSelection ? "/workspace" diff --git a/apps/mobile/src/contexts/NotificationContext.tsx b/apps/mobile/src/contexts/NotificationContext.tsx index 23ac7a3..28bc92c 100644 --- a/apps/mobile/src/contexts/NotificationContext.tsx +++ b/apps/mobile/src/contexts/NotificationContext.tsx @@ -1,11 +1,10 @@ -import React, { createContext, useContext, useEffect, useRef, useState } from "react"; +import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; import * as Notifications from "expo-notifications"; import Constants from "expo-constants"; import { Platform } from "react-native"; -import { useMutation } from "convex/react"; -import { makeFunctionReference } from "convex/server"; import { useAuth } from "./AuthContext"; import { router, usePathname } from "expo-router"; +import { useNotificationRegistrationConvex } from "../hooks/convex/useNotificationRegistrationConvex"; import { getActiveConversationIdFromPath, getConversationIdFromPayload, @@ -13,18 +12,6 @@ import { shouldSuppressForegroundNotification, } from "../utils/notificationRouting"; -const registerPushTokenMutationRef = makeFunctionReference< - "mutation", - { token: string; userId: string; platform: "ios" | "android" }, - null ->("pushTokens:register"); - -const pushDebugLogMutationRef = makeFunctionReference< - "mutation", - { stage: string; details?: string }, - null ->("pushTokens:debugLog"); - interface NotificationContextType { expoPushToken: string | null; notification: Notifications.Notification | null; @@ -58,8 +45,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode } const pathname = usePathname(); const { user, isAuthenticated } = useAuth(); - const registerToken = useMutation(registerPushTokenMutationRef); - const debugLog = useMutation(pushDebugLogMutationRef); + const { registerPushToken: registerToken, debugLog } = useNotificationRegistrationConvex(); useEffect(() => { activeConversationIdRef.current = getActiveConversationIdFromPath(pathname); @@ -87,13 +73,16 @@ export function NotificationProvider({ children }: { children: React.ReactNode } }); }, []); - const sendDebugLog = async (stage: string, details?: string) => { - try { - await debugLog({ stage, details }); - } catch (error) { - console.warn("Failed to write push registration debug log", error); - } - }; + const sendDebugLog = useCallback( + async (stage: string, details?: string) => { + try { + await debugLog({ stage, details }); + } catch (error) { + console.warn("Failed to write push registration debug log", error); + } + }, + [debugLog] + ); useEffect(() => { if (!isAuthenticated || !user) return; @@ -209,7 +198,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode } responseListener.current.remove(); } }; - }, [isAuthenticated, user, registerToken]); + }, [isAuthenticated, user, registerToken, sendDebugLog]); return ( ; + email: string; + name?: string; + workspaceId: Id<"workspaces">; + role: MobileWorkspaceRole; + avatarUrl?: string; +} + +export interface MobileWorkspace { + _id: Id<"workspaces">; + name: string; + role: MobileWorkspaceRole; + allowedOrigins?: string[]; +} + +export type MobileCurrentUserRecord = { + user: MobileAuthUser | null; + workspaces: MobileWorkspace[]; +} | null; + +export type HostedOnboardingStatus = "not_started" | "in_progress" | "completed"; + +export type HostedOnboardingView = { + status: HostedOnboardingStatus; + currentStep: number; + completedSteps: string[]; + onboardingVerificationToken: string | null; + verificationToken: string | null; + verificationTokenIssuedAt: number | null; + widgetVerifiedAt: number | null; + isWidgetVerified: boolean; + updatedAt: number | null; +}; + +export type HostedOnboardingState = + | (HostedOnboardingView & { + hasRecognizedInstall: boolean; + latestDetectedAt: number | null; + latestRecognizedDetectedAt: number | null; + detectedIntegrationCount: number; + }) + | null; + +export type HostedOnboardingVerificationTokenResult = { + token: string; + issuedAt: number; +}; + +export type CompleteHostedOnboardingWidgetStepResult = + | { + success: true; + status: "completed"; + currentStep: number; + completedSteps: string[]; + updatedAt: number; + } + | { + success: false; + reason: "token_mismatch" | "not_verified"; + }; + +export type HostedOnboardingIntegrationSignal = { + id: string; + clientType: string; + clientVersion: string | null; + clientIdentifier: string | null; + origin: string | null; + currentUrl: string | null; + devicePlatform: string | null; + sessionCount: number; + activeSessionCount: number; + lastSeenAt: number; + latestSessionExpiresAt: number; + isActiveNow: boolean; + matchesCurrentVerificationWindow: boolean; +}; + +export type HostedOnboardingIntegrationSignals = { + tokenIssuedAt: number | null; + hasRecognizedInstall: boolean; + latestDetectedAt: number | null; + latestRecognizedDetectedAt: number | null; + integrations: HostedOnboardingIntegrationSignal[]; +} | null; + +export type MobileNotificationPreferencesRecord = { + defaults: { + newVisitorMessageEmail: boolean; + newVisitorMessagePush: boolean; + }; + overrides: { + newVisitorMessageEmail: boolean | null; + newVisitorMessagePush: boolean | null; + }; + effective: { + newVisitorMessageEmail: boolean; + newVisitorMessagePush: boolean; + }; + muted: boolean; +} | null; + +export type MobileWorkspaceRecord = { + _id: Id<"workspaces">; + allowedOrigins?: string[]; + signupMode?: "invite-only" | "domain-allowlist"; + allowedDomains?: string[]; +} | null; + +export interface MobileWorkspaceMemberRecord { + _id: Id<"workspaceMembers">; + userId: Id<"users">; + name?: string; + email?: string; + role: MobileWorkspaceRole; +} + +export type InviteToWorkspaceResult = { + status: "added" | "invited"; +}; + +export type MobilePushTokenRecord = { + _id: string; +}; + +export interface MobileConversationItem { + _id: string; + visitorId?: string; + status: MobileConversationStatus; + lastMessageAt?: number; + createdAt: number; + unreadByAgent?: number; + visitor: { + name?: string; + email?: string; + readableId?: string; + } | null; + lastMessage: { + content: string; + senderType: string; + createdAt: number; + } | null; +} + +export type MobileInboxPageResult = { + conversations: MobileConversationItem[]; + nextCursor: string | null; +}; + +export type MobileConversationRecord = { + _id: Id<"conversations">; + visitorId?: Id<"visitors">; + status: MobileConversationStatus; +}; + +export type MobileVisitorRecord = { + _id: Id<"visitors">; + name?: string; + email?: string; + readableId?: string; + location?: { city?: string; country?: string }; + device?: { browser?: string; os?: string }; +} | null; + +export interface MobileConversationMessage { + _id: string; + content: string; + senderType: "user" | "visitor" | "agent" | "bot"; + createdAt: number; +} diff --git a/apps/mobile/src/hooks/convex/useAuthConvex.ts b/apps/mobile/src/hooks/convex/useAuthConvex.ts new file mode 100644 index 0000000..b24fd0b --- /dev/null +++ b/apps/mobile/src/hooks/convex/useAuthConvex.ts @@ -0,0 +1,79 @@ +import type { Id } from "@opencom/convex/dataModel"; +import { makeFunctionReference } from "convex/server"; +import { useMobileMutation, useMobileQuery } from "../../lib/convex/hooks"; +import type { HostedOnboardingState, MobileAuthUser, MobileCurrentUserRecord } from "./types"; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type SwitchWorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type CompleteSignupProfileArgs = { + name?: string; + workspaceName?: string; +}; + +type SwitchWorkspaceResult = { + user: MobileAuthUser; +}; + +type CompleteSignupProfileResult = { + success: true; + userNameUpdated: boolean; + workspaceNameUpdated: boolean; +}; + +type UnregisterAllPushTokensResult = { + success: true; + removed: number; +}; + +const CURRENT_USER_QUERY_REF = makeFunctionReference< + "query", + Record, + MobileCurrentUserRecord +>("auth:currentUser"); +const SWITCH_WORKSPACE_MUTATION_REF = makeFunctionReference< + "mutation", + SwitchWorkspaceArgs, + SwitchWorkspaceResult +>("auth:switchWorkspace"); +const COMPLETE_SIGNUP_PROFILE_MUTATION_REF = makeFunctionReference< + "mutation", + CompleteSignupProfileArgs, + CompleteSignupProfileResult +>("auth:completeSignupProfile"); +const UNREGISTER_ALL_PUSH_TOKENS_MUTATION_REF = makeFunctionReference< + "mutation", + Record, + UnregisterAllPushTokensResult +>("pushTokens:unregisterAllForCurrentUser"); +const HOSTED_ONBOARDING_STATE_QUERY_REF = makeFunctionReference< + "query", + WorkspaceArgs, + HostedOnboardingState +>("workspaces:getHostedOnboardingState"); + +export function useAuthContextConvex() { + return { + completeSignupProfile: useMobileMutation(COMPLETE_SIGNUP_PROFILE_MUTATION_REF), + currentUser: useMobileQuery(CURRENT_USER_QUERY_REF, {}), + switchWorkspace: useMobileMutation(SWITCH_WORKSPACE_MUTATION_REF), + unregisterAllPushTokens: useMobileMutation(UNREGISTER_ALL_PUSH_TOKENS_MUTATION_REF), + }; +} + +export function useAuthHomeRouteConvex( + workspaceIdForHomeRouting?: Id<"workspaces"> | null, + enabled = true +) { + return { + hostedOnboardingState: useMobileQuery( + HOSTED_ONBOARDING_STATE_QUERY_REF, + enabled && workspaceIdForHomeRouting ? { workspaceId: workspaceIdForHomeRouting } : "skip" + ), + }; +} diff --git a/apps/mobile/src/hooks/convex/useConversationConvex.ts b/apps/mobile/src/hooks/convex/useConversationConvex.ts new file mode 100644 index 0000000..6e45957 --- /dev/null +++ b/apps/mobile/src/hooks/convex/useConversationConvex.ts @@ -0,0 +1,95 @@ +import type { Id } from "@opencom/convex/dataModel"; +import { makeFunctionReference } from "convex/server"; +import { useMobileMutation, useMobileQuery } from "../../lib/convex/hooks"; +import type { + MobileConversationMessage, + MobileConversationRecord, + MobileConversationStatus, + MobileVisitorRecord, +} from "./types"; + +type ConversationIdArgs = { + id: Id<"conversations">; +}; + +type MessagesListArgs = { + conversationId: Id<"conversations">; +}; + +type SendMessageArgs = { + conversationId: Id<"conversations">; + senderId: Id<"users">; + senderType: "agent"; + content: string; +}; + +type UpdateConversationStatusArgs = { + id: Id<"conversations">; + status: MobileConversationStatus; +}; + +type MarkConversationReadArgs = { + id: Id<"conversations">; + readerType: "agent" | "visitor"; +}; + +const CONVERSATION_GET_QUERY_REF = makeFunctionReference< + "query", + ConversationIdArgs, + MobileConversationRecord | null +>("conversations:get"); +const VISITOR_GET_QUERY_REF = makeFunctionReference< + "query", + { id: Id<"visitors"> }, + MobileVisitorRecord +>("visitors:get"); +const MESSAGES_LIST_QUERY_REF = makeFunctionReference< + "query", + MessagesListArgs, + MobileConversationMessage[] +>("messages:list"); +const SEND_MESSAGE_MUTATION_REF = makeFunctionReference< + "mutation", + SendMessageArgs, + Id<"messages"> +>("messages:send"); +const UPDATE_CONVERSATION_STATUS_MUTATION_REF = makeFunctionReference< + "mutation", + UpdateConversationStatusArgs, + null +>("conversations:updateStatus"); +const MARK_CONVERSATION_READ_MUTATION_REF = makeFunctionReference< + "mutation", + MarkConversationReadArgs, + null +>("conversations:markAsRead"); + +function resolveConversationId( + conversationId?: string | Id<"conversations"> | null +): Id<"conversations"> | null { + return conversationId ? (conversationId as Id<"conversations">) : null; +} + +export function useConversationConvex(conversationId?: string | Id<"conversations"> | null) { + const resolvedConversationId = resolveConversationId(conversationId); + const conversation = useMobileQuery( + CONVERSATION_GET_QUERY_REF, + resolvedConversationId ? { id: resolvedConversationId } : "skip" + ); + + return { + conversation, + markConversationRead: useMobileMutation(MARK_CONVERSATION_READ_MUTATION_REF), + messages: useMobileQuery( + MESSAGES_LIST_QUERY_REF, + resolvedConversationId ? { conversationId: resolvedConversationId } : "skip" + ), + resolvedConversationId, + sendMessage: useMobileMutation(SEND_MESSAGE_MUTATION_REF), + updateConversationStatus: useMobileMutation(UPDATE_CONVERSATION_STATUS_MUTATION_REF), + visitor: useMobileQuery( + VISITOR_GET_QUERY_REF, + conversation?.visitorId ? { id: conversation.visitorId } : "skip" + ), + }; +} diff --git a/apps/mobile/src/hooks/convex/useInboxConvex.ts b/apps/mobile/src/hooks/convex/useInboxConvex.ts new file mode 100644 index 0000000..63af4e7 --- /dev/null +++ b/apps/mobile/src/hooks/convex/useInboxConvex.ts @@ -0,0 +1,49 @@ +import type { Id } from "@opencom/convex/dataModel"; +import { makeFunctionReference } from "convex/server"; +import { useMobileQuery } from "../../lib/convex/hooks"; +import type { MobileConversationStatus, MobileInboxPageResult } from "./types"; + +type VisitorArgs = { + visitorId: Id<"visitors">; +}; + +type InboxArgs = { + workspaceId: Id<"workspaces">; + status?: MobileConversationStatus; +}; + +const VISITOR_IS_ONLINE_QUERY_REF = makeFunctionReference<"query", VisitorArgs, boolean>( + "visitors:isOnline" +); +const INBOX_LIST_QUERY_REF = makeFunctionReference<"query", InboxArgs, MobileInboxPageResult>( + "conversations:listForInbox" +); + +type UseInboxConvexOptions = { + workspaceId?: Id<"workspaces"> | null; + status?: MobileConversationStatus; +}; + +export function useInboxConvex({ workspaceId, status }: UseInboxConvexOptions) { + const inboxArgs = workspaceId + ? { + workspaceId, + ...(status ? { status } : {}), + } + : "skip"; + + return { + inboxPage: useMobileQuery(INBOX_LIST_QUERY_REF, inboxArgs), + }; +} + +export function useVisitorPresenceConvex(visitorId?: string | Id<"visitors"> | null) { + const resolvedVisitorId = visitorId ? (visitorId as Id<"visitors">) : null; + + return { + isOnline: useMobileQuery( + VISITOR_IS_ONLINE_QUERY_REF, + resolvedVisitorId ? { visitorId: resolvedVisitorId } : "skip" + ), + }; +} diff --git a/apps/mobile/src/hooks/convex/useNotificationRegistrationConvex.ts b/apps/mobile/src/hooks/convex/useNotificationRegistrationConvex.ts new file mode 100644 index 0000000..f886f33 --- /dev/null +++ b/apps/mobile/src/hooks/convex/useNotificationRegistrationConvex.ts @@ -0,0 +1,37 @@ +import type { Id } from "@opencom/convex/dataModel"; +import { makeFunctionReference } from "convex/server"; +import { useMobileMutation } from "../../lib/convex/hooks"; + +type RegisterPushTokenArgs = { + token: string; + userId: Id<"users">; + platform: "ios" | "android"; +}; + +type PushDebugLogArgs = { + stage: string; + details?: string; +}; + +type PushDebugLogResult = { + success: true; + authUserId: Id<"users"> | null; +}; + +const REGISTER_PUSH_TOKEN_MUTATION_REF = makeFunctionReference< + "mutation", + RegisterPushTokenArgs, + Id<"pushTokens"> +>("pushTokens:register"); +const PUSH_DEBUG_LOG_MUTATION_REF = makeFunctionReference< + "mutation", + PushDebugLogArgs, + PushDebugLogResult +>("pushTokens:debugLog"); + +export function useNotificationRegistrationConvex() { + return { + debugLog: useMobileMutation(PUSH_DEBUG_LOG_MUTATION_REF), + registerPushToken: useMobileMutation(REGISTER_PUSH_TOKEN_MUTATION_REF), + }; +} diff --git a/apps/mobile/src/hooks/convex/useOnboardingConvex.ts b/apps/mobile/src/hooks/convex/useOnboardingConvex.ts new file mode 100644 index 0000000..f92f895 --- /dev/null +++ b/apps/mobile/src/hooks/convex/useOnboardingConvex.ts @@ -0,0 +1,61 @@ +import type { Id } from "@opencom/convex/dataModel"; +import { makeFunctionReference } from "convex/server"; +import { useMobileMutation, useMobileQuery } from "../../lib/convex/hooks"; +import type { + CompleteHostedOnboardingWidgetStepResult, + HostedOnboardingIntegrationSignals, + HostedOnboardingState, + HostedOnboardingVerificationTokenResult, + HostedOnboardingView, +} from "./types"; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type CompleteWidgetStepArgs = { + workspaceId: Id<"workspaces">; + token?: string; +}; + +const HOSTED_ONBOARDING_STATE_QUERY_REF = makeFunctionReference< + "query", + WorkspaceArgs, + HostedOnboardingState +>("workspaces:getHostedOnboardingState"); +const HOSTED_ONBOARDING_SIGNALS_QUERY_REF = makeFunctionReference< + "query", + WorkspaceArgs, + HostedOnboardingIntegrationSignals +>("workspaces:getHostedOnboardingIntegrationSignals"); +const START_HOSTED_ONBOARDING_MUTATION_REF = makeFunctionReference< + "mutation", + WorkspaceArgs, + HostedOnboardingView +>("workspaces:startHostedOnboarding"); +const ISSUE_VERIFICATION_TOKEN_MUTATION_REF = makeFunctionReference< + "mutation", + WorkspaceArgs, + HostedOnboardingVerificationTokenResult +>("workspaces:issueHostedOnboardingVerificationToken"); +const COMPLETE_WIDGET_STEP_MUTATION_REF = makeFunctionReference< + "mutation", + CompleteWidgetStepArgs, + CompleteHostedOnboardingWidgetStepResult +>("workspaces:completeHostedOnboardingWidgetStep"); + +export function useOnboardingConvex(workspaceId?: Id<"workspaces"> | null) { + return { + completeWidgetStep: useMobileMutation(COMPLETE_WIDGET_STEP_MUTATION_REF), + integrationSignals: useMobileQuery( + HOSTED_ONBOARDING_SIGNALS_QUERY_REF, + workspaceId ? { workspaceId } : "skip" + ), + issueVerificationToken: useMobileMutation(ISSUE_VERIFICATION_TOKEN_MUTATION_REF), + onboardingState: useMobileQuery( + HOSTED_ONBOARDING_STATE_QUERY_REF, + workspaceId ? { workspaceId } : "skip" + ), + startHostedOnboarding: useMobileMutation(START_HOSTED_ONBOARDING_MUTATION_REF), + }; +} diff --git a/apps/mobile/src/hooks/convex/useSettingsConvex.ts b/apps/mobile/src/hooks/convex/useSettingsConvex.ts new file mode 100644 index 0000000..44fd7c8 --- /dev/null +++ b/apps/mobile/src/hooks/convex/useSettingsConvex.ts @@ -0,0 +1,137 @@ +import type { Id } from "@opencom/convex/dataModel"; +import { makeFunctionReference } from "convex/server"; +import { useMobileAction, useMobileMutation, useMobileQuery } from "../../lib/convex/hooks"; +import type { + InviteToWorkspaceResult, + MobileNotificationPreferencesRecord, + MobilePushTokenRecord, + MobileWorkspaceMemberRecord, + MobileWorkspaceRecord, +} from "./types"; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type WorkspaceGetArgs = { + id: Id<"workspaces">; +}; + +type PushTokensByUserArgs = { + userId: Id<"users">; +}; + +type UpdateAllowedOriginsArgs = { + workspaceId: Id<"workspaces">; + allowedOrigins: string[]; +}; + +type InviteToWorkspaceArgs = { + workspaceId: Id<"workspaces">; + email: string; + role: "admin" | "agent"; + baseUrl: string; +}; + +type UpdateWorkspaceRoleArgs = { + membershipId: Id<"workspaceMembers">; + role: "admin" | "agent"; +}; + +type RemoveWorkspaceMemberArgs = { + membershipId: Id<"workspaceMembers">; +}; + +type UpdateSignupSettingsArgs = { + workspaceId: Id<"workspaces">; + signupMode: "invite-only" | "domain-allowlist"; + allowedDomains: string[]; +}; + +type UpdateMyNotificationPreferencesArgs = { + workspaceId: Id<"workspaces">; + muted: boolean; +}; + +type MutationSuccessResult = { + success: true; +}; + +const MY_NOTIFICATION_PREFERENCES_QUERY_REF = makeFunctionReference< + "query", + WorkspaceArgs, + MobileNotificationPreferencesRecord +>("notificationSettings:getMyPreferences"); +const WORKSPACE_GET_QUERY_REF = makeFunctionReference< + "query", + WorkspaceGetArgs, + MobileWorkspaceRecord +>("workspaces:get"); +const WORKSPACE_MEMBERS_LIST_QUERY_REF = makeFunctionReference< + "query", + WorkspaceArgs, + MobileWorkspaceMemberRecord[] +>("workspaceMembers:listByWorkspace"); +const PUSH_TOKENS_BY_USER_QUERY_REF = makeFunctionReference< + "query", + PushTokensByUserArgs, + MobilePushTokenRecord[] +>("pushTokens:getByUser"); +const UPDATE_ALLOWED_ORIGINS_MUTATION_REF = makeFunctionReference< + "mutation", + UpdateAllowedOriginsArgs, + void +>("workspaces:updateAllowedOrigins"); +const INVITE_TO_WORKSPACE_ACTION_REF = makeFunctionReference< + "action", + InviteToWorkspaceArgs, + InviteToWorkspaceResult +>("workspaceMembers:inviteToWorkspace"); +const UPDATE_WORKSPACE_ROLE_MUTATION_REF = makeFunctionReference< + "mutation", + UpdateWorkspaceRoleArgs, + MutationSuccessResult +>("workspaceMembers:updateRole"); +const REMOVE_WORKSPACE_MEMBER_MUTATION_REF = makeFunctionReference< + "mutation", + RemoveWorkspaceMemberArgs, + MutationSuccessResult +>("workspaceMembers:remove"); +const UPDATE_SIGNUP_SETTINGS_MUTATION_REF = makeFunctionReference< + "mutation", + UpdateSignupSettingsArgs, + void +>("workspaces:updateSignupSettings"); +const UPDATE_MY_NOTIFICATION_PREFERENCES_MUTATION_REF = makeFunctionReference< + "mutation", + UpdateMyNotificationPreferencesArgs, + Id<"notificationPreferences"> +>("notificationSettings:updateMyPreferences"); + +type UseSettingsConvexOptions = { + workspaceId?: Id<"workspaces"> | null; + userId?: Id<"users"> | null; +}; + +export function useSettingsConvex({ workspaceId, userId }: UseSettingsConvexOptions) { + return { + inviteToWorkspace: useMobileAction(INVITE_TO_WORKSPACE_ACTION_REF), + members: useMobileQuery( + WORKSPACE_MEMBERS_LIST_QUERY_REF, + workspaceId ? { workspaceId } : "skip" + ), + myNotificationPreferences: useMobileQuery( + MY_NOTIFICATION_PREFERENCES_QUERY_REF, + workspaceId ? { workspaceId } : "skip" + ), + pushTokens: useMobileQuery(PUSH_TOKENS_BY_USER_QUERY_REF, userId ? { userId } : "skip"), + removeMember: useMobileMutation(REMOVE_WORKSPACE_MEMBER_MUTATION_REF), + updateAllowedOrigins: useMobileMutation(UPDATE_ALLOWED_ORIGINS_MUTATION_REF), + updateMyNotificationPreferences: useMobileMutation( + UPDATE_MY_NOTIFICATION_PREFERENCES_MUTATION_REF + ), + updateRole: useMobileMutation(UPDATE_WORKSPACE_ROLE_MUTATION_REF), + updateSignupSettings: useMobileMutation(UPDATE_SIGNUP_SETTINGS_MUTATION_REF), + workspace: useMobileQuery(WORKSPACE_GET_QUERY_REF, workspaceId ? { id: workspaceId } : "skip"), + }; +} diff --git a/apps/mobile/src/lib/convex/hooks.ts b/apps/mobile/src/lib/convex/hooks.ts new file mode 100644 index 0000000..dbe5849 --- /dev/null +++ b/apps/mobile/src/lib/convex/hooks.ts @@ -0,0 +1,59 @@ +import { + type OptionalRestArgsOrSkip, + type ReactAction, + type ReactMutation, + useAction, + useMutation, + useQuery, +} from "convex/react"; +import type { FunctionReference } from "convex/server"; + +type MobileArgs = Record; + +export type MobileQueryRef = FunctionReference< + "query", + "public", + Args, + Result +>; + +export type MobileMutationRef = FunctionReference< + "mutation", + "public", + Args, + Result +>; + +export type MobileActionRef = FunctionReference< + "action", + "public", + Args, + Result +>; + +function toMobileQueryArgs( + args: Args | "skip" +): OptionalRestArgsOrSkip> { + return (args === "skip" ? ["skip"] : [args]) as OptionalRestArgsOrSkip< + MobileQueryRef + >; +} + +export function useMobileQuery( + queryRef: MobileQueryRef, + args: Args | "skip" +): Result | undefined { + return useQuery(queryRef, ...toMobileQueryArgs(args)); +} + +export function useMobileMutation( + mutationRef: MobileMutationRef +): ReactMutation> { + return useMutation(mutationRef); +} + +export function useMobileAction( + actionRef: MobileActionRef +): ReactAction> { + return useAction(actionRef); +} diff --git a/apps/mobile/src/typeHardeningGuard.test.ts b/apps/mobile/src/typeHardeningGuard.test.ts new file mode 100644 index 0000000..e165abd --- /dev/null +++ b/apps/mobile/src/typeHardeningGuard.test.ts @@ -0,0 +1,181 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, extname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const MOBILE_SRC_DIR = dirname(fileURLToPath(import.meta.url)); +const MOBILE_ROOT_DIR = resolve(MOBILE_SRC_DIR, ".."); +const MOBILE_APP_DIR = resolve(MOBILE_ROOT_DIR, "app"); + +const MOBILE_CONVEX_ADAPTER_PATH = resolve(MOBILE_SRC_DIR, "lib/convex/hooks.ts"); +const MOBILE_PROVIDER_BOUNDARY_PATH = resolve(MOBILE_ROOT_DIR, "app/_layout.tsx"); +const APPROVED_DIRECT_CONVEX_IMPORT_FILES = [ + MOBILE_CONVEX_ADAPTER_PATH, + MOBILE_PROVIDER_BOUNDARY_PATH, + resolve(MOBILE_SRC_DIR, "typeHardeningGuard.test.ts"), +]; + +const WRAPPER_LAYER_FILES = [ + "hooks/convex/useAuthConvex.ts", + "hooks/convex/useConversationConvex.ts", + "hooks/convex/useInboxConvex.ts", + "hooks/convex/useNotificationRegistrationConvex.ts", + "hooks/convex/useOnboardingConvex.ts", + "hooks/convex/useSettingsConvex.ts", +].map((path) => resolve(MOBILE_SRC_DIR, path)); +const APPROVED_DIRECT_REF_FACTORY_FILES = [ + ...WRAPPER_LAYER_FILES, + resolve(MOBILE_SRC_DIR, "typeHardeningGuard.test.ts"), +]; + +const MIGRATED_MOBILE_CONSUMERS = [ + ["src/contexts/AuthContext.tsx", ["useAuthContextConvex", "useAuthHomeRouteConvex"]], + ["src/contexts/NotificationContext.tsx", ["useNotificationRegistrationConvex"]], + ["app/(app)/conversation/[id].tsx", ["useConversationConvex"]], + ["app/(app)/index.tsx", ["useInboxConvex", "useVisitorPresenceConvex"]], + ["app/(app)/onboarding.tsx", ["useOnboardingConvex"]], + ["app/(app)/settings.tsx", ["useSettingsConvex"]], +] as const; + +const DIRECT_CONVEX_IMPORT_PATTERN = /from ["']convex\/react["']/; +const DIRECT_REF_FACTORY_PATTERN = /\bmakeFunctionReference(?:\s*<[\s\S]*?>)?\s*\(/; +const MOBILE_ADAPTER_HOOK_PATTERN = /\buseMobile(?:Query|Mutation|Action)\b/; +const COMPONENT_SCOPED_CONVEX_REF_PATTERNS = [ + /^\s{2,}(const|let)\s+\w+\s*=\s*makeFunctionReference(?:<|\()/, + /use(?:Query|Mutation|Action)\(\s*makeFunctionReference(?:<|\()/, +]; + +function collectSourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const entryPath = resolve(dir, entry.name); + + if (entry.isDirectory()) { + return collectSourceFiles(entryPath); + } + + if (!entry.isFile()) { + return []; + } + + const extension = extname(entry.name); + return extension === ".ts" || extension === ".tsx" ? [entryPath] : []; + }); +} + +function toPortableRelativePath(filePath: string): string { + return relative(MOBILE_ROOT_DIR, filePath).replace(/\\/g, "/"); +} + +function isApprovedDirectConvexImport(filePath: string): boolean { + return APPROVED_DIRECT_CONVEX_IMPORT_FILES.includes(filePath); +} + +function isApprovedDirectRefFactory(filePath: string): boolean { + return APPROVED_DIRECT_REF_FACTORY_FILES.includes(filePath); +} + +function findUnexpectedMobileDirectConvexBoundaries(): string[] { + return [...collectSourceFiles(MOBILE_APP_DIR), ...collectSourceFiles(MOBILE_SRC_DIR)].flatMap( + (filePath) => { + const source = readFileSync(filePath, "utf8"); + const violations: string[] = []; + + if (DIRECT_CONVEX_IMPORT_PATTERN.test(source) && !isApprovedDirectConvexImport(filePath)) { + violations.push(`${toPortableRelativePath(filePath)}: direct convex/react import`); + } + + if (DIRECT_REF_FACTORY_PATTERN.test(source) && !isApprovedDirectRefFactory(filePath)) { + violations.push(`${toPortableRelativePath(filePath)}: direct makeFunctionReference call`); + } + + return violations; + } + ); +} + +function findComponentScopedConvexRefs(dir: string): string[] { + return collectSourceFiles(dir).flatMap((filePath) => { + if (!isApprovedDirectRefFactory(filePath)) { + return []; + } + + const source = readFileSync(filePath, "utf8"); + + return source + .split("\n") + .flatMap((line, index) => + COMPONENT_SCOPED_CONVEX_REF_PATTERNS.some((pattern) => pattern.test(line)) + ? [`${toPortableRelativePath(filePath)}:${index + 1}`] + : [] + ); + }); +} + +describe("mobile convex ref hardening guards", () => { + it("keeps mobile React files free of component-scoped Convex ref factories", () => { + expect([ + ...findComponentScopedConvexRefs(MOBILE_APP_DIR), + ...findComponentScopedConvexRefs(MOBILE_SRC_DIR), + ]).toEqual([]); + }); + + it("keeps direct convex imports and ref factories limited to approved boundaries", () => { + expect(findUnexpectedMobileDirectConvexBoundaries()).toEqual([]); + }); + + it("keeps the approved direct convex import boundaries explicit", () => { + expect( + APPROVED_DIRECT_CONVEX_IMPORT_FILES.map((filePath) => toPortableRelativePath(filePath)) + ).toEqual(["src/lib/convex/hooks.ts", "app/_layout.tsx", "src/typeHardeningGuard.test.ts"]); + }); + + it("keeps the approved direct ref factory files explicit", () => { + expect( + APPROVED_DIRECT_REF_FACTORY_FILES.map((filePath) => toPortableRelativePath(filePath)) + ).toEqual([ + "src/hooks/convex/useAuthConvex.ts", + "src/hooks/convex/useConversationConvex.ts", + "src/hooks/convex/useInboxConvex.ts", + "src/hooks/convex/useNotificationRegistrationConvex.ts", + "src/hooks/convex/useOnboardingConvex.ts", + "src/hooks/convex/useSettingsConvex.ts", + "src/typeHardeningGuard.test.ts", + ]); + }); + + it("provides a mobile-local Convex adapter layer for typed wrapper hooks", () => { + const source = readFileSync(MOBILE_CONVEX_ADAPTER_PATH, "utf8"); + + expect(source).toContain("export type MobileQueryRef"); + expect(source).toContain("export type MobileMutationRef"); + expect(source).toContain("export type MobileActionRef"); + expect(source).toContain("export function useMobileQuery"); + expect(source).toContain("export function useMobileMutation"); + expect(source).toContain("export function useMobileAction"); + expect(source).toContain("function toMobileQueryArgs"); + expect(source).toContain("OptionalRestArgsOrSkip"); + expect(source).not.toContain("makeFunctionReference("); + }); + + it("keeps wrapper-layer escape hatches in mobile-local wrapper files", () => { + for (const filePath of WRAPPER_LAYER_FILES) { + const source = readFileSync(filePath, "utf8"); + + expect(MOBILE_ADAPTER_HOOK_PATTERN.test(source)).toBe(true); + expect(DIRECT_REF_FACTORY_PATTERN.test(source)).toBe(true); + } + }); + + it("keeps migrated mobile consumers on local wrapper hooks", () => { + for (const [relativePath, markers] of MIGRATED_MOBILE_CONSUMERS) { + const source = readFileSync(resolve(MOBILE_ROOT_DIR, relativePath), "utf8"); + + expect(source).not.toContain('from "convex/react"'); + expect(source).not.toContain("makeFunctionReference("); + + for (const marker of markers) { + expect(source).toContain(marker); + } + } + }); +}); diff --git a/apps/web/src/app/articles/ArticlesImportSection.tsx b/apps/web/src/app/articles/ArticlesImportSection.tsx index 2e43738..de6675c 100644 --- a/apps/web/src/app/articles/ArticlesImportSection.tsx +++ b/apps/web/src/app/articles/ArticlesImportSection.tsx @@ -68,7 +68,7 @@ export function ArticlesImportSection({ onExportSourceChange, onExportMarkdown, onRestoreRun, -}: ArticlesImportSectionProps) { +}: ArticlesImportSectionProps): React.JSX.Element { return (
diff --git a/docs/convex-type-safety-playbook.md b/docs/convex-type-safety-playbook.md index a8f99f5..ae56fe3 100644 --- a/docs/convex-type-safety-playbook.md +++ b/docs/convex-type-safety-playbook.md @@ -21,14 +21,14 @@ Historical hardening notes still exist in `openspec/archive/refactor-*` and `run ## Decision Table -| Situation | Preferred approach | Where | Why | -| --- | --- | --- | --- | -| Define a new public Convex query or mutation | Export a normal Convex function with narrow `v.*` args and a narrow return shape | `packages/convex/convex/**` | Keeps the source contract explicit and reusable | -| Call Convex from web or widget UI/runtime code | Use the local surface adapter plus a feature-local wrapper hook or fixed ref constant | `apps/web/src/**`, `apps/widget/src/**` | Keeps `convex/react` and ref typing out of runtime/UI modules | -| Call one Convex function from another and generated refs typecheck normally | Use generated `api.*` / `internal.*` refs | `packages/convex/convex/**` | This is the default, simplest path | -| Call one Convex function from another and generated refs hit `TS2589` | Add a local shallow `runQuery` / `runMutation` / `runAction` / `runAfter` helper | the hotspot file only | Shrinks type instantiation at the call boundary | -| The generated ref itself still triggers `TS2589` | Replace only that hot ref with a fixed, typed `makeFunctionReference("module:function")` constant | the hotspot file only | Avoids broad weakening of the entire module | -| Convex React hook tuple typing still needs help | Keep a tiny adapter-local helper/cast in the surface adapter | adapter file only | Localizes the last unavoidable boundary | +| Situation | Preferred approach | Where | Why | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------- | +| Define a new public Convex query or mutation | Export a normal Convex function with narrow `v.*` args and a narrow return shape | `packages/convex/convex/**` | Keeps the source contract explicit and reusable | +| Call Convex from web or widget UI/runtime code | Use the local surface adapter plus a feature-local wrapper hook or fixed ref constant | `apps/web/src/**`, `apps/widget/src/**` | Keeps `convex/react` and ref typing out of runtime/UI modules | +| Call one Convex function from another and generated refs typecheck normally | Use generated `api.*` / `internal.*` refs | `packages/convex/convex/**` | This is the default, simplest path | +| Call one Convex function from another and generated refs hit `TS2589` | Add a local shallow `runQuery` / `runMutation` / `runAction` / `runAfter` helper | the hotspot file only | Shrinks type instantiation at the call boundary | +| The generated ref itself still triggers `TS2589` | Replace only that hot ref with a fixed, typed `makeFunctionReference("module:function")` constant | the hotspot file only | Avoids broad weakening of the entire module | +| Convex React hook tuple typing still needs help | Keep a tiny adapter-local helper/cast in the surface adapter | adapter file only | Localizes the last unavoidable boundary | ## Non-Negotiable Rules @@ -50,6 +50,7 @@ The current hardening guards freeze these boundaries: - `apps/web/src/app/typeHardeningGuard.test.ts` - `apps/widget/src/test/refHardeningGuard.test.ts` +- `apps/mobile/src/typeHardeningGuard.test.ts` - `packages/react-native-sdk/tests/hookBoundaryGuard.test.ts` ### 2. Do not create refs inside React components or hooks @@ -181,9 +182,7 @@ export function useVisitorTickets( ) { return useWidgetQuery( VISITOR_TICKETS_REF, - workspaceId && visitorId && sessionToken - ? { workspaceId, visitorId, sessionToken } - : "skip" + workspaceId && visitorId && sessionToken ? { workspaceId, visitorId, sessionToken } : "skip" ); } ``` @@ -263,16 +262,9 @@ type ConvexRef< Return = unknown, > = FunctionReference; -const DELIVER_NOTIFICATION_REF = makeFunctionReference< - "mutation", - DeliverArgs, - DeliverResult ->("notifications:deliver") as unknown as ConvexRef< - "mutation", - "internal", - DeliverArgs, - DeliverResult ->; +const DELIVER_NOTIFICATION_REF = makeFunctionReference<"mutation", DeliverArgs, DeliverResult>( + "notifications:deliver" +) as unknown as ConvexRef<"mutation", "internal", DeliverArgs, DeliverResult>; ``` Use this only after the generated ref path proved pathological. @@ -335,6 +327,7 @@ Use this only after the generated ref path proved pathological. - Target the same pattern as web/widget: local wrapper hooks plus module-scope typed refs. - Do not add new direct `convex/react` usage to screens, contexts, or controller-style hooks. - If a local adapter/wrapper does not exist for the feature yet, create one instead of importing hooks directly into runtime UI. +- Guard coverage lives in `apps/mobile/src/typeHardeningGuard.test.ts`. ## Anti-Patterns To Avoid @@ -366,6 +359,7 @@ pnpm --filter @opencom/widget typecheck pnpm --filter @opencom/convex test -- --run tests/runtimeTypeHardeningGuard.test.ts pnpm --filter @opencom/web test -- --run src/app/typeHardeningGuard.test.ts pnpm --filter @opencom/widget test -- --run src/test/refHardeningGuard.test.ts +pnpm exec vitest run --config apps/mobile/vitest.config.ts apps/mobile/src/typeHardeningGuard.test.ts ``` ## Review Rule of Thumb diff --git a/openspec/changes/introduce-mobile-local-convex-wrapper-hooks/tasks.md b/openspec/changes/introduce-mobile-local-convex-wrapper-hooks/tasks.md index e38bac9..a7a4950 100644 --- a/openspec/changes/introduce-mobile-local-convex-wrapper-hooks/tasks.md +++ b/openspec/changes/introduce-mobile-local-convex-wrapper-hooks/tasks.md @@ -1,24 +1,24 @@ ## 1. Foundation -- [ ] 1.1 Create a minimal mobile-local Convex adapter layer for thin typed hook primitives. -- [ ] 1.2 Define conventions for mobile domain wrapper placement, naming, and explicit local typing boundaries. -- [ ] 1.3 Ensure unavoidable casts or `@ts-expect-error` usage are centralized outside mobile screens and context files, with `app/_layout.tsx` remaining the only accepted direct provider boundary. +- [x] 1.1 Create a minimal mobile-local Convex adapter layer for thin typed hook primitives. +- [x] 1.2 Define conventions for mobile domain wrapper placement, naming, and explicit local typing boundaries. +- [x] 1.3 Ensure unavoidable casts or `@ts-expect-error` usage are centralized outside mobile screens and context files, with `app/_layout.tsx` remaining the only accepted direct provider boundary. ## 2. Initial domain migrations -- [ ] 2.1 Add wrapper hooks for auth/workspace selection and onboarding domains, then migrate `apps/mobile/src/contexts/AuthContext.tsx` and `apps/mobile/app/(app)/onboarding.tsx`. -- [ ] 2.2 Add wrapper hooks for notification and settings domains, then migrate `apps/mobile/src/contexts/NotificationContext.tsx` and `apps/mobile/app/(app)/settings.tsx`. -- [ ] 2.3 Add wrapper hooks for inbox and conversation flows, then migrate `apps/mobile/app/(app)/index.tsx` and `apps/mobile/app/(app)/conversation/[id].tsx`. -- [ ] 2.4 Confirm no new mobile screen or context file outside the provider boundary imports `convex/react` directly once its domain wrapper exists. +- [x] 2.1 Add wrapper hooks for auth/workspace selection and onboarding domains, then migrate `apps/mobile/src/contexts/AuthContext.tsx` and `apps/mobile/app/(app)/onboarding.tsx`. +- [x] 2.2 Add wrapper hooks for notification and settings domains, then migrate `apps/mobile/src/contexts/NotificationContext.tsx` and `apps/mobile/app/(app)/settings.tsx`. +- [x] 2.3 Add wrapper hooks for inbox and conversation flows, then migrate `apps/mobile/app/(app)/index.tsx` and `apps/mobile/app/(app)/conversation/[id].tsx`. +- [x] 2.4 Confirm no new mobile screen or context file outside the provider boundary imports `convex/react` directly once its domain wrapper exists. ## 3. Controller/context adoption -- [ ] 3.1 Update mobile contexts and screen/controller hooks to compose domain wrappers without owning generated hook details. -- [ ] 3.2 Keep navigation/state ownership and data-access wrapper ownership distinct during migration. -- [ ] 3.3 Confirm wrapper adoption aligns with mobile parity and shared onboarding-domain changes rather than duplicating them. +- [x] 3.1 Update mobile contexts and screen/controller hooks to compose domain wrappers without owning generated hook details. +- [x] 3.2 Keep navigation/state ownership and data-access wrapper ownership distinct during migration. +- [x] 3.3 Confirm wrapper adoption aligns with mobile parity and shared onboarding-domain changes rather than duplicating them. ## 4. Verification -- [ ] 4.1 Run targeted mobile tests or flow checks for onboarding, inbox, and settings modules touched by the migration. -- [ ] 4.2 Run `pnpm --filter @opencom/mobile typecheck` and any relevant workspace typecheck commands for mobile-touched code. -- [ ] 4.3 Run `openspec validate introduce-mobile-local-convex-wrapper-hooks --strict --no-interactive`. +- [x] 4.1 Run targeted mobile tests or flow checks for onboarding, inbox, and settings modules touched by the migration. +- [x] 4.2 Run `pnpm --filter @opencom/mobile typecheck` and any relevant workspace typecheck commands for mobile-touched code. +- [x] 4.3 Run `openspec validate introduce-mobile-local-convex-wrapper-hooks --strict --no-interactive`. diff --git a/packages/react-native-sdk/tests/hookBoundaryGuard.test.ts b/packages/react-native-sdk/tests/hookBoundaryGuard.test.ts index 73ee17b..d3a4edb 100644 --- a/packages/react-native-sdk/tests/hookBoundaryGuard.test.ts +++ b/packages/react-native-sdk/tests/hookBoundaryGuard.test.ts @@ -5,10 +5,12 @@ import { describe, expect, it } from "vitest"; const TESTS_DIR = dirname(fileURLToPath(import.meta.url)); const SRC_DIR = resolve(TESTS_DIR, "../src"); +const MOBILE_APP_DIR = resolve(TESTS_DIR, "../../../apps/mobile"); const INTERNAL_CONVEX_PATH = resolve(SRC_DIR, "internal/convex.ts"); const INTERNAL_RUNTIME_PATH = resolve(SRC_DIR, "internal/runtime.ts"); const INTERNAL_OPENCOM_CONTEXT_PATH = resolve(SRC_DIR, "internal/opencomContext.ts"); +const MOBILE_HARDENING_GUARD_PATH = resolve(MOBILE_APP_DIR, "src/typeHardeningGuard.test.ts"); const TRANSPORT_BOUNDARY_FILES = [ "hooks/useConversations.ts", @@ -82,4 +84,13 @@ describe("react native sdk hook boundary guards", () => { expect(pushSource).toContain("getSdkTransportContext()"); expect(pushSource).toContain("getSdkVisitorTransport()"); }); + + it("keeps the mobile app hardening guard discoverable from the React Native guard suite", () => { + const mobileGuardSource = readFileSync(MOBILE_HARDENING_GUARD_PATH, "utf8"); + + expect(mobileGuardSource).toContain('describe("mobile convex ref hardening guards"'); + expect(mobileGuardSource).toContain("APPROVED_DIRECT_CONVEX_IMPORT_FILES"); + expect(mobileGuardSource).toContain("APPROVED_DIRECT_REF_FACTORY_FILES"); + expect(mobileGuardSource).toContain("findComponentScopedConvexRefs"); + }); });