From 5dc90724d3938713ae7f418c7225562a0e23ce32 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 11 Jun 2026 12:58:08 +0200 Subject: [PATCH] feat: JSON import + mapping presets in SPA - Accept .json files in import upload, send format to backend - Backend endpoint PUT /import/sessions/{id}/profile/{profile_id} - Load saved presets on mapping step, auto-apply and skip to preview - Save current mapping as preset on confirm step - Delete presets from mapping step --- crates/presentation/src/handlers/import.rs | 98 +++++++++++++++++++++- crates/presentation/src/routes.rs | 4 + spa/src/hooks/use-imports.ts | 8 ++ spa/src/lib/api/client.ts | 14 ++++ spa/src/lib/api/imports.ts | 20 ++++- spa/src/locales/en.json | 14 +++- spa/src/routes/_app/settings/import.tsx | 83 +++++++++++++++++- 7 files changed, 230 insertions(+), 11 deletions(-) diff --git a/crates/presentation/src/handlers/import.rs b/crates/presentation/src/handlers/import.rs index 48c0574..f7365c6 100644 --- a/crates/presentation/src/handlers/import.rs +++ b/crates/presentation/src/handlers/import.rs @@ -14,9 +14,10 @@ use std::collections::HashMap; use crate::render::render_page; use application::import::{ apply_mapping as apply_import_mapping, + apply_profile as apply_import_profile, commands::{ - ApplyImportMappingCommand, CreateImportSessionCommand, DeleteImportProfileCommand, - ExecuteImportCommand, SaveImportProfileCommand, + ApplyImportMappingCommand, ApplyImportProfileCommand, CreateImportSessionCommand, + DeleteImportProfileCommand, ExecuteImportCommand, SaveImportProfileCommand, }, create_session as create_import_session, delete_profile as delete_import_profile, execute as execute_import, list_profiles as list_import_profiles, @@ -859,3 +860,96 @@ pub async fn api_delete_profile( } } } + +#[utoipa::path( + put, path = "/api/v1/import/sessions/{id}/profile/{profile_id}", + params( + ("id" = String, Path, description = "Import session UUID"), + ("profile_id" = String, Path, description = "Import profile UUID"), + ), + responses( + (status = 200, description = "Profile applied and mapping regenerated", body = inline(serde_json::Value)), + (status = 400, description = "Invalid ID"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Session or profile not found"), + (status = 422, description = "Mapping error"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_apply_profile( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path((session_id_str, profile_id_str)): Path<(String, String)>, +) -> impl IntoResponse { + let Ok(session_id) = session_id_str.parse::() else { + return ( + StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({"error": "invalid session id"})), + ) + .into_response(); + }; + let Ok(profile_id) = profile_id_str.parse::() else { + return ( + StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({"error": "invalid profile id"})), + ) + .into_response(); + }; + + if let Err(e) = apply_import_profile::execute( + &state.app_ctx, + ApplyImportProfileCommand { + user_id: user_id.value(), + session_id, + profile_id, + }, + ) + .await + { + let status = if matches!(e, domain::errors::DomainError::NotFound(_)) { + StatusCode::NOT_FOUND + } else { + StatusCode::UNPROCESSABLE_ENTITY + }; + return (status, axum::Json(serde_json::json!({"error": e.to_string()}))).into_response(); + } + + let session = match state + .app_ctx + .repos + .import_session + .get( + &ImportSessionId::from_uuid(session_id), + &user_id, + ) + .await + { + Ok(Some(s)) => s, + _ => { + return ( + StatusCode::NOT_FOUND, + axum::Json(serde_json::json!({"error": "session not found after profile apply"})), + ) + .into_response(); + } + }; + + let mappings = session.field_mappings.unwrap_or_default(); + match apply_import_mapping::execute( + &state.app_ctx, + ApplyImportMappingCommand { + user_id: user_id.value(), + session_id, + mappings, + }, + ) + .await + { + Ok(rows) => axum::Json(serde_json::json!({"row_count": rows.len()})).into_response(), + Err(e) => ( + StatusCode::UNPROCESSABLE_ENTITY, + axum::Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(), + } +} diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 5f27f63..15722f2 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -365,6 +365,10 @@ fn api_routes(rate_limit: u64) -> Router { "/import/profiles/{id}", routing::delete(handlers::import::api_delete_profile), ) + .route( + "/import/sessions/{id}/profile/{profile_id}", + routing::put(handlers::import::api_apply_profile), + ) .route( "/profile", routing::get(handlers::users::get_profile).put(handlers::users::update_profile_handler), diff --git a/spa/src/hooks/use-imports.ts b/spa/src/hooks/use-imports.ts index 26e6d0c..492a36c 100644 --- a/spa/src/hooks/use-imports.ts +++ b/spa/src/hooks/use-imports.ts @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { + applyImportProfile, applyMapping, confirmImport, createImportSession, @@ -93,3 +94,10 @@ export function useDeleteImportProfile() { }, }) } + +export function useApplyImportProfile() { + return useMutation({ + mutationFn: ({ sessionId, profileId }: { sessionId: string; profileId: string }) => + applyImportProfile(sessionId, profileId), + }) +} diff --git a/spa/src/lib/api/client.ts b/spa/src/lib/api/client.ts index a4ea48f..7df6e45 100644 --- a/spa/src/lib/api/client.ts +++ b/spa/src/lib/api/client.ts @@ -118,3 +118,17 @@ export async function upload( body: form, }) } + +export async function uploadWithFields( + path: string, + file: File, + fields: Record, +): Promise { + const form = new FormData() + form.append("file", file) + for (const [k, v] of Object.entries(fields)) form.append(k, v) + return request(buildUrl(path), { + method: "POST", + body: form, + }) +} diff --git a/spa/src/lib/api/imports.ts b/spa/src/lib/api/imports.ts index 1a79103..adef1aa 100644 --- a/spa/src/lib/api/imports.ts +++ b/spa/src/lib/api/imports.ts @@ -1,5 +1,5 @@ import { z } from "zod" -import { del, get, post, put, upload } from "./client" +import { del, get, post, put, uploadWithFields } from "./client" export const sessionCreatedResponseSchema = z.object({ session_id: z.string(), @@ -41,7 +41,9 @@ export const saveProfileRequestSchema = z.object({ export type SaveProfileRequest = z.infer export function createImportSession(file: File) { - return upload("/import/sessions", file) + const ext = file.name.split(".").pop()?.toLowerCase() + const format = ext === "json" ? "json" : "csv" + return uploadWithFields("/import/sessions", file, { format }) } export function getImportSession(id: string) { @@ -76,14 +78,24 @@ export function confirmImport(sessionId: string, data: ConfirmRequest) { return post(`/import/sessions/${sessionId}/confirm`, data) } +export type ImportProfile = { + id: string + name: string + created_at: string +} + export function getImportProfiles() { - return get("/import/profiles") + return get("/import/profiles") } export function saveImportProfile(data: SaveProfileRequest) { - return post("/import/profiles", data) + return post<{ id: string }>("/import/profiles", data) } export function deleteImportProfile(id: string) { return del(`/import/profiles/${id}`) } + +export function applyImportProfile(sessionId: string, profileId: string) { + return put<{ row_count: number }>(`/import/sessions/${sessionId}/profile/${profileId}`) +} diff --git a/spa/src/locales/en.json b/spa/src/locales/en.json index a0cdf5a..1dbbeeb 100644 --- a/spa/src/locales/en.json +++ b/spa/src/locales/en.json @@ -144,7 +144,7 @@ "editProfile": "Edit Profile", "editProfileDesc": "Username, bio", "import": "Import", - "importDesc": "Import from CSV", + "importDesc": "Import from CSV or JSON", "yearWrapUp": "Year Wrap-Up", "yearWrapUpDesc": "Annual summaries", "webhookTokens": "Webhook Tokens", @@ -337,7 +337,7 @@ "scale1to10": "1–10 → 1–5 (×0.5)", "scale1to100": "1–100 → 1–5 (×0.05)", "scaleLetterboxd": "0–4 Letterboxd → 1–5 (×1.25)", - "dropCsv": "Drop a CSV file or tap to browse", + "dropCsv": "Drop a CSV or JSON file, or tap to browse", "uploading": "Uploading...", "preview": "Preview", "rowsCols": "{{rows}} rows · {{cols}} columns", @@ -357,6 +357,14 @@ "importing": "Importing...", "importRows": "Import {{count}} rows", "importComplete": "Import complete!", - "viewDiary": "View your diary" + "viewDiary": "View your diary", + "presets": "Presets", + "loadPreset": "Load preset", + "savePreset": "Save as preset", + "presetName": "Preset name", + "presetNamePlaceholder": "e.g. Letterboxd", + "presetSaved": "Preset saved", + "presetDeleted": "Preset deleted", + "noPresets": "No saved presets" } } diff --git a/spa/src/routes/_app/settings/import.tsx b/spa/src/routes/_app/settings/import.tsx index b5eb5d1..65e1df2 100644 --- a/spa/src/routes/_app/settings/import.tsx +++ b/spa/src/routes/_app/settings/import.tsx @@ -1,7 +1,8 @@ import { createFileRoute, Link } from "@tanstack/react-router" import { useRef, useState } from "react" import { useTranslation } from "react-i18next" -import { ArrowLeft, CheckCircle, Upload } from "lucide-react" +import { ArrowLeft, CheckCircle, Trash2, Upload } from "lucide-react" +import { toast } from "sonner" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" @@ -25,8 +26,12 @@ import { Skeleton } from "@/components/ui/skeleton" import { useCreateImportSession, useApplyMapping, + useApplyImportProfile, useConfirmImport, useImportPreview, + useImportProfiles, + useSaveImportProfile, + useDeleteImportProfile, } from "@/hooks/use-imports" import { useDocumentTitle } from "@/hooks/use-document-title" import type { SessionCreatedResponse } from "@/lib/api/imports" @@ -65,7 +70,10 @@ function ImportPage() { const createSession = useCreateImportSession() const applyMapping = useApplyMapping() + const applyProfile = useApplyImportProfile() const confirmImport = useConfirmImport() + const { data: profiles } = useImportProfiles() + const deleteProfile = useDeleteImportProfile() const handleFile = (file: File) => { createSession.mutate(file, { @@ -115,6 +123,14 @@ function ImportPage() { ) } + const handleApplyProfile = (profileId: string) => { + if (!session) return + applyProfile.mutate( + { sessionId: session.session_id, profileId }, + { onSuccess: () => setStep(2) }, + ) + } + const hasRatingMapping = Object.values(mappings).includes("rating") return ( @@ -162,7 +178,7 @@ function ImportPage() { { const file = e.target.files?.[0] @@ -214,6 +230,40 @@ function ImportPage() { + {/* Presets */} + {profiles && profiles.length > 0 && ( + + + {t("import.presets")} + + + {profiles.map((p) => ( +
+ + +
+ ))} +
+
+ )} + {/* Column mapping */} @@ -332,6 +382,8 @@ function ConfirmStep({ }) { const { t } = useTranslation() const { data, isPending: previewLoading } = useImportPreview(sessionId) + const saveProfile = useSaveImportProfile() + const [presetName, setPresetName] = useState("") if (previewLoading) return @@ -387,6 +439,33 @@ function ConfirmStep({ + + + {t("import.savePreset")} + + + setPresetName(e.target.value)} + placeholder={t("import.presetNamePlaceholder")} + className="flex-1" + /> + + + +