Get file upload working

Even though the UI doesn't do anything with it yet.
This commit is contained in:
Eli Ribble 2026-03-27 08:39:38 -07:00
parent 0d1bd752a4
commit 747544bb58
No known key found for this signature in database
8 changed files with 354 additions and 77 deletions

View file

@ -11,6 +11,7 @@ import (
"github.com/Gleipnir-Technology/nidus-sync/html"
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
"github.com/Gleipnir-Technology/nidus-sync/platform"
"github.com/Gleipnir-Technology/nidus-sync/platform/file"
"github.com/gorilla/schema"
"github.com/rs/zerolog/log"
)
@ -124,23 +125,28 @@ func handlerJSONPost[ReqType any](f handlerFunctionPost[ReqType]) http.HandlerFu
}
}
func authenticatedHandlerPostMultipart[RequestType any](f handlerFunctionPostAuthenticated[RequestType]) http.Handler {
func authenticatedHandlerPostMultipart(f handlerFunctionPostAuthenticated[[]file.Upload], collection file.Collection) http.Handler {
return auth.NewEnsureAuth(func(w http.ResponseWriter, r *http.Request, u platform.User) {
err := r.ParseMultipartForm(32 << 10) // 32 MB buffer
if err != nil {
respondError(w, http.StatusBadRequest, "Failed to parse form: %w ", err)
return
}
uploads, err := file.SaveFileUploads(r, collection)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to save uploads: %w", err)
return
}
var content RequestType
/*
err = decoder.Decode(&content, r.PostForm)
if err != nil {
respondError(w, http.StatusBadRequest, "Failed to decode form: %w", err)
return
}
*/
ctx := r.Context()
path, e := f(ctx, r, u, content)
path, e := f(ctx, r, u, uploads)
if e != nil {
http.Error(w, e.Error(), e.Status)
return

View file

@ -5,6 +5,7 @@ import (
"github.com/go-chi/render"
"github.com/Gleipnir-Technology/nidus-sync/auth"
"github.com/Gleipnir-Technology/nidus-sync/platform/file"
)
func AddRoutes(r chi.Router) {
@ -18,10 +19,6 @@ func AddRoutes(r chi.Router) {
r.Method("GET", "/client/ios", auth.NewEnsureAuth(handleClientIos))
r.Method("GET", "/communication", authenticatedHandlerJSON(listCommunication))
r.Method("POST", "/configuration/integration/arcgis", authenticatedHandlerJSONPost(postConfigurationIntegrationArcgis))
r.Method("POST", "/configuration/upload/pool/flyover", authenticatedHandlerPostMultipart(postUploadPoolFlyoverCreate))
r.Method("POST", "/configuration/upload/pool/custom", authenticatedHandlerPostMultipart(postUploadPoolCustomCreate))
r.Method("POST", "/configuration/upload/{id}/commit", authenticatedHandlerJSONPost(postUploadCommit))
r.Method("POST", "/configuration/upload/{id}/discard", authenticatedHandlerJSONPost(postUploadDiscard))
r.Method("GET", "/events", auth.NewEnsureAuth(streamEvents))
r.Method("POST", "/image/{uuid}", auth.NewEnsureAuth(apiImagePost))
r.Method("GET", "/image/{uuid}/content", auth.NewEnsureAuth(apiImageContentGet))
@ -41,7 +38,11 @@ func AddRoutes(r chi.Router) {
r.Method("POST", "/sudo/sse", authenticatedHandlerJSONPost(postSudoSSE))
r.Method("GET", "/trap-data", auth.NewEnsureAuth(apiTrapData))
r.Method("GET", "/tile/{z}/{y}/{x}", auth.NewEnsureAuth(getTile))
r.Method("POST", "/upload/pool/flyover", authenticatedHandlerPostMultipart(postUploadPoolFlyoverCreate, file.CollectionCSV))
r.Method("POST", "/upload/pool/custom", authenticatedHandlerPostMultipart(postUploadPoolCustomCreate, file.CollectionCSV))
r.Method("GET", "/upload/{id}", authenticatedHandlerJSON(getUploadByID))
r.Method("POST", "/upload/{id}/commit", authenticatedHandlerJSONPost(postUploadCommit))
r.Method("POST", "/upload/{id}/discard", authenticatedHandlerJSONPost(postUploadDiscard))
r.Method("GET", "/user/self", authenticatedHandlerJSON(getUserSelf))
r.Method("GET", "/user/suggestion", authenticatedHandlerJSON(listUserSuggestion))
r.Method("GET", "/user", authenticatedHandlerJSON(listUser))

View file

@ -99,17 +99,11 @@ func postUploadDiscard(ctx context.Context, r *http.Request, u platform.User, f
return "/configuration/upload", nil
}
type FormUploadPool struct{}
func postUploadPoolFlyoverCreate(ctx context.Context, r *http.Request, u platform.User, f FormUploadPool) (string, *nhttp.ErrorWithStatus) {
func postUploadPoolFlyoverCreate(ctx context.Context, r *http.Request, u platform.User, uploads []file.Upload) (string, *nhttp.ErrorWithStatus) {
// If the organization we're uploading to doesn't have a service area, we can't process the upload correctly
if !(u.Organization.HasServiceArea() || u.Organization.IsCatchall()) {
return "", nhttp.NewErrorStatus(http.StatusConflict, "Your organization does not yet have a service area")
}
uploads, err := file.SaveFileUpload(r, "csvfile", file.CollectionCSV)
if err != nil {
return "", nhttp.NewError("Failed to extract image uploads: %s", err)
}
if len(uploads) == 0 {
return "", nhttp.NewErrorStatus(http.StatusBadRequest, "No upload found")
}
@ -123,11 +117,7 @@ func postUploadPoolFlyoverCreate(ctx context.Context, r *http.Request, u platfor
}
return fmt.Sprintf("/configuration/upload/%d", saved_upload.ID), nil
}
func postUploadPoolCustomCreate(ctx context.Context, r *http.Request, u platform.User, f FormUploadPool) (string, *nhttp.ErrorWithStatus) {
uploads, err := file.SaveFileUpload(r, "csvfile", file.CollectionCSV)
if err != nil {
return "", nhttp.NewError("Failed to extract image uploads: %s", err)
}
func postUploadPoolCustomCreate(ctx context.Context, r *http.Request, u platform.User, uploads []file.Upload) (string, *nhttp.ErrorWithStatus) {
if len(uploads) == 0 {
return "", nhttp.NewErrorStatus(http.StatusBadRequest, "No upload found")
}

View file

@ -11,20 +11,17 @@ import (
"github.com/rs/zerolog/log"
)
type FileUpload struct {
type Upload struct {
ContentType string
Name string
SizeBytes int
UUID uuid.UUID
}
func SaveFileUpload(r *http.Request, name string, collection Collection) ([]FileUpload, error) {
results := make([]FileUpload, 0)
func SaveFileUploads(r *http.Request, collection Collection) ([]Upload, error) {
results := make([]Upload, 0)
for n, fheaders := range r.MultipartForm.File {
log.Debug().Str("n", n).Msg("looking at header")
if n != name {
continue
}
for _, headers := range fheaders {
f, err := saveFileUpload(headers, collection)
if err != nil {
@ -35,8 +32,8 @@ func SaveFileUpload(r *http.Request, name string, collection Collection) ([]File
}
return results, nil
}
func saveFileUploads(r *http.Request, collection Collection) ([]FileUpload, error) {
results := make([]FileUpload, 0)
func saveFileUploads(r *http.Request, collection Collection) ([]Upload, error) {
results := make([]Upload, 0)
for name, fheaders := range r.MultipartForm.File {
for _, headers := range fheaders {
upload, err := saveFileUpload(headers, collection)
@ -48,7 +45,7 @@ func saveFileUploads(r *http.Request, collection Collection) ([]FileUpload, erro
}
return results, nil
}
func saveFileUpload(headers *multipart.FileHeader, collection Collection) (upload FileUpload, err error) {
func saveFileUpload(headers *multipart.FileHeader, collection Collection) (upload Upload, err error) {
file, err := headers.Open()
if err != nil {
return upload, fmt.Errorf("Failed to open header: %w", err)
@ -67,7 +64,7 @@ func saveFileUpload(headers *multipart.FileHeader, collection Collection) (uploa
return upload, fmt.Errorf("Failed to write file to disk: %w", err)
}
log.Info().Int("size", len(file_bytes)).Str("uploaded_filename", headers.Filename).Str("content-type", content_type).Str("uuid", u.String()).Msg("Saved an uploaded file to disk")
return FileUpload{
return Upload{
ContentType: content_type,
Name: headers.Filename,
SizeBytes: len(file_bytes),

View file

@ -81,7 +81,7 @@ func GetUploadDetail(ctx context.Context, organization_id int32, file_id int32)
return nil, errors.New("No idea what to do with upload type")
}
func NewUpload(ctx context.Context, u User, upload file.FileUpload, t enums.FileuploadCsvtype) (*Upload, error) {
func NewUpload(ctx context.Context, u User, upload file.Upload, t enums.FileuploadCsvtype) (*Upload, error) {
txn, err := db.PGInstance.BobDB.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("Failed to begin transaction: %w", err)

304
ts/components/CSVUpload.vue Normal file
View file

@ -0,0 +1,304 @@
<style scoped>
.upload-widget {
max-width: 400px;
font-family: system-ui, -apple-system, sans-serif;
}
.upload-area {
position: relative;
border: 2px dashed #cbd5e0;
border-radius: 8px;
padding: 2rem;
text-align: center;
background-color: #f7fafc;
transition: all 0.3s ease;
cursor: pointer;
}
.upload-area:hover {
border-color: #4299e1;
background-color: #ebf8ff;
}
.file-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.file-input:disabled {
cursor: not-allowed;
}
.upload-info {
pointer-events: none;
}
.placeholder {
color: #718096;
}
.placeholder svg {
margin: 0 auto 1rem;
color: #a0aec0;
}
.placeholder p {
margin: 0;
font-size: 0.875rem;
}
.file-selected {
color: #2d3748;
}
.file-name {
margin: 0 0 0.5rem;
font-weight: 600;
word-break: break-all;
}
.file-size {
margin: 0;
font-size: 0.875rem;
color: #718096;
}
.upload-button {
width: 100%;
margin-top: 1rem;
padding: 0.75rem 1.5rem;
background-color: #4299e1;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.upload-button:hover {
background-color: #3182ce;
}
.upload-button:disabled {
background-color: #cbd5e0;
cursor: not-allowed;
}
.upload-status {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-top: 1rem;
padding: 0.75rem;
background-color: #ebf8ff;
border-radius: 6px;
color: #2c5282;
}
.spinner {
width: 20px;
height: 20px;
border: 3px solid #bee3f8;
border-top-color: #4299e1;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.success-message {
margin-top: 1rem;
padding: 0.75rem;
background-color: #c6f6d5;
color: #22543d;
border-radius: 6px;
font-weight: 500;
}
.error-message {
margin-top: 1rem;
padding: 0.75rem;
background-color: #fed7d7;
color: #742a2a;
border-radius: 6px;
font-weight: 500;
}
</style>
<template>
<div class="upload-widget">
<div class="upload-area">
<input
ref="fileInput"
type="file"
accept=".csv"
@change="handleFileSelect"
:disabled="isUploading"
class="file-input"
/>
<div class="upload-info">
<div v-if="!selectedFile" class="placeholder">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<p>Select a CSV file to upload</p>
</div>
<div v-else class="file-selected">
<p class="file-name">{{ selectedFile.name }}</p>
<p class="file-size">{{ formatFileSize(selectedFile.size) }}</p>
</div>
</div>
</div>
<button
v-if="selectedFile && !isUploading"
@click="uploadFile"
class="upload-button"
:disabled="isUploading"
>
Upload File
</button>
<div v-if="isUploading" class="upload-status">
<div class="spinner"></div>
<p>Uploading...</p>
</div>
<div v-if="uploadSuccess" class="success-message">
File uploaded successfully!
</div>
<div v-if="errorMessage" class="error-message">
{{ errorMessage }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
interface Props {
uploadUrl: string;
headers?: Record<string, string>;
additionalData?: Record<string, string>;
}
interface Emits {
(e: 'doError', error: Error): void;
(e: 'doFileSelected', file: File): void;
(e: 'doSuccess', response: any): void;
}
const props = withDefaults(defineProps<Props>(), {
headers: () => ({}),
additionalData: () => ({})
});
const emit = defineEmits<Emits>();
const fileInput = ref<HTMLInputElement | null>(null);
const selectedFile = ref<File | null>(null);
const isUploading = ref(false);
const uploadSuccess = ref(false);
const errorMessage = ref('');
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
// Validate it's a CSV file
if (!file.name.toLowerCase().endsWith('.csv')) {
errorMessage.value = 'Please select a valid CSV file';
selectedFile.value = null;
return;
}
selectedFile.value = file;
errorMessage.value = '';
uploadSuccess.value = false;
emit('doFileSelected', file);
}
};
const uploadFile = async () => {
if (!selectedFile.value) return;
isUploading.value = true;
errorMessage.value = '';
uploadSuccess.value = false;
try {
const formData = new FormData();
formData.append('file', selectedFile.value);
// Add any additional data to the form
Object.entries(props.additionalData).forEach(([key, value]) => {
formData.append(key, value);
});
const response = await fetch(props.uploadUrl, {
method: 'POST',
headers: {
...props.headers,
// Don't set Content-Type - let browser set it with boundary
},
body: formData,
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.statusText}`);
}
const data = await response.json();
uploadSuccess.value = true;
emit('doSuccess', data);
// Reset after successful upload
setTimeout(() => {
resetUpload();
}, 2000);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : 'Upload failed';
emit('doError', error as Error);
} finally {
isUploading.value = false;
}
};
const resetUpload = () => {
selectedFile.value = null;
uploadSuccess.value = false;
errorMessage.value = '';
if (fileInput.value) {
fileInput.value.value = '';
}
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
};
// Expose methods for parent component
defineExpose({
resetUpload
});
</script>

View file

@ -85,12 +85,14 @@
<i class="bi bi-filetype-csv me-1"></i> CSV Format
</span>
</div>
<a
class="btn btn-success btn-lg w-100"
href="/configuration/upload/pool/custom"
<RouterLink
to="/configuration/upload/pool/custom"
>
<i class="bi bi-upload me-2"></i>Pick me
</a>
<button
class="btn btn-success btn-lg w-100">
<i class="bi bi-upload me-2"></i>Pick me
</button>
</RouterLink>
</div>
</div>
</div>

View file

@ -81,47 +81,11 @@
<h5 class="mb-0">Upload Data</h5>
</div>
<div class="card-body">
<form
action="/configuration/upload/pool/flyover"
method="POST"
enctype="multipart/form-data"
>
<div class="upload-area">
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
fill="currentColor"
class="bi bi-cloud-arrow-up text-primary mb-3"
viewBox="0 0 16 16"
>
<path
fill-rule="evenodd"
d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708l2-2z"
/>
<path
d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383zm.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z"
/>
</svg>
<h5>Select your CSV file</h5>
<p class="text-muted">
Drag and drop a file here or click to browse
</p>
<input
type="file"
class="form-control"
id="csvFile"
name="csvfile"
accept=".csv"
/>
</div>
<div class="d-grid gap-2 text-center">
<button class="btn btn-primary" type="submit">
Upload and Continue
</button>
</div>
</form>
<CSVUpload
upload-url="/api/upload/pool/flyover"
@doError="onError"
@doFileSelected="onFileSelected"
@doSuccess="onUploadSuccess" />
</div>
</div>
@ -133,3 +97,16 @@
</div>
</div>
</template>
<script setup lang="ts">
import CSVUpload from "@/components/CSVUpload.vue";
function onError(err) {
console.error("CSV upload error", err);
}
function onFileSelected(file) {
console.log("file selected", file);
}
function onUploadSuccess(resp) {
console.log("upload success", resp);
}
</script>