fix: merge main branch and renumber migration 027→037

Resolve 3 merge conflicts and renumber the automation_hooks migration
to follow main's migration chain (036_add_document_translation_fields).

Conflicts resolved:
- app/api/__init__.py: add automation_router alongside main's new routers
- app/utils/settings_service.py: add automation_hooks_enabled alongside compliance_enabled
- tests/conftest.py: add AutomationHook alongside AuditLog/ComplianceTemplate imports

Migration renumbered:
- 027_add_automation_hooks → 037_add_automation_hooks
- down_revision: 026_add_scheduled_jobs → 036_add_document_translation_fields

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-16 22:40:15 +00:00
parent 6a83d51d88
commit 204000aabc
318 changed files with 317516 additions and 2858 deletions
+193
View File
@@ -0,0 +1,193 @@
/**
* Authentication context for the DocuElevate mobile app.
*
* Manages the lifecycle of the stored API token and user profile. The SSO
* login flow uses expo-auth-session to open the server's OAuth page in the
* system browser; on return the redirect URL carries a one-time code that is
* exchanged for a session cookie, which is then traded for a permanent API
* token via POST /api/mobile/generate-token.
*/
import * as Linking from "expo-linking";
import * as SecureStore from "expo-secure-store";
import * as WebBrowser from "expo-web-browser";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import {
SECURE_STORE_API_TOKEN_KEY,
SECURE_STORE_BASE_URL_KEY,
SECURE_STORE_OWNER_ID_KEY,
api,
type WhoAmIResponse,
} from "../services/api";
WebBrowser.maybeCompleteAuthSession();
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface AuthState {
isLoading: boolean;
isAuthenticated: boolean;
user: WhoAmIResponse | null;
baseUrl: string;
signIn: (serverUrl: string) => Promise<void>;
signOut: () => Promise<void>;
setToken: (token: string) => Promise<void>;
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const AuthContext = createContext<AuthState>({
isLoading: true,
isAuthenticated: false,
user: null,
baseUrl: "",
signIn: async () => {},
signOut: async () => {},
setToken: async () => {},
});
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isLoading, setIsLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState<WhoAmIResponse | null>(null);
const [baseUrl, setBaseUrl] = useState("");
// On mount: restore persisted session
useEffect(() => {
(async () => {
try {
const storedUrl = await SecureStore.getItemAsync(SECURE_STORE_BASE_URL_KEY);
const storedToken = await SecureStore.getItemAsync(SECURE_STORE_API_TOKEN_KEY);
if (storedUrl && storedToken) {
await api.init(storedUrl);
setBaseUrl(storedUrl);
// Verify token is still valid
const profile = await api.whoAmI();
setUser(profile);
setIsAuthenticated(true);
}
} catch {
// Token expired or server unavailable – clear stored credentials
await SecureStore.deleteItemAsync(SECURE_STORE_API_TOKEN_KEY);
await SecureStore.deleteItemAsync(SECURE_STORE_OWNER_ID_KEY);
} finally {
setIsLoading(false);
}
})();
}, []);
const setToken = useCallback(async (token: string) => {
await SecureStore.setItemAsync(SECURE_STORE_API_TOKEN_KEY, token);
const profile = await api.whoAmI();
setUser(profile);
await SecureStore.setItemAsync(SECURE_STORE_OWNER_ID_KEY, profile.owner_id);
setIsAuthenticated(true);
}, []);
const signIn = useCallback(
async (serverUrl: string) => {
const cleanUrl = serverUrl.replace(/\/$/, "");
await api.init(cleanUrl);
setBaseUrl(cleanUrl);
// Compute the correct redirect URI for the current runtime:
// • Expo Go (development) → exp://<host>:<port>/--/callback
// • Standalone / EAS build → docuelevate://callback
// Both schemes are accepted by the server; using Linking.createURL
// ensures the browser deep-link resolves correctly in every environment.
const redirectUri = Linking.createURL("callback");
// Open the web login page in the system browser. The user authenticates
// via SSO or local credentials, then the app deep-link is triggered.
// The WebBrowser.openAuthSessionAsync handles the redirect back to the app.
const result = await WebBrowser.openAuthSessionAsync(
`${cleanUrl}/login?mobile=1&redirect_uri=${encodeURIComponent(redirectUri)}`,
redirectUri
);
if (result.type !== "success") {
throw new Error("Authentication was cancelled or failed");
}
// Parse the token from the redirect URL if the server appended it,
// otherwise hit the generate-token endpoint (session cookie is carried
// by the WebBrowser).
const url = new URL(result.url);
const inlineToken = url.searchParams.get("token");
if (inlineToken) {
await setToken(inlineToken);
} else {
// The server set a session cookie during the browser session; exchange
// it for a persistent API token.
const deviceInfo = await _getDeviceName();
const tokenResp = await api.generateMobileToken(deviceInfo);
await setToken(tokenResp.token);
}
},
[setToken]
);
const signOut = useCallback(async () => {
await SecureStore.deleteItemAsync(SECURE_STORE_API_TOKEN_KEY);
await SecureStore.deleteItemAsync(SECURE_STORE_OWNER_ID_KEY);
setUser(null);
setIsAuthenticated(false);
}, []);
return (
<AuthContext.Provider
value={{
isLoading,
isAuthenticated,
user,
baseUrl,
signIn,
signOut,
setToken,
}}
>
{children}
</AuthContext.Provider>
);
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useAuth(): AuthState {
return useContext(AuthContext);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function _getDeviceName(): Promise<string> {
try {
const Constants = await import("expo-constants");
return (
Constants.default.deviceName ||
Constants.default.expoConfig?.name ||
"Mobile App"
);
} catch {
return "Mobile App";
}
}
+51
View File
@@ -0,0 +1,51 @@
/**
* ShareContext – propagates files received from the iOS Share Sheet or the
* Android Share Intent to the UploadScreen so they can be uploaded
* automatically.
*
* The root layout listens for incoming file:// / content:// URLs via
* expo-linking and calls addPendingFile(). UploadScreen consumes the context,
* uploads each pending file, then calls clearPendingFiles().
*/
import React, { createContext, useCallback, useContext, useState } from "react";
export interface SharedFile {
uri: string;
filename: string;
mimeType?: string;
}
interface ShareContextValue {
pendingFiles: SharedFile[];
addPendingFile: (file: SharedFile) => void;
clearPendingFiles: () => void;
}
const ShareContext = createContext<ShareContextValue>({
pendingFiles: [],
addPendingFile: () => {},
clearPendingFiles: () => {},
});
export function ShareProvider({ children }: { children: React.ReactNode }) {
const [pendingFiles, setPendingFiles] = useState<SharedFile[]>([]);
const addPendingFile = useCallback((file: SharedFile) => {
setPendingFiles((prev) => [...prev, file]);
}, []);
const clearPendingFiles = useCallback(() => {
setPendingFiles([]);
}, []);
return (
<ShareContext.Provider value={{ pendingFiles, addPendingFile, clearPendingFiles }}>
{children}
</ShareContext.Provider>
);
}
export function useShare(): ShareContextValue {
return useContext(ShareContext);
}
+116
View File
@@ -0,0 +1,116 @@
/**
* usePushNotifications – register the device for push notifications.
*
* Requests the user's permission for notifications, obtains an Expo push
* token, and registers it with the DocuElevate backend via
* POST /api/mobile/register-device.
*
* This hook should be called once from the root component after the user has
* successfully authenticated.
*/
import Constants from "expo-constants";
import * as Device from "expo-device";
import * as Notifications from "expo-notifications";
import { useCallback, useEffect, useRef } from "react";
import { Platform } from "react-native";
import api from "../services/api";
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
shouldShowBanner: true,
shouldShowList: true,
}),
});
export function usePushNotifications(isAuthenticated: boolean) {
const notificationListener = useRef<Notifications.Subscription | null>(null);
const responseListener = useRef<Notifications.Subscription | null>(null);
const registerForPushNotifications = useCallback(async () => {
if (!Device.isDevice) {
// Push tokens are not available in simulators.
return;
}
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("default", {
name: "DocuElevate",
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#1e40af",
});
}
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== "granted") {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== "granted") {
// User declined – no push notifications
return;
}
let projectId: string | undefined;
try {
projectId =
Constants.expoConfig?.extra?.eas?.projectId ??
Constants.easConfig?.projectId;
} catch {
// ignore
}
const tokenData = await Notifications.getExpoPushTokenAsync(
projectId ? { projectId } : undefined
);
const pushToken = tokenData.data;
const platform = Platform.OS as "ios" | "android" | "web";
let deviceName = "Mobile App";
try {
deviceName = Device.modelName ?? Device.deviceName ?? "Mobile App";
} catch {
// ignore
}
try {
await api.registerDevice({ push_token: pushToken, device_name: deviceName, platform });
} catch {
// Registration failure is non-fatal – the app still works without push.
}
}, []);
useEffect(() => {
if (!isAuthenticated) return;
registerForPushNotifications();
// Listen for incoming notifications while app is foregrounded
notificationListener.current = Notifications.addNotificationReceivedListener((notification) => {
console.log("Notification received:", notification.request.content.title);
});
// Listen for user taps on notifications
responseListener.current = Notifications.addNotificationResponseReceivedListener((response) => {
const data = response.notification.request.content.data as Record<string, unknown>;
// Navigate to file detail if file_id is present
if (data?.file_id) {
console.log("User tapped notification for file:", data.file_id);
// Navigation would be wired up by the caller via a callback prop
}
});
return () => {
notificationListener.current?.remove();
responseListener.current?.remove();
};
}, [isAuthenticated, registerForPushNotifications]);
}
+221
View File
@@ -0,0 +1,221 @@
/**
* FilesScreen – list of documents processed by DocuElevate.
*/
import React, { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
FlatList,
Pressable,
RefreshControl,
StyleSheet,
Text,
View,
} from "react-native";
import type { FileRecord } from "../services/api";
import api from "../services/api";
function formatBytes(bytes: number | null): string {
if (bytes === null || bytes === undefined) return "–";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
} catch {
return iso;
}
}
function statusEmoji(status: string): string {
const map: Record<string, string> = {
completed: "✅",
processing: "⚙️",
pending: "⏳",
failed: "❌",
duplicate: "🔁",
};
return map[status?.toLowerCase()] ?? "📄";
}
export default function FilesScreen() {
const [files, setFiles] = useState<FileRecord[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchFiles = useCallback(
async (pageNum: number, replace: boolean) => {
try {
const data = await api.listFiles(pageNum, 20);
if (replace) {
setFiles(data);
} else {
setFiles((prev) => [...prev, ...data]);
}
setHasMore(data.length === 20);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load files");
}
},
[]
);
useEffect(() => {
(async () => {
setLoading(true);
await fetchFiles(1, true);
setLoading(false);
})();
}, [fetchFiles]);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
setPage(1);
await fetchFiles(1, true);
setRefreshing(false);
}, [fetchFiles]);
const handleLoadMore = useCallback(async () => {
if (!hasMore || loading || refreshing) return;
const next = page + 1;
setPage(next);
await fetchFiles(next, false);
}, [fetchFiles, hasMore, loading, page, refreshing]);
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#1e40af" />
</View>
);
}
if (error) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>{error}</Text>
<Pressable style={styles.retryButton} onPress={handleRefresh}>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
</View>
);
}
return (
<FlatList
style={styles.list}
data={files}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.listContent}
renderItem={({ item }) => <FileRow file={item} />}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.4}
ListEmptyComponent={
<View style={styles.emptyState}>
<Text style={styles.emptyEmoji}>📂</Text>
<Text style={styles.emptyText}>No documents yet.</Text>
<Text style={styles.emptyHint}>
Upload a document from the Upload tab to get started.
</Text>
</View>
}
ListFooterComponent={
hasMore && files.length > 0 ? (
<ActivityIndicator color="#1e40af" style={{ marginVertical: 16 }} />
) : null
}
/>
);
}
function FileRow({ file }: { file: FileRecord }) {
const status = file.processing_status?.status ?? "pending";
return (
<View style={rowStyles.row}>
<Text style={rowStyles.icon}>{statusEmoji(status)}</Text>
<View style={rowStyles.info}>
<Text style={rowStyles.filename} numberOfLines={1}>
{file.original_filename}
</Text>
<Text style={rowStyles.meta}>
{formatDate(file.created_at)} · {formatBytes(file.file_size)}
</Text>
</View>
<Text style={rowStyles.status}>{status}</Text>
</View>
);
}
const styles = StyleSheet.create({
list: { flex: 1, backgroundColor: "#f9fafb" },
listContent: { padding: 16 },
center: {
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f9fafb",
padding: 24,
},
errorText: { color: "#dc2626", fontSize: 15, textAlign: "center", marginBottom: 16 },
retryButton: {
backgroundColor: "#1e40af",
borderRadius: 8,
paddingHorizontal: 24,
paddingVertical: 10,
},
retryText: { color: "#fff", fontWeight: "600" },
emptyState: { alignItems: "center", paddingTop: 60 },
emptyEmoji: { fontSize: 48, marginBottom: 12 },
emptyText: { fontSize: 16, color: "#374151", marginBottom: 8 },
emptyHint: {
fontSize: 13,
color: "#6b7280",
textAlign: "center",
paddingHorizontal: 32,
},
});
const rowStyles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#fff",
borderRadius: 10,
padding: 14,
marginBottom: 10,
shadowColor: "#000",
shadowOpacity: 0.04,
shadowOffset: { width: 0, height: 2 },
shadowRadius: 4,
elevation: 2,
},
icon: { fontSize: 22, marginRight: 12 },
info: { flex: 1 },
filename: {
fontSize: 14,
fontWeight: "600",
color: "#111827",
marginBottom: 4,
},
meta: { fontSize: 12, color: "#6b7280" },
status: {
fontSize: 11,
color: "#6b7280",
fontWeight: "500",
textTransform: "capitalize",
},
});
+203
View File
@@ -0,0 +1,203 @@
/**
* LoginScreen – server URL entry and SSO sign-in.
*
* Renders a server URL input and a "Sign in with SSO" button that opens the
* DocuElevate web login page in the system browser. On success the
* AuthContext stores the API token and navigates to the main app.
*/
import { useRouter } from "expo-router";
import React, { useState } from "react";
import {
ActivityIndicator,
Alert,
Image,
KeyboardAvoidingView,
Platform,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { useAuth } from "../context/AuthContext";
export default function LoginScreen() {
const { signIn } = useAuth();
const router = useRouter();
const [serverUrl, setServerUrl] = useState("");
const [loading, setLoading] = useState(false);
async function handleSignIn() {
const url = serverUrl.trim();
if (!url) {
Alert.alert("Server URL required", "Please enter the URL of your DocuElevate server.");
return;
}
if (!url.startsWith("http://") && !url.startsWith("https://")) {
Alert.alert("Invalid URL", "The server URL must start with http:// or https://");
return;
}
setLoading(true);
try {
await signIn(url);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Sign-in failed";
Alert.alert("Sign-in failed", message);
} finally {
setLoading(false);
}
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<View style={styles.card}>
<View style={styles.logoContainer}>
<Image
source={require("../../assets/logo.png")}
style={styles.logoImage}
resizeMode="contain"
accessibilityLabel="DocuElevate logo"
/>
<Text style={styles.logoText}>DocuElevate</Text>
</View>
<Text style={styles.tagline}>Intelligent Document Processing</Text>
<Text style={styles.label}>Server URL</Text>
<TextInput
style={styles.input}
placeholder="https://your-docuelevate-server.com"
placeholderTextColor="#9ca3af"
value={serverUrl}
onChangeText={setServerUrl}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
returnKeyType="go"
onSubmitEditing={handleSignIn}
accessibilityLabel="Server URL"
/>
<Pressable
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleSignIn}
disabled={loading}
accessibilityRole="button"
accessibilityLabel="Sign in with SSO"
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Sign in with SSO</Text>
)}
</Pressable>
<Text style={styles.hint}>
You will be redirected to your organisation's sign-in page.
</Text>
<Pressable
onPress={() => router.back()}
accessibilityRole="button"
accessibilityLabel="Back to welcome screen"
style={styles.backLink}
>
<Text style={styles.backLinkText}>← Back</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f3f4f6",
justifyContent: "center",
padding: 24,
},
card: {
backgroundColor: "#ffffff",
borderRadius: 16,
padding: 28,
shadowColor: "#000",
shadowOpacity: 0.08,
shadowOffset: { width: 0, height: 4 },
shadowRadius: 12,
elevation: 4,
},
logoContainer: {
alignItems: "center",
marginBottom: 4,
},
logoImage: {
width: 80,
height: 80,
marginBottom: 10,
},
logoText: {
fontSize: 28,
fontWeight: "700",
color: "#1e40af",
textAlign: "center",
},
tagline: {
fontSize: 14,
color: "#6b7280",
textAlign: "center",
marginBottom: 32,
},
label: {
fontSize: 14,
fontWeight: "600",
color: "#374151",
marginBottom: 6,
},
input: {
borderWidth: 1,
borderColor: "#d1d5db",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 15,
color: "#111827",
marginBottom: 20,
backgroundColor: "#f9fafb",
},
button: {
backgroundColor: "#1e40af",
borderRadius: 8,
paddingVertical: 14,
alignItems: "center",
justifyContent: "center",
minHeight: 48,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: "#ffffff",
fontSize: 16,
fontWeight: "600",
},
hint: {
marginTop: 16,
fontSize: 12,
color: "#9ca3af",
textAlign: "center",
},
backLink: {
marginTop: 20,
alignItems: "center",
minHeight: 44,
justifyContent: "center",
},
backLinkText: {
fontSize: 13,
color: "#6b7280",
},
});
+195
View File
@@ -0,0 +1,195 @@
/**
* ProfileScreen – authenticated user profile and settings.
*/
import React from "react";
import {
Alert,
Image,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
View,
} from "react-native";
import { useAuth } from "../context/AuthContext";
export default function ProfileScreen() {
const { user, signOut, baseUrl } = useAuth();
function handleSignOut() {
Alert.alert("Sign out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{
text: "Sign out",
style: "destructive",
onPress: signOut,
},
]);
}
if (!user) {
return (
<View style={styles.center}>
<Text style={styles.emptyText}>Not signed in</Text>
</View>
);
}
return (
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
{/* Avatar + name */}
<View style={styles.profileCard}>
{user.avatar_url ? (
<Image
source={{ uri: user.avatar_url }}
style={styles.avatar}
accessibilityLabel={`Avatar for ${user.display_name ?? user.owner_id}`}
/>
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarInitial}>
{(user.display_name ?? user.owner_id).charAt(0).toUpperCase()}
</Text>
</View>
)}
<Text style={styles.displayName}>{user.display_name ?? user.owner_id}</Text>
{user.email && <Text style={styles.email}>{user.email}</Text>}
{user.is_admin && <Text style={styles.adminBadge}>Admin</Text>}
</View>
{/* Server info */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Connection</Text>
<View style={styles.row}>
<Text style={styles.rowLabel}>Server</Text>
<Text style={styles.rowValue} numberOfLines={1}>
{baseUrl || "–"}
</Text>
</View>
<View style={styles.row}>
<Text style={styles.rowLabel}>User ID</Text>
<Text style={styles.rowValue} numberOfLines={1}>
{user.owner_id}
</Text>
</View>
</View>
{/* Danger zone */}
<View style={styles.section}>
<Pressable
style={styles.signOutButton}
onPress={handleSignOut}
accessibilityRole="button"
accessibilityLabel="Sign out"
>
<Text style={styles.signOutText}>Sign out</Text>
</Pressable>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
scroll: { flex: 1, backgroundColor: "#f9fafb" },
content: { padding: 20 },
center: {
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f9fafb",
},
emptyText: { color: "#6b7280", fontSize: 16 },
profileCard: {
alignItems: "center",
backgroundColor: "#fff",
borderRadius: 16,
padding: 24,
marginBottom: 20,
shadowColor: "#000",
shadowOpacity: 0.06,
shadowOffset: { width: 0, height: 4 },
shadowRadius: 12,
elevation: 3,
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
marginBottom: 14,
},
avatarPlaceholder: {
backgroundColor: "#1e40af",
alignItems: "center",
justifyContent: "center",
},
avatarInitial: {
color: "#fff",
fontSize: 32,
fontWeight: "700",
},
displayName: {
fontSize: 20,
fontWeight: "700",
color: "#111827",
marginBottom: 4,
},
email: { fontSize: 14, color: "#6b7280", marginBottom: 6 },
adminBadge: {
backgroundColor: "#dbeafe",
color: "#1e40af",
fontSize: 11,
fontWeight: "700",
paddingHorizontal: 10,
paddingVertical: 3,
borderRadius: 12,
overflow: "hidden",
},
section: {
backgroundColor: "#fff",
borderRadius: 12,
padding: 16,
marginBottom: 16,
shadowColor: "#000",
shadowOpacity: 0.04,
shadowOffset: { width: 0, height: 2 },
shadowRadius: 6,
elevation: 2,
},
sectionTitle: {
fontSize: 13,
fontWeight: "700",
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: 0.5,
marginBottom: 12,
},
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: "#f3f4f6",
},
rowLabel: { fontSize: 14, color: "#374151" },
rowValue: {
fontSize: 14,
color: "#6b7280",
maxWidth: "60%",
textAlign: "right",
},
signOutButton: {
backgroundColor: "#fee2e2",
borderRadius: 10,
paddingVertical: 14,
alignItems: "center",
minHeight: 48,
},
signOutText: {
color: "#dc2626",
fontWeight: "700",
fontSize: 15,
},
});
+458
View File
@@ -0,0 +1,458 @@
/**
* UploadScreen – document upload via camera, photo library, or file picker.
*
* Users can:
* 1. Take a photo of a document with the device camera.
* 2. Select an existing photo from the device's photo library.
* 3. Pick an existing file (PDF, image, Office document) from the Files app.
* 4. Receive files shared from other apps via the iOS Share Sheet / Android
* Share Intent (handled via ShareContext populated by the root layout).
*
* After a successful upload the screen polls the backend every 5 seconds to
* track the real-time processing status of each uploaded file.
*/
import * as DocumentPicker from "expo-document-picker";
import * as ImagePicker from "expo-image-picker";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { useAuth } from "../context/AuthContext";
import { useShare } from "../context/ShareContext";
import api from "../services/api";
/** Statuses that indicate processing has finished (no further polling needed). */
const TERMINAL_STATUSES = new Set(["completed", "failed", "duplicate"]);
interface UploadItem {
id: string;
filename: string;
status: "pending" | "uploading" | "done" | "error";
error?: string;
taskId?: string;
/** Sanitised filename returned by the server – used to search for the record. */
originalFilename?: string;
/** Database ID of the FileRecord once it has been created by the worker. */
fileId?: number;
/** Actual server-side processing status (e.g. "pending", "processing", "completed"). */
serverStatus?: string;
/** Original file URI – retained so the upload can be retried on failure. */
uri?: string;
/** MIME type of the original file. */
mimeType?: string;
}
export default function UploadScreen() {
const { isAuthenticated } = useAuth();
const { pendingFiles, clearPendingFiles } = useShare();
const [uploads, setUploads] = useState<UploadItem[]>([]);
// Keep a ref in sync so the polling interval can read current state without
// capturing a stale closure.
const uploadsRef = useRef<UploadItem[]>([]);
useEffect(() => {
uploadsRef.current = uploads;
}, [uploads]);
// ---------------------------------------------------------------------------
// Core helpers (declared before the effects that depend on them)
// ---------------------------------------------------------------------------
const uploadFile = useCallback(async (uri: string, filename: string, mimeType?: string) => {
const id = `${Date.now()}-${filename}`;
setUploads((prev) => [{ id, filename, status: "uploading", uri, mimeType }, ...prev]);
try {
const resp = await api.uploadFile(uri, filename, mimeType);
setUploads((prev) =>
prev.map((item) =>
item.id === id
? { ...item, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
: item
)
);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Upload failed";
setUploads((prev) =>
prev.map((item) => (item.id === id ? { ...item, status: "error", error: msg } : item))
);
}
}, []);
const retryUpload = useCallback(async (item: UploadItem) => {
if (!item.uri) return;
// Reset the item to "uploading" and clear previous error/server state.
setUploads((prev) =>
prev.map((u) =>
u.id === item.id
? { ...u, status: "uploading" as const, error: undefined, serverStatus: undefined, fileId: undefined, taskId: undefined, originalFilename: undefined }
: u
)
);
try {
const resp = await api.uploadFile(item.uri, item.filename, item.mimeType);
setUploads((prev) =>
prev.map((u) =>
u.id === item.id
? { ...u, status: "done", taskId: resp.task_id, originalFilename: resp.original_filename }
: u
)
);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Upload failed";
setUploads((prev) =>
prev.map((u) => (u.id === item.id ? { ...u, status: "error", error: msg } : u))
);
}
}, []);
// ---------------------------------------------------------------------------
// Polling – check server-side processing status every 5 seconds
// ---------------------------------------------------------------------------
useEffect(() => {
const poll = async () => {
const current = uploadsRef.current;
for (const item of current) {
// Only poll items that were successfully uploaded and haven't reached a
// terminal status yet.
if (item.status !== "done") continue;
if (item.serverStatus && TERMINAL_STATUSES.has(item.serverStatus)) continue;
try {
if (item.fileId !== undefined) {
// We already know the file ID – just refresh its status.
const ps = await api.getFileStatus(item.fileId);
setUploads((prev) =>
prev.map((u) => (u.id === item.id ? { ...u, serverStatus: ps.status } : u))
);
} else if (item.originalFilename) {
// Search for the file by name; it may not exist yet if the worker
// hasn't started.
const files = await api.listFiles(1, 5, item.originalFilename);
const found = files.find((f) => f.original_filename === item.originalFilename);
if (found) {
setUploads((prev) =>
prev.map((u) =>
u.id === item.id
? { ...u, fileId: found.id, serverStatus: found.processing_status.status }
: u
)
);
}
}
} catch (err) {
// Network errors are transient – silently retry on the next tick.
console.debug("[StatusPoll] failed:", err);
}
}
};
const interval = setInterval(poll, 5000);
return () => clearInterval(interval);
}, []); // Intentionally empty – poll() reads state via uploadsRef
// ---------------------------------------------------------------------------
// Handle files received from the iOS Share Sheet / Android Share Intent
// ---------------------------------------------------------------------------
useEffect(() => {
if (pendingFiles.length === 0 || !isAuthenticated) return;
const files = [...pendingFiles];
clearPendingFiles();
files.forEach((file) => {
void uploadFile(file.uri, file.filename, file.mimeType);
});
}, [pendingFiles, isAuthenticated, clearPendingFiles, uploadFile]);
async function handleCamera() {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== "granted") {
Alert.alert(
"Camera access required",
"Please grant camera access in Settings to capture documents."
);
return;
}
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ["images"],
quality: 0.9,
allowsEditing: false,
});
if (!result.canceled && result.assets.length > 0) {
const asset = result.assets[0];
const filename = `scan_${Date.now()}.jpg`;
await uploadFile(asset.uri, filename, "image/jpeg");
}
}
async function handlePhotoLibrary() {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== "granted") {
Alert.alert(
"Photo library access required",
"Please grant photo library access in Settings to select images."
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
quality: 0.9,
allowsEditing: false,
});
if (!result.canceled && result.assets.length > 0) {
const asset = result.assets[0];
// Derive extension from MIME type so the filename matches the actual format
const ext = asset.mimeType?.split("/")[1]?.replace("jpeg", "jpg") ?? "jpg";
const filename = asset.fileName ?? `photo_${Date.now()}.${ext}`;
await uploadFile(asset.uri, filename, asset.mimeType ?? "image/jpeg");
}
}
async function handleFilePicker() {
try {
const result = await DocumentPicker.getDocumentAsync({
type: "*/*",
multiple: true,
copyToCacheDirectory: true,
});
if (!result.canceled) {
for (const asset of result.assets) {
await uploadFile(asset.uri, asset.name, asset.mimeType ?? undefined);
}
}
} catch (err: unknown) {
Alert.alert("File picker error", err instanceof Error ? err.message : "Could not open file picker");
}
}
if (!isAuthenticated) {
return (
<View style={styles.center}>
<Text style={styles.emptyText}>Please sign in to upload documents.</Text>
</View>
);
}
return (
<View style={styles.container}>
{/* Action buttons */}
<View style={styles.actions}>
<Pressable
style={[styles.actionButton, styles.cameraButton]}
onPress={handleCamera}
accessibilityRole="button"
accessibilityLabel="Capture document with camera"
>
<Text style={styles.actionIcon}>📷</Text>
<Text style={styles.actionLabel}>Camera</Text>
</Pressable>
<Pressable
style={[styles.actionButton, styles.photoLibraryButton]}
onPress={handlePhotoLibrary}
accessibilityRole="button"
accessibilityLabel="Select photo from library"
>
<Text style={styles.actionIcon}>🖼️</Text>
<Text style={styles.actionLabel}>Photos</Text>
</Pressable>
<Pressable
style={[styles.actionButton, styles.fileButton]}
onPress={handleFilePicker}
accessibilityRole="button"
accessibilityLabel="Pick file from device"
>
<Text style={styles.actionIcon}>📄</Text>
<Text style={styles.actionLabel}>Files</Text>
</Pressable>
</View>
{/* Upload list */}
<ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
{uploads.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyEmoji}>☁️</Text>
<Text style={styles.emptyText}>
Tap Camera, Photos, or Files to upload a document.
</Text>
<Text style={styles.emptyHint}>
You can also share files from other apps directly to DocuElevate.
</Text>
</View>
) : (
uploads.map((item) => (
<UploadRow key={item.id} item={item} onRetry={retryUpload} />
))
)}
</ScrollView>
</View>
);
}
function UploadRow({ item, onRetry }: { item: UploadItem; onRetry: (item: UploadItem) => void }) {
const uploadIcons: Record<UploadItem["status"], string> = {
pending: "⏳",
uploading: "⬆️",
done: "✅",
error: "❌",
};
/** Human-readable label for the server-side processing status. */
function serverStatusLabel(s: string): string {
const labels: Record<string, string> = {
pending: "Queued for processing…",
processing: "Processing…",
completed: "Processed ✓",
failed: "Processing failed",
duplicate: "Duplicate – already processed",
};
return labels[s] ?? s;
}
const canRetry = item.status === "error" && !!item.uri;
function handleLongPress() {
if (!canRetry) return;
Alert.alert("Retry Upload", `Do you want to retry uploading "${item.filename}"?`, [
{ text: "Cancel", style: "cancel" },
{ text: "Retry", onPress: () => onRetry(item) },
]);
}
return (
<Pressable
onLongPress={handleLongPress}
onPress={canRetry ? () => onRetry(item) : undefined}
style={rowStyles.row}
accessibilityRole={canRetry ? "button" : "none"}
accessibilityLabel={canRetry ? `Retry uploading ${item.filename}` : undefined}
accessibilityHint={canRetry ? "Tap or long-press to retry this upload" : undefined}
>
<Text style={rowStyles.icon}>{uploadIcons[item.status]}</Text>
<View style={rowStyles.info}>
<Text style={rowStyles.filename} numberOfLines={1}>
{item.filename}
</Text>
{item.status === "uploading" && (
<ActivityIndicator size="small" color="#1e40af" />
)}
{item.status === "done" && !item.serverStatus && (
<Text style={rowStyles.statusQueued}>Queued for processing…</Text>
)}
{item.status === "done" && item.serverStatus && (
<Text
style={
item.serverStatus === "completed"
? rowStyles.statusDone
: item.serverStatus === "failed"
? rowStyles.statusError
: rowStyles.statusQueued
}
>
{serverStatusLabel(item.serverStatus)}
</Text>
)}
{item.status === "error" && (
<View>
<Text style={rowStyles.statusError}>{item.error}</Text>
{canRetry && (
<Text style={rowStyles.retryHint}>Tap to retry</Text>
)}
</View>
)}
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#f9fafb" },
actions: {
flexDirection: "row",
padding: 16,
gap: 12,
},
actionButton: {
flex: 1,
borderRadius: 12,
paddingVertical: 20,
alignItems: "center",
justifyContent: "center",
minHeight: 80,
},
cameraButton: { backgroundColor: "#1e40af" },
photoLibraryButton: { backgroundColor: "#7c3aed" },
fileButton: { backgroundColor: "#059669" },
actionIcon: { fontSize: 28, marginBottom: 6 },
actionLabel: {
color: "#fff",
fontSize: 14,
fontWeight: "600",
},
list: { flex: 1 },
listContent: { padding: 16 },
emptyState: {
alignItems: "center",
paddingTop: 60,
},
emptyEmoji: { fontSize: 48, marginBottom: 12 },
emptyText: {
fontSize: 16,
color: "#374151",
textAlign: "center",
marginBottom: 8,
},
emptyHint: {
fontSize: 13,
color: "#6b7280",
textAlign: "center",
paddingHorizontal: 32,
},
center: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
});
const rowStyles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#fff",
borderRadius: 10,
padding: 14,
marginBottom: 10,
shadowColor: "#000",
shadowOpacity: 0.04,
shadowOffset: { width: 0, height: 2 },
shadowRadius: 4,
elevation: 2,
},
icon: { fontSize: 22, marginRight: 12 },
info: { flex: 1 },
filename: {
fontSize: 14,
fontWeight: "600",
color: "#111827",
marginBottom: 4,
},
statusDone: { fontSize: 12, color: "#059669" },
statusQueued: { fontSize: 12, color: "#6b7280" },
statusError: { fontSize: 12, color: "#dc2626" },
retryHint: { fontSize: 12, color: "#1e40af", fontWeight: "600", marginTop: 4 },
});
+209
View File
@@ -0,0 +1,209 @@
/**
* WelcomeScreen – first screen shown to unauthenticated users on first launch.
*
* Presents the DocuElevate brand, a short description of what the app does,
* and a "Get Started" button that navigates to the LoginScreen.
*/
import { useRouter } from "expo-router";
import React from "react";
import {
Image,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
const FEATURES: { icon: string; title: string; description: string }[] = [
{
icon: "🔍",
title: "OCR & Text Extraction",
description: "Convert scanned PDFs and images into fully searchable text automatically.",
},
{
icon: "🤖",
title: "AI Metadata Extraction",
description: "AI classifies documents and pulls out key fields like dates, amounts, and subjects.",
},
{
icon: "☁️",
title: "Multi-Cloud Storage",
description: "Route processed files to Dropbox, Google Drive, OneDrive, S3, Nextcloud, and more.",
},
];
export default function WelcomeScreen() {
const router = useRouter();
return (
<SafeAreaView style={styles.safe}>
<ScrollView
contentContainerStyle={styles.scroll}
showsVerticalScrollIndicator={false}
>
{/* Hero */}
<View style={styles.hero}>
<View style={styles.logoContainer}>
<Image
source={require("../../assets/logo.png")}
style={styles.logoImage}
resizeMode="contain"
accessibilityLabel="DocuElevate logo"
/>
</View>
<Text style={styles.appName}>DocuElevate</Text>
<Text style={styles.tagline}>Intelligent Document Processing</Text>
<Text style={styles.heroDescription}>
Ingest documents, run OCR, extract metadata with AI, and route files
to your cloud storage — all in one seamless pipeline.
</Text>
</View>
{/* Feature highlights */}
<View style={styles.features}>
{FEATURES.map((feature) => (
<View key={feature.title} style={styles.featureRow}>
<Text style={styles.featureIcon} aria-hidden={true}>{feature.icon}</Text>
<View style={styles.featureText}>
<Text style={styles.featureTitle}>{feature.title}</Text>
<Text style={styles.featureDescription}>{feature.description}</Text>
</View>
</View>
))}
</View>
{/* CTA */}
<Pressable
style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}
onPress={() => router.push("/(auth)/login")}
accessibilityRole="button"
accessibilityLabel="Get started — connect to your DocuElevate server"
>
<Text style={styles.buttonText}>Get Started</Text>
</Pressable>
<Text style={styles.hint}>
Connect to your self-hosted or cloud DocuElevate server.
</Text>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: {
flex: 1,
backgroundColor: "#1e40af",
},
scroll: {
flexGrow: 1,
paddingHorizontal: 28,
paddingTop: 48,
paddingBottom: 40,
},
hero: {
alignItems: "center",
marginBottom: 40,
},
logoContainer: {
width: 96,
height: 96,
borderRadius: 24,
backgroundColor: "rgba(255,255,255,0.15)",
alignItems: "center",
justifyContent: "center",
marginBottom: 16,
},
logoImage: {
width: 72,
height: 72,
},
appName: {
fontSize: 36,
fontWeight: "800",
color: "#ffffff",
textAlign: "center",
marginBottom: 6,
letterSpacing: 0.5,
},
tagline: {
fontSize: 14,
fontWeight: "600",
color: "rgba(255,255,255,0.75)",
textTransform: "uppercase",
letterSpacing: 1.5,
textAlign: "center",
marginBottom: 16,
},
heroDescription: {
fontSize: 16,
color: "rgba(255,255,255,0.85)",
textAlign: "center",
lineHeight: 24,
maxWidth: 320,
},
features: {
backgroundColor: "rgba(255,255,255,0.1)",
borderRadius: 16,
paddingVertical: 8,
paddingHorizontal: 16,
marginBottom: 36,
},
featureRow: {
flexDirection: "row",
alignItems: "flex-start",
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "rgba(255,255,255,0.2)",
},
featureIcon: {
fontSize: 24,
marginRight: 14,
marginTop: 1,
},
featureText: {
flex: 1,
},
featureTitle: {
fontSize: 15,
fontWeight: "600",
color: "#ffffff",
marginBottom: 2,
},
featureDescription: {
fontSize: 13,
color: "rgba(255,255,255,0.75)",
lineHeight: 18,
},
button: {
backgroundColor: "#ffffff",
borderRadius: 12,
paddingVertical: 16,
alignItems: "center",
justifyContent: "center",
minHeight: 52,
shadowColor: "#000",
shadowOpacity: 0.15,
shadowOffset: { width: 0, height: 4 },
shadowRadius: 8,
elevation: 4,
marginBottom: 16,
},
buttonPressed: {
opacity: 0.9,
transform: [{ scale: 0.98 }],
},
buttonText: {
color: "#1e40af",
fontSize: 17,
fontWeight: "700",
letterSpacing: 0.3,
},
hint: {
fontSize: 12,
color: "rgba(255,255,255,0.55)",
textAlign: "center",
},
});
+214
View File
@@ -0,0 +1,214 @@
/**
* DocuElevate API client for the mobile app.
*
* All requests authenticate via a Bearer token stored in the device's secure
* keychain (via expo-secure-store). The token is obtained once through the
* SSO flow and cached until the user explicitly logs out.
*/
import * as SecureStore from "expo-secure-store";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const SECURE_STORE_API_TOKEN_KEY = "de_api_token";
export const SECURE_STORE_BASE_URL_KEY = "de_base_url";
export const SECURE_STORE_OWNER_ID_KEY = "de_owner_id";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface WhoAmIResponse {
owner_id: string;
display_name: string | null;
email: string | null;
avatar_url: string | null;
is_admin: boolean;
}
export interface GenerateTokenResponse {
token: string;
token_id: number;
name: string;
created_at: string;
}
export interface DeviceRegistration {
push_token: string;
device_name?: string;
platform: "ios" | "android" | "web";
}
export interface ProcessingStatus {
status: string;
last_step: string | null;
has_errors: boolean;
total_steps: number;
}
export interface FileRecord {
id: number;
original_filename: string;
file_size: number | null;
mime_type: string | null;
created_at: string;
processing_status: ProcessingStatus;
}
export interface UploadResponse {
task_id: string;
status: string;
original_filename: string;
stored_filename: string;
}
// ---------------------------------------------------------------------------
// Base API client
// ---------------------------------------------------------------------------
class DocuElevateAPI {
private baseUrl: string = "";
async init(baseUrl: string): Promise<void> {
this.baseUrl = baseUrl.replace(/\/$/, "");
await SecureStore.setItemAsync(SECURE_STORE_BASE_URL_KEY, this.baseUrl);
}
async loadFromStorage(): Promise<boolean> {
try {
const url = await SecureStore.getItemAsync(SECURE_STORE_BASE_URL_KEY);
if (url) {
this.baseUrl = url;
return true;
}
} catch {
// ignore
}
return false;
}
getBaseUrl(): string {
return this.baseUrl;
}
private async getToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(SECURE_STORE_API_TOKEN_KEY);
} catch {
return null;
}
}
private async request<T>(
method: string,
path: string,
options?: { body?: unknown; formData?: FormData }
): Promise<T> {
const token = await this.getToken();
const headers: Record<string, string> = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
let body: string | FormData | undefined;
if (options?.formData) {
body = options.formData;
// Let fetch set multipart content-type with boundary automatically
} else if (options?.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(options.body);
}
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers,
body,
});
if (!response.ok) {
let detail = `HTTP ${response.status}`;
try {
const err = await response.json();
detail = err.detail || JSON.stringify(err);
} catch {
// ignore
}
throw new Error(detail);
}
if (response.status === 204) {
return undefined as unknown as T;
}
return response.json();
}
// -------------------------------------------------------------------------
// Auth
// -------------------------------------------------------------------------
/** Exchange the current session (cookie) for a long-lived API token. */
async generateMobileToken(deviceName: string): Promise<GenerateTokenResponse> {
return this.request<GenerateTokenResponse>("POST", "/api/mobile/generate-token", {
body: { device_name: deviceName },
});
}
/** Return profile information for the authenticated user. */
async whoAmI(): Promise<WhoAmIResponse> {
return this.request<WhoAmIResponse>("GET", "/api/mobile/whoami");
}
// -------------------------------------------------------------------------
// Push notifications
// -------------------------------------------------------------------------
/** Register a push notification device token. */
async registerDevice(data: DeviceRegistration): Promise<void> {
await this.request("POST", "/api/mobile/register-device", { body: data });
}
/** Deactivate a device registration. */
async deactivateDevice(deviceId: number): Promise<void> {
await this.request("DELETE", `/api/mobile/devices/${deviceId}`);
}
// -------------------------------------------------------------------------
// Files
// -------------------------------------------------------------------------
/** Upload a file for processing. */
async uploadFile(uri: string, filename: string, mimeType?: string): Promise<UploadResponse> {
const formData = new FormData();
formData.append("file", {
uri,
name: filename,
type: mimeType || "application/octet-stream",
} as unknown as Blob);
return this.request<UploadResponse>("POST", "/api/ui-upload", { formData });
}
/** List recently processed files, optionally filtered by filename search. */
async listFiles(page = 1, pageSize = 20, search?: string): Promise<FileRecord[]> {
let url = `/api/files?page=${page}&per_page=${pageSize}`;
if (search) url += `&search=${encodeURIComponent(search)}`;
const data = await this.request<{ files: FileRecord[]; pagination: unknown }>("GET", url);
return data.files;
}
/** Get the processing status for a single file by its ID. */
async getFileStatus(fileId: number): Promise<ProcessingStatus> {
const data = await this.request<{ processing_status: ProcessingStatus }>(
"GET",
`/api/files/${fileId}`
);
return data.processing_status;
}
}
export const api = new DocuElevateAPI();
export default api;