From a47a88b967e842f42dcbf7e1e5b78194d23662a8 Mon Sep 17 00:00:00 2001 From: Chris Carlon Date: Wed, 30 Oct 2024 23:21:35 +0000 Subject: [PATCH] feat(Backend and UI): Progress made on fileupload logic. Changes made to upload_layer Axum endpoint and file upload modal in the front end. File uploads but more work needed to refactor code so state is tracked correctly and workspace id / auth token are passed in correctly and not dummy values used hardcoded. [2024-10-30] --- gridwalk-backend/src/core/layer.rs | 12 +- gridwalk-backend/src/data/dynamodb/config.rs | 2 - gridwalk-backend/src/routes/layer.rs | 28 ++- gridwalk-ui/src/app/api/upload/layer/route.ts | 66 ++++--- .../components/navBars/mainMapNavigation.tsx | 52 ++--- .../app/project/components/navBars/types.ts | 3 +- gridwalk-ui/src/app/project/page.tsx | 184 +++++++++++------- .../{london-center-geojson.json => test.json} | 0 8 files changed, 193 insertions(+), 154 deletions(-) rename test_files/{london-center-geojson.json => test.json} (100%) diff --git a/gridwalk-backend/src/core/layer.rs b/gridwalk-backend/src/core/layer.rs index 8c5738c..ad29f15 100644 --- a/gridwalk-backend/src/core/layer.rs +++ b/gridwalk-backend/src/core/layer.rs @@ -5,7 +5,6 @@ use anyhow::{anyhow, Result}; use duckdb_postgis; use serde::{Deserialize, Serialize}; use std::sync::Arc; -use uuid::Uuid; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct CreateLayer { @@ -15,7 +14,6 @@ pub struct CreateLayer { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Layer { - pub id: String, pub workspace_id: String, pub name: String, pub uploaded_by: String, @@ -25,7 +23,6 @@ pub struct Layer { impl Layer { pub fn from_req(req: CreateLayer, user: &User) -> Self { Layer { - id: Uuid::new_v4().to_string(), workspace_id: req.workspace_id, name: req.name, uploaded_by: user.id.clone(), @@ -39,26 +36,25 @@ impl Layer { user: &User, workspace: &Workspace, ) -> Result<()> { - // Get workspace member record + // Get workspace member let requesting_member = workspace.get_member(database, user).await?; if requesting_member.role == WorkspaceRole::Read { return Err(anyhow!("User does not have permissions to create layers.")); } - database.create_layer(self).await?; Ok(()) } pub async fn send_to_postgis(&self, file_path: &str) -> Result<()> { - let postgis_uri = std::env::var("POSTGIS_URI") - .map_err(|_| anyhow!("PostGIS URI not set in environment variables"))?; + let postgis_uri = "postgresql://admin:password@localhost:5432/gridwalk"; let schema = duckdb_postgis::duckdb_load::launch_process_file( file_path, - &self.id, + &self.name, &postgis_uri, &self.workspace_id, ) .map_err(|e| anyhow!("Failed to send file to PostGIS: {:?}", e))?; println!("{:?}", schema); + println!("Uploaded to POSTGIS BABY!"); Ok(()) } diff --git a/gridwalk-backend/src/data/dynamodb/config.rs b/gridwalk-backend/src/data/dynamodb/config.rs index 3a5c2e0..c15866d 100644 --- a/gridwalk-backend/src/data/dynamodb/config.rs +++ b/gridwalk-backend/src/data/dynamodb/config.rs @@ -420,14 +420,12 @@ impl UserStore for Dynamodb { } async fn create_layer(&self, layer: &Layer) -> Result<()> { - // Create the workspace member item to insert let mut item = std::collections::HashMap::new(); item.insert( String::from("PK"), AV::S(format!("WSP#{}", layer.workspace_id)), ); - item.insert(String::from("SK"), AV::S(format!("LAYER#{}", layer.id))); item.insert(String::from("name"), AV::S(layer.clone().name)); item.insert( String::from("uploaded_by"), diff --git a/gridwalk-backend/src/routes/layer.rs b/gridwalk-backend/src/routes/layer.rs index 51316bb..eacb19c 100644 --- a/gridwalk-backend/src/routes/layer.rs +++ b/gridwalk-backend/src/routes/layer.rs @@ -7,7 +7,6 @@ use axum::{ response::IntoResponse, Json, }; -use duckdb_postgis::duckdb_load::launch_process_file; use std::path::Path; use std::sync::Arc; use tokio::{ @@ -15,7 +14,6 @@ use tokio::{ io::AsyncWriteExt, }; -// Remove the generic type parameter and use Arc directly pub async fn upload_layer( State(state): State>, Extension(auth_user): Extension, @@ -77,7 +75,9 @@ pub async fn upload_layer( // Save file locally let dir_path = Path::new("uploads"); - let file_path = dir_path.join(&layer.id); + let file_path = dir_path.join(&layer.name); + let file_path_str = file_path.to_str().unwrap(); + fs::create_dir_all(dir_path) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -90,23 +90,21 @@ pub async fn upload_layer( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // Process file and upload to PostGIS - let postgis_uri = "postgresql://admin:password@localhost:5432/gridwalk"; - launch_process_file( - file_path.to_str().unwrap(), - &layer.id, - postgis_uri, - &layer.workspace_id, - ) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - println!("Uploaded to POSTGIS!"); - - // Write layer record to database using Arc + // Check permissions of user layer .create(&state.app_data, user, &workspace) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + // Write layer table to PostGis + layer + .send_to_postgis(file_path_str) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Write layer record to App Database + layer.write_record(&state.app_data).await.ok(); + // Return success response let json_response = serde_json::to_value(&layer).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; diff --git a/gridwalk-ui/src/app/api/upload/layer/route.ts b/gridwalk-ui/src/app/api/upload/layer/route.ts index 58d48c1..14b528d 100644 --- a/gridwalk-ui/src/app/api/upload/layer/route.ts +++ b/gridwalk-ui/src/app/api/upload/layer/route.ts @@ -1,31 +1,50 @@ import { NextRequest } from "next/server"; -// Hardcoded auth token - replace with your actual token -const HARDCODED_AUTH_TOKEN = ""; +const HARDCODED_AUTH_TOKEN = "VVPME0BYEDG7LJYGLL9PKJ8AS1GABM"; + +interface LayerInfo { + workspace_id: string; + name: string; +} export async function POST(request: NextRequest) { try { const formData = await request.formData(); + + // Validation step const file = formData.get("file"); + const workspaceId = formData.get("workspace_id"); + const layerName = formData.get("name"); + // Early validation return if (!file || !(file instanceof File)) { - return new Response( - JSON.stringify({ - success: false, - error: "No file provided", - }), + return Response.json( + { success: false, error: "No file provided" }, + { status: 400 }, + ); + } + + if (!workspaceId || !layerName) { + return Response.json( + { success: false, error: "Missing workspace_id or name" }, { status: 400 }, ); } - // Create a new FormData for the API call + // Transform for Rust backend + const layerInfo: LayerInfo = { + workspace_id: workspaceId.toString(), + name: layerName.toString(), + }; + const apiFormData = new FormData(); apiFormData.append("file", file); + apiFormData.append( + "layer_info", + new Blob([JSON.stringify(layerInfo)], { type: "application/json" }), + ); - // Hardcoded values - apiFormData.append("workspace_id", ""); - apiFormData.append("name", "my-awesome-layer"); - + // Send to backend const response = await fetch("http://localhost:3001/upload_layer", { method: "POST", headers: { @@ -35,27 +54,22 @@ export async function POST(request: NextRequest) { }); if (!response.ok) { - throw new Error("Upload failed"); + const errorText = await response.text(); + throw new Error(errorText || response.statusText); } + console.log("SUCCESS"); + const data = await response.json(); - return new Response(JSON.stringify({ success: true, data }), { - status: 200, - }); + return Response.json({ success: true, data }); } catch (error) { console.error("Upload error:", error); - return new Response( - JSON.stringify({ + return Response.json( + { success: false, - error: "Upload failed", - }), + error: error instanceof Error ? error.message : "Upload failed", + }, { status: 500 }, ); } } - -export const config = { - api: { - bodyParser: false, - }, -}; diff --git a/gridwalk-ui/src/app/project/components/navBars/mainMapNavigation.tsx b/gridwalk-ui/src/app/project/components/navBars/mainMapNavigation.tsx index ef7569a..b6f7276 100644 --- a/gridwalk-ui/src/app/project/components/navBars/mainMapNavigation.tsx +++ b/gridwalk-ui/src/app/project/components/navBars/mainMapNavigation.tsx @@ -7,7 +7,7 @@ import { File, X, Upload, - Trash2, + CheckCircle, } from "lucide-react"; import { ModalProps, MainMapNav } from "./types"; @@ -16,11 +16,12 @@ const MapModal: React.FC = ({ onClose, onNavItemClick, selectedItem, - layers, + // layers, onLayerUpload, - onLayerDelete, + // onLayerDelete, isUploading, error, + uploadSuccess, }) => { const MainMapNavs: MainMapNav[] = [ { @@ -99,7 +100,7 @@ const MapModal: React.FC = ({ type="file" className="hidden" onChange={handleFileChange} - accept=".geojson,.json,.kml,.gpx" + accept=".geojson,.json,.gpkg" disabled={isUploading} /> @@ -107,35 +108,24 @@ const MapModal: React.FC = ({ {/* Status Messages */} {isUploading && ( -
Uploading...
+
+ + Uploading... +
)} - {error &&
{error}
} - {/* Layers List */} -
- {layers.length === 0 ? ( -

No layers uploaded yet

- ) : ( - layers.map((layer) => ( -
-
-

{layer.name}

-

{layer.type}

-
- -
- )) - )} -
+ {error && ( +
+ ❌ {error} +
+ )} + + {uploadSuccess && !isUploading && !error && ( +
+ + File uploaded successfully! +
+ )} ); diff --git a/gridwalk-ui/src/app/project/components/navBars/types.ts b/gridwalk-ui/src/app/project/components/navBars/types.ts index de0bd26..45db59c 100644 --- a/gridwalk-ui/src/app/project/components/navBars/types.ts +++ b/gridwalk-ui/src/app/project/components/navBars/types.ts @@ -13,11 +13,12 @@ export interface ModalProps { onClose: () => void; onNavItemClick: (item: MainMapNav) => void; selectedItem: MainMapNav | null; - layers: LayerUpload[]; + // layers: LayerUpload[]; onLayerUpload: (file: File) => Promise; onLayerDelete: (layerId: string) => void; isUploading: boolean; error: string | null; + uploadSuccess: boolean; } /* Map Edit Items */ diff --git a/gridwalk-ui/src/app/project/page.tsx b/gridwalk-ui/src/app/project/page.tsx index cc96ffb..164ff24 100644 --- a/gridwalk-ui/src/app/project/page.tsx +++ b/gridwalk-ui/src/app/project/page.tsx @@ -1,28 +1,53 @@ "use client"; -import React, { useState } from "react"; + +import React, { useState, useCallback } from "react"; import { useMapInit } from "./components/mapInit/mapInit"; import MainMapNavigation from "./components/navBars/mainMapNavigation"; -import { MainMapNav, LayerUpload } from "./components/navBars/types"; import MapEditNavigation from "./components/navBars/mapEditNavigation"; -import { MapEditNav } from "./components/navBars/types"; import BaseLayerNavigation from "./components/navBars/baseLayerNavigation"; -import { BaseEditNav } from "./components/navBars/types"; +import { + MainMapNav, + MapEditNav, + BaseEditNav, +} from "./components/navBars/types"; + +// Type definitions +export interface LayerUpload { + id: string; + name: string; + type: string; + visible: boolean; + workspace_id?: string; +} -// Map styles configuration +export interface UploadResponse { + success: boolean; + data?: { + id: string; + name: string; + workspace_id: string; + }; + error?: string; +} + +// Constants with strict typing const MAP_STYLES = { light: "/OS_VTS_3857_Light.json", dark: "/OS_VTS_3857_Dark.json", car: "/OS_VTS_3857_Road.json", } as const; -// Initial map configuration +type MapStyleKey = keyof typeof MAP_STYLES; + const INITIAL_MAP_CONFIG = { center: [-0.1278, 51.5074] as [number, number], zoom: 11, -}; +} as const; + +const DEFAULT_WORKSPACE = "d068ebc4-dc32-4929-ac55-869e04bfb269" as const; export default function Project() { - // State management + // Navigation state const [selectedItem, setSelectedItem] = useState(null); const [selectedEditItem, setSelectedEditItem] = useState( null, @@ -33,112 +58,129 @@ export default function Project() { const [isModalOpen, setIsModalOpen] = useState(false); const [currentStyle, setCurrentStyle] = useState(MAP_STYLES.light); - // Layer state management + // Layer state const [layers, setLayers] = useState([]); + const [uploadSuccess, setUploadSuccess] = useState(false); const [isUploading, setIsUploading] = useState(false); const [error, setError] = useState(null); - // Map initialisation + // Map initialization const { mapContainer, mapError } = useMapInit({ ...INITIAL_MAP_CONFIG, styleUrl: currentStyle, }); - // Layer handlers - const handleLayerUpload = async (file: File) => { - setIsUploading(true); - setError(null); - - try { - const formData = new FormData(); - formData.append("file", file); - - const response = await fetch("/api/upload/layer", { - method: "POST", - body: formData, - }); - - if (!response.ok) { - throw new Error("Upload failed"); + // Upload handler with improved error handling and type safety + const handleLayerUpload = useCallback( + async ( + file: File, + workspaceId: string = DEFAULT_WORKSPACE, + ): Promise => { + setIsUploading(true); + setError(null); + setUploadSuccess(false); + + if (!file) { + setError("No file provided"); + setIsUploading(false); + return; } - const data = await response.json(); - - setLayers((prev) => [ - ...prev, - { - id: data.data.id || Math.random().toString(), - name: file.name, - type: file.type, - visible: true, - }, - ]); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to upload file"); - } finally { - setIsUploading(false); - } - }; + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("workspace_id", workspaceId); + formData.append("name", file.name); + + const response = await fetch("/api/upload/layer", { + method: "POST", + body: formData, + }); + + const data = (await response.json()) as UploadResponse; + + if (!response.ok || !data.success) { + throw new Error( + data.error || `Upload failed with status: ${response.status}`, + ); + } + // Set success and reset after 3 seconds + setUploadSuccess(true); + setTimeout(() => setUploadSuccess(false), 3000); + } catch (err) { + const errorMessage = + err instanceof Error + ? err.message + : "Unknown error during file upload"; + setError(errorMessage); + console.error("Layer upload error:", err); + } finally { + setIsUploading(false); + } + }, + [], + ); - const handleLayerDelete = (layerId: string) => { + // Event handlers with proper typing + const handleLayerDelete = useCallback((layerId: string) => { setLayers((prev) => prev.filter((layer) => layer.id !== layerId)); - }; + }, []); - // Navigation handlers - const handleNavItemClick = (item: MainMapNav) => { + const handleNavItemClick = useCallback((item: MainMapNav) => { setSelectedItem(item); setIsModalOpen(true); - }; + }, []); - const handleEditItemClick = (item: MapEditNav) => { - setSelectedEditItem(item === selectedEditItem ? null : item); - }; + const handleEditItemClick = useCallback((item: MapEditNav) => { + setSelectedEditItem((prev) => (prev?.id === item.id ? null : item)); + }, []); - const handleBaseItemClick = (item: BaseEditNav) => { + const handleBaseItemClick = useCallback((item: BaseEditNav) => { setSelectedBaseItem(item); - switch (item.id) { - case "light": - setCurrentStyle(MAP_STYLES.light); - break; - case "dark": - setCurrentStyle(MAP_STYLES.dark); - break; - case "car": - setCurrentStyle(MAP_STYLES.car); - break; + const styleKey = item.id as MapStyleKey; + if (styleKey in MAP_STYLES) { + setCurrentStyle(MAP_STYLES[styleKey]); } - }; + }, []); - const handleModalClose = () => { + const handleModalClose = useCallback(() => { setIsModalOpen(false); setSelectedItem(null); - }; + }, []); + + // Error UI component + const ErrorDisplay = mapError ? ( +
+ {mapError} +
+ ) : null; return (
- {mapError && ( -
- {mapError} -
- )} + {ErrorDisplay} +
+ + +