Files
gh-christianlouis-docuelevate/mobile/src/services/api.ts
T
copilot-swe-agent[bot] d5c18ccf07 fix(upload): reject exact duplicates at upload time and prevent duplicate mobile uploads
- Move duplicate check before task enqueue in ui_upload endpoint
- Clean up temp file and return status "duplicate" for exact duplicates
- Add URI-level dedup guard in mobile UploadScreen to prevent repeated uploads
- Improve ShareContext URI normalization (collapse slashes, decode percent-encoding)
- Guard +not-found.tsx effect against re-firing for the same pathname
- Update mobile UploadResponse type and handlers for duplicate status
- Update web frontend upload.js to show duplicate status
- Update API and Configuration docs

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-19 14:07:43 +00:00

236 lines
6.7 KiB
TypeScript

/**
* 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 QRClaimResponse {
token: string;
token_id: number;
name: string;
owner_id: 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;
duplicate_of?: {
duplicate_type: string;
original_file_id: number;
original_filename: string;
message: 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 },
});
}
/** Claim a QR login challenge and receive an API token. */
async claimQRChallenge(challengeToken: string, deviceName: string): Promise<QRClaimResponse> {
return this.request<QRClaimResponse>("POST", "/api/qr-auth/claim", {
body: { challenge_token: challengeToken, 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;