From 85d02c0638d93e885df0384b61a704617dbfa0df Mon Sep 17 00:00:00 2001 From: Jack D Date: Wed, 11 Mar 2026 18:33:19 +0000 Subject: [PATCH 1/4] introduce-mobile-local-convex-wrapper-hooks --- apps/mobile/app/(app)/conversation/[id].tsx | 105 +++----------- apps/mobile/app/(app)/index.tsx | 57 ++------ apps/mobile/app/(app)/onboarding.tsx | 84 ++--------- apps/mobile/app/(app)/settings.tsx | 126 +++-------------- apps/mobile/src/contexts/AuthContext.tsx | 87 ++---------- .../src/contexts/NotificationContext.tsx | 18 +-- apps/mobile/src/hooks/convex/types.ts | 126 +++++++++++++++++ apps/mobile/src/hooks/convex/useAuthConvex.ts | 58 ++++++++ .../src/hooks/convex/useConversationConvex.ts | 89 ++++++++++++ .../mobile/src/hooks/convex/useInboxConvex.ts | 46 ++++++ .../useNotificationRegistrationConvex.ts | 26 ++++ .../src/hooks/convex/useOnboardingConvex.ts | 51 +++++++ .../src/hooks/convex/useSettingsConvex.ts | 123 ++++++++++++++++ apps/mobile/src/lib/convex/hooks.ts | 77 ++++++++++ apps/mobile/src/typeHardeningGuard.test.ts | 132 ++++++++++++++++++ .../tasks.md | 26 ++-- 16 files changed, 811 insertions(+), 420 deletions(-) create mode 100644 apps/mobile/src/hooks/convex/types.ts create mode 100644 apps/mobile/src/hooks/convex/useAuthConvex.ts create mode 100644 apps/mobile/src/hooks/convex/useConversationConvex.ts create mode 100644 apps/mobile/src/hooks/convex/useInboxConvex.ts create mode 100644 apps/mobile/src/hooks/convex/useNotificationRegistrationConvex.ts create mode 100644 apps/mobile/src/hooks/convex/useOnboardingConvex.ts create mode 100644 apps/mobile/src/hooks/convex/useSettingsConvex.ts create mode 100644 apps/mobile/src/lib/convex/hooks.ts create mode 100644 apps/mobile/src/typeHardeningGuard.test.ts 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..f0931f2 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 [statusFilter, setStatusFilter] = useState(undefined); + const { inboxPage } = useInboxConvex({ workspaceId: activeWorkspaceId, status: statusFilter }); const conversations = (Array.isArray(inboxPage) ? inboxPage : inboxPage?.conversations) as | ConversationItem[] | undefined; 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..b85f9bc 100644 --- a/apps/mobile/src/contexts/NotificationContext.tsx +++ b/apps/mobile/src/contexts/NotificationContext.tsx @@ -2,10 +2,9 @@ import React, { createContext, useContext, useEffect, useRef, useState } from "r 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); diff --git a/apps/mobile/src/hooks/convex/types.ts b/apps/mobile/src/hooks/convex/types.ts new file mode 100644 index 0000000..012fadd --- /dev/null +++ b/apps/mobile/src/hooks/convex/types.ts @@ -0,0 +1,126 @@ +import type { Id } from "@opencom/convex/dataModel"; + +export type MobileWorkspaceRole = "owner" | "admin" | "agent" | "viewer"; +export type MobileConversationStatus = "open" | "closed" | "snoozed"; + +export interface MobileAuthUser { + _id: Id<"users">; + 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 HostedOnboardingState = { + status: "not_started" | "started" | "completed"; + isWidgetVerified: boolean; + verificationToken?: string | null; +} | null; + +export type HostedOnboardingIntegrationSignal = { + 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; +}; + +export type HostedOnboardingIntegrationSignals = { + integrations: HostedOnboardingIntegrationSignal[]; +} | null; + +export type MobileNotificationPreferencesRecord = { + 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 = + | MobileConversationItem[] + | { + conversations: MobileConversationItem[]; + }; + +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..4b26fd9 --- /dev/null +++ b/apps/mobile/src/hooks/convex/useAuthConvex.ts @@ -0,0 +1,58 @@ +import type { Id } from "@opencom/convex/dataModel"; +import { + mobileMutationRef, + mobileQueryRef, + useMobileMutation, + useMobileQuery, +} from "../../lib/convex/hooks"; +import type { HostedOnboardingState, MobileCurrentUserRecord } from "./types"; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type SwitchWorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type CompleteSignupProfileArgs = { + name?: string; + workspaceName?: string; +}; + +const CURRENT_USER_QUERY_REF = mobileQueryRef, MobileCurrentUserRecord>( + "auth:currentUser" +); +const SWITCH_WORKSPACE_MUTATION_REF = mobileMutationRef( + "auth:switchWorkspace" +); +const COMPLETE_SIGNUP_PROFILE_MUTATION_REF = mobileMutationRef( + "auth:completeSignupProfile" +); +const UNREGISTER_ALL_PUSH_TOKENS_MUTATION_REF = mobileMutationRef, null>( + "pushTokens:unregisterAllForCurrentUser" +); +const HOSTED_ONBOARDING_STATE_QUERY_REF = mobileQueryRef( + "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..bf5c3ec --- /dev/null +++ b/apps/mobile/src/hooks/convex/useConversationConvex.ts @@ -0,0 +1,89 @@ +import type { Id } from "@opencom/convex/dataModel"; +import { + mobileMutationRef, + mobileQueryRef, + 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 = mobileQueryRef< + ConversationIdArgs, + MobileConversationRecord | null +>("conversations:get"); +const VISITOR_GET_QUERY_REF = mobileQueryRef<{ id: Id<"visitors"> }, MobileVisitorRecord>( + "visitors:get" +); +const MESSAGES_LIST_QUERY_REF = mobileQueryRef( + "messages:list" +); +const SEND_MESSAGE_MUTATION_REF = mobileMutationRef>( + "messages:send" +); +const UPDATE_CONVERSATION_STATUS_MUTATION_REF = mobileMutationRef< + UpdateConversationStatusArgs, + null +>("conversations:updateStatus"); +const MARK_CONVERSATION_READ_MUTATION_REF = mobileMutationRef( + "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..0c8d431 --- /dev/null +++ b/apps/mobile/src/hooks/convex/useInboxConvex.ts @@ -0,0 +1,46 @@ +import type { Id } from "@opencom/convex/dataModel"; +import { mobileQueryRef, 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 = mobileQueryRef("visitors:isOnline"); +const INBOX_LIST_QUERY_REF = mobileQueryRef( + "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..97b520d --- /dev/null +++ b/apps/mobile/src/hooks/convex/useNotificationRegistrationConvex.ts @@ -0,0 +1,26 @@ +import { mobileMutationRef, useMobileMutation } from "../../lib/convex/hooks"; + +type RegisterPushTokenArgs = { + token: string; + userId: string; + platform: "ios" | "android"; +}; + +type PushDebugLogArgs = { + stage: string; + details?: string; +}; + +const REGISTER_PUSH_TOKEN_MUTATION_REF = mobileMutationRef( + "pushTokens:register" +); +const PUSH_DEBUG_LOG_MUTATION_REF = mobileMutationRef( + "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..a4fc893 --- /dev/null +++ b/apps/mobile/src/hooks/convex/useOnboardingConvex.ts @@ -0,0 +1,51 @@ +import type { Id } from "@opencom/convex/dataModel"; +import { + mobileMutationRef, + mobileQueryRef, + useMobileMutation, + useMobileQuery, +} from "../../lib/convex/hooks"; +import type { HostedOnboardingIntegrationSignals, HostedOnboardingState } from "./types"; + +type WorkspaceArgs = { + workspaceId: Id<"workspaces">; +}; + +type CompleteWidgetStepArgs = { + workspaceId: Id<"workspaces">; + token?: string; +}; + +const HOSTED_ONBOARDING_STATE_QUERY_REF = mobileQueryRef( + "workspaces:getHostedOnboardingState" +); +const HOSTED_ONBOARDING_SIGNALS_QUERY_REF = mobileQueryRef< + WorkspaceArgs, + HostedOnboardingIntegrationSignals +>("workspaces:getHostedOnboardingIntegrationSignals"); +const START_HOSTED_ONBOARDING_MUTATION_REF = mobileMutationRef( + "workspaces:startHostedOnboarding" +); +const ISSUE_VERIFICATION_TOKEN_MUTATION_REF = mobileMutationRef( + "workspaces:issueHostedOnboardingVerificationToken" +); +const COMPLETE_WIDGET_STEP_MUTATION_REF = mobileMutationRef< + CompleteWidgetStepArgs, + { success: boolean } +>("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..84360b7 --- /dev/null +++ b/apps/mobile/src/hooks/convex/useSettingsConvex.ts @@ -0,0 +1,123 @@ +import type { Id } from "@opencom/convex/dataModel"; +import { + mobileActionRef, + mobileMutationRef, + mobileQueryRef, + 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; +}; + +const MY_NOTIFICATION_PREFERENCES_QUERY_REF = mobileQueryRef< + WorkspaceArgs, + MobileNotificationPreferencesRecord +>("notificationSettings:getMyPreferences"); +const WORKSPACE_GET_QUERY_REF = mobileQueryRef( + "workspaces:get" +); +const WORKSPACE_MEMBERS_LIST_QUERY_REF = mobileQueryRef< + WorkspaceArgs, + MobileWorkspaceMemberRecord[] +>("workspaceMembers:listByWorkspace"); +const PUSH_TOKENS_BY_USER_QUERY_REF = mobileQueryRef( + "pushTokens:getByUser" +); +const UPDATE_ALLOWED_ORIGINS_MUTATION_REF = mobileMutationRef( + "workspaces:updateAllowedOrigins" +); +const INVITE_TO_WORKSPACE_ACTION_REF = mobileActionRef< + InviteToWorkspaceArgs, + InviteToWorkspaceResult +>("workspaceMembers:inviteToWorkspace"); +const UPDATE_WORKSPACE_ROLE_MUTATION_REF = mobileMutationRef( + "workspaceMembers:updateRole" +); +const REMOVE_WORKSPACE_MEMBER_MUTATION_REF = mobileMutationRef( + "workspaceMembers:remove" +); +const UPDATE_SIGNUP_SETTINGS_MUTATION_REF = mobileMutationRef( + "workspaces:updateSignupSettings" +); +const UPDATE_MY_NOTIFICATION_PREFERENCES_MUTATION_REF = mobileMutationRef< + UpdateMyNotificationPreferencesArgs, + null +>("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..6ccc5a2 --- /dev/null +++ b/apps/mobile/src/lib/convex/hooks.ts @@ -0,0 +1,77 @@ +import { + type OptionalRestArgsOrSkip, + type ReactAction, + type ReactMutation, + useAction, + useMutation, + useQuery, +} from "convex/react"; +import { makeFunctionReference, 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 +>; + +export function mobileQueryRef( + functionName: string +): MobileQueryRef { + return makeFunctionReference<"query", Args, Result>(functionName); +} + +export function mobileMutationRef( + functionName: string +): MobileMutationRef { + return makeFunctionReference<"mutation", Args, Result>(functionName); +} + +export function mobileActionRef( + functionName: string +): MobileActionRef { + return makeFunctionReference<"action", Args, Result>(functionName); +} + +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..ef8438f --- /dev/null +++ b/apps/mobile/src/typeHardeningGuard.test.ts @@ -0,0 +1,132 @@ +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_BOUNDARY_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 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 MOBILE_ADAPTER_REF_PATTERN = /\bmobile(?:Query|Mutation|Action)Ref\b/; + +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 isApprovedDirectConvexBoundary(filePath: string): boolean { + return APPROVED_DIRECT_CONVEX_BOUNDARY_FILES.includes(filePath); +} + +function findUnexpectedMobileDirectConvexBoundaries(): string[] { + return [...collectSourceFiles(MOBILE_APP_DIR), ...collectSourceFiles(MOBILE_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(MOBILE_ROOT_DIR, filePath)}: direct convex/react import`); + } + + if (DIRECT_REF_FACTORY_PATTERN.test(source)) { + violations.push( + `${relative(MOBILE_ROOT_DIR, filePath)}: direct makeFunctionReference call` + ); + } + + return violations; + } + ); +} + +describe("mobile convex ref hardening guards", () => { + it("keeps direct convex imports and ref factories limited to approved boundaries", () => { + expect(findUnexpectedMobileDirectConvexBoundaries()).toEqual([]); + }); + + it("keeps the approved direct Convex boundaries explicit", () => { + expect( + APPROVED_DIRECT_CONVEX_BOUNDARY_FILES.map((filePath) => relative(MOBILE_ROOT_DIR, filePath)) + ).toEqual(["src/lib/convex/hooks.ts", "app/_layout.tsx", "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 function mobileQueryRef"); + expect(source).toContain("export function mobileMutationRef"); + expect(source).toContain("export function 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"); + }); + + 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(MOBILE_ADAPTER_REF_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/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`. From 0f1ce18bcf10754c119368ec59589ad16115c8c2 Mon Sep 17 00:00:00 2001 From: Jack D Date: Wed, 11 Mar 2026 19:37:21 +0000 Subject: [PATCH 2/4] Infer typing from backend --- apps/mobile/src/hooks/convex/types.ts | 66 +++++++++++--- apps/mobile/src/hooks/convex/useAuthConvex.ts | 65 +++++++++----- .../src/hooks/convex/useConversationConvex.ts | 46 +++++----- .../mobile/src/hooks/convex/useInboxConvex.ts | 9 +- .../useNotificationRegistrationConvex.ts | 27 ++++-- .../src/hooks/convex/useOnboardingConvex.ts | 48 ++++++---- .../src/hooks/convex/useSettingsConvex.ts | 76 +++++++++------- apps/mobile/src/lib/convex/hooks.ts | 20 +---- apps/mobile/src/typeHardeningGuard.test.ts | 89 ++++++++++++++----- docs/convex-type-safety-playbook.md | 36 ++++---- .../tests/hookBoundaryGuard.test.ts | 11 +++ 11 files changed, 316 insertions(+), 177 deletions(-) diff --git a/apps/mobile/src/hooks/convex/types.ts b/apps/mobile/src/hooks/convex/types.ts index 012fadd..9a2f88a 100644 --- a/apps/mobile/src/hooks/convex/types.ts +++ b/apps/mobile/src/hooks/convex/types.ts @@ -24,30 +24,68 @@ export type MobileCurrentUserRecord = { workspaces: MobileWorkspace[]; } | null; -export type HostedOnboardingState = { - status: "not_started" | "started" | "completed"; +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; - verificationToken?: string | null; -} | null; + 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; - integrationKey: string; clientType: string; - clientVersion?: string | null; - status: "recognized" | "active" | "inactive"; + 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; - origin?: string | null; - currentUrl?: string | null; - clientIdentifier?: string | null; - lastSeenAt?: number | null; - activeSessionCount: number; - detectedAt?: number | null; - metadata?: Record | null; }; export type HostedOnboardingIntegrationSignals = { + tokenIssuedAt: number | null; + hasRecognizedInstall: boolean; + latestDetectedAt: number | null; + latestRecognizedDetectedAt: number | null; integrations: HostedOnboardingIntegrationSignal[]; } | null; diff --git a/apps/mobile/src/hooks/convex/useAuthConvex.ts b/apps/mobile/src/hooks/convex/useAuthConvex.ts index 4b26fd9..b24fd0b 100644 --- a/apps/mobile/src/hooks/convex/useAuthConvex.ts +++ b/apps/mobile/src/hooks/convex/useAuthConvex.ts @@ -1,11 +1,7 @@ import type { Id } from "@opencom/convex/dataModel"; -import { - mobileMutationRef, - mobileQueryRef, - useMobileMutation, - useMobileQuery, -} from "../../lib/convex/hooks"; -import type { HostedOnboardingState, MobileCurrentUserRecord } from "./types"; +import { makeFunctionReference } from "convex/server"; +import { useMobileMutation, useMobileQuery } from "../../lib/convex/hooks"; +import type { HostedOnboardingState, MobileAuthUser, MobileCurrentUserRecord } from "./types"; type WorkspaceArgs = { workspaceId: Id<"workspaces">; @@ -20,21 +16,46 @@ type CompleteSignupProfileArgs = { workspaceName?: string; }; -const CURRENT_USER_QUERY_REF = mobileQueryRef, MobileCurrentUserRecord>( - "auth:currentUser" -); -const SWITCH_WORKSPACE_MUTATION_REF = mobileMutationRef( - "auth:switchWorkspace" -); -const COMPLETE_SIGNUP_PROFILE_MUTATION_REF = mobileMutationRef( - "auth:completeSignupProfile" -); -const UNREGISTER_ALL_PUSH_TOKENS_MUTATION_REF = mobileMutationRef, null>( - "pushTokens:unregisterAllForCurrentUser" -); -const HOSTED_ONBOARDING_STATE_QUERY_REF = mobileQueryRef( - "workspaces:getHostedOnboardingState" -); +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 { diff --git a/apps/mobile/src/hooks/convex/useConversationConvex.ts b/apps/mobile/src/hooks/convex/useConversationConvex.ts index bf5c3ec..6e45957 100644 --- a/apps/mobile/src/hooks/convex/useConversationConvex.ts +++ b/apps/mobile/src/hooks/convex/useConversationConvex.ts @@ -1,10 +1,6 @@ import type { Id } from "@opencom/convex/dataModel"; -import { - mobileMutationRef, - mobileQueryRef, - useMobileMutation, - useMobileQuery, -} from "../../lib/convex/hooks"; +import { makeFunctionReference } from "convex/server"; +import { useMobileMutation, useMobileQuery } from "../../lib/convex/hooks"; import type { MobileConversationMessage, MobileConversationRecord, @@ -37,26 +33,36 @@ type MarkConversationReadArgs = { readerType: "agent" | "visitor"; }; -const CONVERSATION_GET_QUERY_REF = mobileQueryRef< +const CONVERSATION_GET_QUERY_REF = makeFunctionReference< + "query", ConversationIdArgs, MobileConversationRecord | null >("conversations:get"); -const VISITOR_GET_QUERY_REF = mobileQueryRef<{ id: Id<"visitors"> }, MobileVisitorRecord>( - "visitors:get" -); -const MESSAGES_LIST_QUERY_REF = mobileQueryRef( - "messages:list" -); -const SEND_MESSAGE_MUTATION_REF = mobileMutationRef>( - "messages:send" -); -const UPDATE_CONVERSATION_STATUS_MUTATION_REF = mobileMutationRef< +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 = mobileMutationRef( - "conversations:markAsRead" -); +const MARK_CONVERSATION_READ_MUTATION_REF = makeFunctionReference< + "mutation", + MarkConversationReadArgs, + null +>("conversations:markAsRead"); function resolveConversationId( conversationId?: string | Id<"conversations"> | null diff --git a/apps/mobile/src/hooks/convex/useInboxConvex.ts b/apps/mobile/src/hooks/convex/useInboxConvex.ts index 0c8d431..63af4e7 100644 --- a/apps/mobile/src/hooks/convex/useInboxConvex.ts +++ b/apps/mobile/src/hooks/convex/useInboxConvex.ts @@ -1,5 +1,6 @@ import type { Id } from "@opencom/convex/dataModel"; -import { mobileQueryRef, useMobileQuery } from "../../lib/convex/hooks"; +import { makeFunctionReference } from "convex/server"; +import { useMobileQuery } from "../../lib/convex/hooks"; import type { MobileConversationStatus, MobileInboxPageResult } from "./types"; type VisitorArgs = { @@ -11,8 +12,10 @@ type InboxArgs = { status?: MobileConversationStatus; }; -const VISITOR_IS_ONLINE_QUERY_REF = mobileQueryRef("visitors:isOnline"); -const INBOX_LIST_QUERY_REF = mobileQueryRef( +const VISITOR_IS_ONLINE_QUERY_REF = makeFunctionReference<"query", VisitorArgs, boolean>( + "visitors:isOnline" +); +const INBOX_LIST_QUERY_REF = makeFunctionReference<"query", InboxArgs, MobileInboxPageResult>( "conversations:listForInbox" ); diff --git a/apps/mobile/src/hooks/convex/useNotificationRegistrationConvex.ts b/apps/mobile/src/hooks/convex/useNotificationRegistrationConvex.ts index 97b520d..f886f33 100644 --- a/apps/mobile/src/hooks/convex/useNotificationRegistrationConvex.ts +++ b/apps/mobile/src/hooks/convex/useNotificationRegistrationConvex.ts @@ -1,8 +1,10 @@ -import { mobileMutationRef, useMobileMutation } from "../../lib/convex/hooks"; +import type { Id } from "@opencom/convex/dataModel"; +import { makeFunctionReference } from "convex/server"; +import { useMobileMutation } from "../../lib/convex/hooks"; type RegisterPushTokenArgs = { token: string; - userId: string; + userId: Id<"users">; platform: "ios" | "android"; }; @@ -11,12 +13,21 @@ type PushDebugLogArgs = { details?: string; }; -const REGISTER_PUSH_TOKEN_MUTATION_REF = mobileMutationRef( - "pushTokens:register" -); -const PUSH_DEBUG_LOG_MUTATION_REF = mobileMutationRef( - "pushTokens:debugLog" -); +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 { diff --git a/apps/mobile/src/hooks/convex/useOnboardingConvex.ts b/apps/mobile/src/hooks/convex/useOnboardingConvex.ts index a4fc893..f92f895 100644 --- a/apps/mobile/src/hooks/convex/useOnboardingConvex.ts +++ b/apps/mobile/src/hooks/convex/useOnboardingConvex.ts @@ -1,11 +1,13 @@ import type { Id } from "@opencom/convex/dataModel"; -import { - mobileMutationRef, - mobileQueryRef, - useMobileMutation, - useMobileQuery, -} from "../../lib/convex/hooks"; -import type { HostedOnboardingIntegrationSignals, HostedOnboardingState } from "./types"; +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">; @@ -16,22 +18,30 @@ type CompleteWidgetStepArgs = { token?: string; }; -const HOSTED_ONBOARDING_STATE_QUERY_REF = mobileQueryRef( - "workspaces:getHostedOnboardingState" -); -const HOSTED_ONBOARDING_SIGNALS_QUERY_REF = mobileQueryRef< +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 = mobileMutationRef( - "workspaces:startHostedOnboarding" -); -const ISSUE_VERIFICATION_TOKEN_MUTATION_REF = mobileMutationRef( - "workspaces:issueHostedOnboardingVerificationToken" -); -const COMPLETE_WIDGET_STEP_MUTATION_REF = mobileMutationRef< +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, - { success: boolean } + CompleteHostedOnboardingWidgetStepResult >("workspaces:completeHostedOnboardingWidgetStep"); export function useOnboardingConvex(workspaceId?: Id<"workspaces"> | null) { diff --git a/apps/mobile/src/hooks/convex/useSettingsConvex.ts b/apps/mobile/src/hooks/convex/useSettingsConvex.ts index 84360b7..44fd7c8 100644 --- a/apps/mobile/src/hooks/convex/useSettingsConvex.ts +++ b/apps/mobile/src/hooks/convex/useSettingsConvex.ts @@ -1,12 +1,6 @@ import type { Id } from "@opencom/convex/dataModel"; -import { - mobileActionRef, - mobileMutationRef, - mobileQueryRef, - useMobileAction, - useMobileMutation, - useMobileQuery, -} from "../../lib/convex/hooks"; +import { makeFunctionReference } from "convex/server"; +import { useMobileAction, useMobileMutation, useMobileQuery } from "../../lib/convex/hooks"; import type { InviteToWorkspaceResult, MobileNotificationPreferencesRecord, @@ -59,39 +53,59 @@ type UpdateMyNotificationPreferencesArgs = { muted: boolean; }; -const MY_NOTIFICATION_PREFERENCES_QUERY_REF = mobileQueryRef< +type MutationSuccessResult = { + success: true; +}; + +const MY_NOTIFICATION_PREFERENCES_QUERY_REF = makeFunctionReference< + "query", WorkspaceArgs, MobileNotificationPreferencesRecord >("notificationSettings:getMyPreferences"); -const WORKSPACE_GET_QUERY_REF = mobileQueryRef( - "workspaces:get" -); -const WORKSPACE_MEMBERS_LIST_QUERY_REF = mobileQueryRef< +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 = mobileQueryRef( - "pushTokens:getByUser" -); -const UPDATE_ALLOWED_ORIGINS_MUTATION_REF = mobileMutationRef( - "workspaces:updateAllowedOrigins" -); -const INVITE_TO_WORKSPACE_ACTION_REF = mobileActionRef< +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 = mobileMutationRef( - "workspaceMembers:updateRole" -); -const REMOVE_WORKSPACE_MEMBER_MUTATION_REF = mobileMutationRef( - "workspaceMembers:remove" -); -const UPDATE_SIGNUP_SETTINGS_MUTATION_REF = mobileMutationRef( - "workspaces:updateSignupSettings" -); -const UPDATE_MY_NOTIFICATION_PREFERENCES_MUTATION_REF = mobileMutationRef< +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, - null + Id<"notificationPreferences"> >("notificationSettings:updateMyPreferences"); type UseSettingsConvexOptions = { diff --git a/apps/mobile/src/lib/convex/hooks.ts b/apps/mobile/src/lib/convex/hooks.ts index 6ccc5a2..dbe5849 100644 --- a/apps/mobile/src/lib/convex/hooks.ts +++ b/apps/mobile/src/lib/convex/hooks.ts @@ -6,7 +6,7 @@ import { useMutation, useQuery, } from "convex/react"; -import { makeFunctionReference, type FunctionReference } from "convex/server"; +import type { FunctionReference } from "convex/server"; type MobileArgs = Record; @@ -31,24 +31,6 @@ export type MobileActionRef = FunctionReference Result >; -export function mobileQueryRef( - functionName: string -): MobileQueryRef { - return makeFunctionReference<"query", Args, Result>(functionName); -} - -export function mobileMutationRef( - functionName: string -): MobileMutationRef { - return makeFunctionReference<"mutation", Args, Result>(functionName); -} - -export function mobileActionRef( - functionName: string -): MobileActionRef { - return makeFunctionReference<"action", Args, Result>(functionName); -} - function toMobileQueryArgs( args: Args | "skip" ): OptionalRestArgsOrSkip> { diff --git a/apps/mobile/src/typeHardeningGuard.test.ts b/apps/mobile/src/typeHardeningGuard.test.ts index ef8438f..e165abd 100644 --- a/apps/mobile/src/typeHardeningGuard.test.ts +++ b/apps/mobile/src/typeHardeningGuard.test.ts @@ -9,7 +9,7 @@ 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_BOUNDARY_FILES = [ +const APPROVED_DIRECT_CONVEX_IMPORT_FILES = [ MOBILE_CONVEX_ADAPTER_PATH, MOBILE_PROVIDER_BOUNDARY_PATH, resolve(MOBILE_SRC_DIR, "typeHardeningGuard.test.ts"), @@ -23,6 +23,10 @@ const WRAPPER_LAYER_FILES = [ "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"]], @@ -36,7 +40,10 @@ const MIGRATED_MOBILE_CONSUMERS = [ 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 MOBILE_ADAPTER_REF_PATTERN = /\bmobile(?:Query|Mutation|Action)Ref\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) => { @@ -55,28 +62,30 @@ function collectSourceFiles(dir: string): string[] { }); } -function isApprovedDirectConvexBoundary(filePath: string): boolean { - return APPROVED_DIRECT_CONVEX_BOUNDARY_FILES.includes(filePath); +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) => { - if (isApprovedDirectConvexBoundary(filePath)) { - return []; - } - const source = readFileSync(filePath, "utf8"); const violations: string[] = []; - if (DIRECT_CONVEX_IMPORT_PATTERN.test(source)) { - violations.push(`${relative(MOBILE_ROOT_DIR, filePath)}: direct convex/react import`); + if (DIRECT_CONVEX_IMPORT_PATTERN.test(source) && !isApprovedDirectConvexImport(filePath)) { + violations.push(`${toPortableRelativePath(filePath)}: direct convex/react import`); } - if (DIRECT_REF_FACTORY_PATTERN.test(source)) { - violations.push( - `${relative(MOBILE_ROOT_DIR, filePath)}: direct makeFunctionReference call` - ); + if (DIRECT_REF_FACTORY_PATTERN.test(source) && !isApprovedDirectRefFactory(filePath)) { + violations.push(`${toPortableRelativePath(filePath)}: direct makeFunctionReference call`); } return violations; @@ -84,28 +93,68 @@ function findUnexpectedMobileDirectConvexBoundaries(): string[] { ); } +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 boundaries explicit", () => { + it("keeps the approved direct convex import boundaries explicit", () => { expect( - APPROVED_DIRECT_CONVEX_BOUNDARY_FILES.map((filePath) => relative(MOBILE_ROOT_DIR, filePath)) + 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 function mobileQueryRef"); - expect(source).toContain("export function mobileMutationRef"); - expect(source).toContain("export function mobileActionRef"); + 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", () => { @@ -113,7 +162,7 @@ describe("mobile convex ref hardening guards", () => { const source = readFileSync(filePath, "utf8"); expect(MOBILE_ADAPTER_HOOK_PATTERN.test(source)).toBe(true); - expect(MOBILE_ADAPTER_REF_PATTERN.test(source)).toBe(true); + expect(DIRECT_REF_FACTORY_PATTERN.test(source)).toBe(true); } }); 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/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"); + }); }); From 326b11c1b1a0a1bd6d4ee5afb91e289fb9869ecb Mon Sep 17 00:00:00 2001 From: Jack D Date: Wed, 11 Mar 2026 20:01:35 +0000 Subject: [PATCH 3/4] Inbox cursor type and small fixes --- apps/mobile/app/(app)/index.tsx | 4 +--- .../src/contexts/NotificationContext.tsx | 21 +++++++++++-------- apps/mobile/src/hooks/convex/types.ts | 21 ++++++++++++++----- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/apps/mobile/app/(app)/index.tsx b/apps/mobile/app/(app)/index.tsx index f0931f2..4d9d3cf 100644 --- a/apps/mobile/app/(app)/index.tsx +++ b/apps/mobile/app/(app)/index.tsx @@ -77,9 +77,7 @@ export default function InboxScreen() { const [refreshing, setRefreshing] = useState(false); const [statusFilter, setStatusFilter] = useState(undefined); const { inboxPage } = useInboxConvex({ workspaceId: activeWorkspaceId, status: statusFilter }); - const conversations = (Array.isArray(inboxPage) ? inboxPage : inboxPage?.conversations) as - | ConversationItem[] - | undefined; + const conversations = inboxPage?.conversations as ConversationItem[] | undefined; const onRefresh = useCallback(() => { setRefreshing(true); diff --git a/apps/mobile/src/contexts/NotificationContext.tsx b/apps/mobile/src/contexts/NotificationContext.tsx index b85f9bc..28bc92c 100644 --- a/apps/mobile/src/contexts/NotificationContext.tsx +++ b/apps/mobile/src/contexts/NotificationContext.tsx @@ -1,4 +1,4 @@ -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"; @@ -73,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; @@ -195,7 +198,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode } responseListener.current.remove(); } }; - }, [isAuthenticated, user, registerToken]); + }, [isAuthenticated, user, registerToken, sendDebugLog]); return ( ; From d7ca423eeb4d39b49dc6947804aa46bd7a78cabe Mon Sep 17 00:00:00 2001 From: Jack D Date: Wed, 11 Mar 2026 20:12:15 +0000 Subject: [PATCH 4/4] explicit type to fix build --- apps/web/src/app/articles/ArticlesImportSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 (