diff --git a/surfsense_web/contracts/types/document.types.ts b/surfsense_web/contracts/types/document.types.ts index 2168b0a21..c5d777080 100644 --- a/surfsense_web/contracts/types/document.types.ts +++ b/surfsense_web/contracts/types/document.types.ts @@ -87,7 +87,7 @@ export const createDocumentRequest = document }); export const createDocumentResponse = z.object({ - message: z.literal("Document created successfully"), + message: z.literal("Documents created successfully"), }); /** diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index 3013be70a..e4b20faa4 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -260,6 +260,26 @@ class BaseApiService { responseType: ResponseType.BLOB, }); } + + async postFormData( + url: string, + responseSchema?: z.ZodSchema, + options?: Omit & { body: FormData } + ) { + // Remove Content-Type from options headers if present + const { "Content-Type": _, ...headersWithoutContentType } = options?.headers ?? {}; + + return this.request(url, responseSchema, { + method: "POST", + ...options, + headers: { + // Don't set Content-Type - let browser set it with multipart boundary + Authorization: `Bearer ${this.bearerToken}`, + ...headersWithoutContentType, + }, + responseType: ResponseType.JSON, + }); + } } export const baseApiService = new BaseApiService( diff --git a/surfsense_web/lib/apis/documents-api.service.ts b/surfsense_web/lib/apis/documents-api.service.ts index e69de29bb..6e67b6aa0 100644 --- a/surfsense_web/lib/apis/documents-api.service.ts +++ b/surfsense_web/lib/apis/documents-api.service.ts @@ -0,0 +1,263 @@ +import { + type CreateDocumentRequest, + createDocumentRequest, + createDocumentResponse, + type DeleteDocumentRequest, + deleteDocumentRequest, + deleteDocumentResponse, + type GetDocumentByChunkRequest, + type GetDocumentRequest, + type GetDocumentsRequest, + type GetDocumentTypeCountsRequest, + getDocumentByChunkRequest, + getDocumentByChunkResponse, + getDocumentRequest, + getDocumentResponse, + getDocumentsRequest, + getDocumentsResponse, + getDocumentTypeCountsRequest, + getDocumentTypeCountsResponse, + type SearchDocumentsRequest, + searchDocumentsRequest, + searchDocumentsResponse, + type UpdateDocumentRequest, + type UploadDocumentRequest, + updateDocumentRequest, + updateDocumentResponse, + uploadDocumentRequest, + uploadDocumentResponse, +} from "@/contracts/types/document.types"; +import { ValidationError } from "../error"; +import { baseApiService } from "./base-api.service"; + +class DocumentsApiService { + /** + * Get a list of documents with optional filtering and pagination + */ + getDocuments = async (request: GetDocumentsRequest) => { + // Validate the request + const parsedRequest = getDocumentsRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user friendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + // Transform query params to be string values + const transformedQueryParams = parsedRequest.data.queryParams + ? Object.fromEntries( + Object.entries(parsedRequest.data.queryParams).map(([k, v]) => { + // Handle array values (document_type) + if (Array.isArray(v)) { + return [k, v.join(",")]; + } + return [k, String(v)]; + }) + ) + : undefined; + + const queryParams = transformedQueryParams + ? new URLSearchParams(transformedQueryParams).toString() + : ""; + + return baseApiService.get(`/api/v1/documents?${queryParams}`, getDocumentsResponse); + }; + + /** + * Get a single document by ID + */ + getDocument = async (request: GetDocumentRequest) => { + // Validate the request + const parsedRequest = getDocumentRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user friendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.get(`/api/v1/documents/${request.id}`, getDocumentResponse); + }; + + /** + * Create documents (extension, crawled URL, or YouTube video) + */ + createDocument = async (request: CreateDocumentRequest) => { + // Validate the request + const parsedRequest = createDocumentRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user friendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.post(`/api/v1/documents`, createDocumentResponse, { + body: parsedRequest.data, + }); + }; + + /** + * Upload document files + */ + uploadDocument = async (request: UploadDocumentRequest) => { + // Validate the request + const parsedRequest = uploadDocumentRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user friendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + // Create FormData for file upload + const formData = new FormData(); + parsedRequest.data.files.forEach((file) => { + formData.append("files", file); + }); + formData.append("search_space_id", String(parsedRequest.data.search_space_id)); + + return baseApiService.postFormData(`/api/v1/documents/fileupload`, uploadDocumentResponse, { + body: formData, + }); + }; + + /** + * Search documents by title + */ + searchDocuments = async (request: SearchDocumentsRequest) => { + // Validate the request + const parsedRequest = searchDocumentsRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user friendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + // Transform query params to be string values + const transformedQueryParams = parsedRequest.data.queryParams + ? Object.fromEntries( + Object.entries(parsedRequest.data.queryParams).map(([k, v]) => { + // Handle array values (document_type) + if (Array.isArray(v)) { + return [k, v.join(",")]; + } + return [k, String(v)]; + }) + ) + : undefined; + + const queryParams = transformedQueryParams + ? new URLSearchParams(transformedQueryParams).toString() + : ""; + + return baseApiService.get(`/api/v1/documents/search?${queryParams}`, searchDocumentsResponse); + }; + + /** + * Get document type counts + */ + getDocumentTypeCounts = async (request: GetDocumentTypeCountsRequest) => { + // Validate the request + const parsedRequest = getDocumentTypeCountsRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user friendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + // Transform query params to be string values + const transformedQueryParams = parsedRequest.data.queryParams + ? Object.fromEntries( + Object.entries(parsedRequest.data.queryParams).map(([k, v]) => [k, String(v)]) + ) + : undefined; + + const queryParams = transformedQueryParams + ? new URLSearchParams(transformedQueryParams).toString() + : ""; + + return baseApiService.get( + `/api/v1/documents/type-counts?${queryParams}`, + getDocumentTypeCountsResponse + ); + }; + + /** + * Get document by chunk ID (includes all chunks) + */ + getDocumentByChunk = async (request: GetDocumentByChunkRequest) => { + // Validate the request + const parsedRequest = getDocumentByChunkRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user friendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.get( + `/api/v1/documents/by-chunk/${request.chunk_id}`, + getDocumentByChunkResponse + ); + }; + + /** + * Update a document + */ + updateDocument = async (request: UpdateDocumentRequest) => { + // Validate the request + const parsedRequest = updateDocumentRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user friendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + const { id, data } = parsedRequest.data; + + return baseApiService.put(`/api/v1/documents/${id}`, updateDocumentResponse, { + body: data, + }); + }; + + /** + * Delete a document + */ + deleteDocument = async (request: DeleteDocumentRequest) => { + // Validate the request + const parsedRequest = deleteDocumentRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + + // Format a user friendly error message + const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.delete(`/api/v1/documents/${request.id}`, deleteDocumentResponse); + }; +} + +export const documentsApiService = new DocumentsApiService();