importer feature

This commit is contained in:
2026-05-10 21:23:56 +02:00
parent a47e3ae4e6
commit f2f1317660
77 changed files with 4884 additions and 1810 deletions

View File

@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS import_sessions (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
parsed_data TEXT NOT NULL,
field_mappings TEXT,
row_results TEXT,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS import_profiles (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
field_mappings TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_import_sessions_user_id ON import_sessions (user_id);
CREATE INDEX IF NOT EXISTS idx_import_sessions_expires_at ON import_sessions (expires_at);
CREATE INDEX IF NOT EXISTS idx_import_profiles_user_id ON import_profiles (user_id);

View File

@@ -0,0 +1,106 @@
use async_trait::async_trait;
use chrono::NaiveDateTime;
use domain::{
errors::DomainError,
models::ImportProfile,
ports::ImportProfileRepository,
value_objects::{ImportProfileId, UserId},
};
use sqlx::SqlitePool;
pub struct SqliteImportProfileRepository {
pool: SqlitePool,
}
impl SqliteImportProfileRepository {
pub fn new(pool: SqlitePool) -> Self { Self { pool } }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("DB error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
fn parse_dt(s: &str) -> Result<NaiveDateTime, DomainError> {
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S"))
.map_err(|e| DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e)))
}
}
#[async_trait]
impl ImportProfileRepository for SqliteImportProfileRepository {
async fn save(&self, p: &ImportProfile) -> Result<(), DomainError> {
let id = p.id.value().to_string();
let user_id = p.user_id.value().to_string();
let created_at = p.created_at.format("%Y-%m-%d %H:%M:%S").to_string();
sqlx::query!(
"INSERT OR REPLACE INTO import_profiles (id, user_id, name, field_mappings, created_at)
VALUES (?, ?, ?, ?, ?)",
id, user_id, p.name, p.field_mappings, created_at
)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
}
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ImportProfile>, DomainError> {
let uid = user_id.value().to_string();
let rows = sqlx::query!(
"SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE user_id = ? ORDER BY created_at DESC",
uid
)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
rows.into_iter().map(|r| -> Result<ImportProfile, DomainError> {
Ok(ImportProfile {
id: ImportProfileId::from_uuid(
r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
),
user_id: UserId::from_uuid(
r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
),
name: r.name,
field_mappings: r.field_mappings,
created_at: Self::parse_dt(&r.created_at)?,
})
}).collect()
}
async fn get(&self, id: &ImportProfileId, user_id: &UserId) -> Result<Option<ImportProfile>, DomainError> {
let id_str = id.value().to_string();
let uid_str = user_id.value().to_string();
let row = sqlx::query!(
"SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE id = ? AND user_id = ?",
id_str, uid_str
)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(row.map(|r| -> Result<ImportProfile, DomainError> {
Ok(ImportProfile {
id: ImportProfileId::from_uuid(
r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
),
user_id: UserId::from_uuid(
r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
),
name: r.name,
field_mappings: r.field_mappings,
created_at: Self::parse_dt(&r.created_at)?,
})
}).transpose()?)
}
async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> {
let id_str = id.value().to_string();
sqlx::query!("DELETE FROM import_profiles WHERE id = ?", id_str)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
}
}

View File

@@ -0,0 +1,114 @@
use async_trait::async_trait;
use chrono::NaiveDateTime;
use domain::{
errors::DomainError,
models::ImportSession,
ports::ImportSessionRepository,
value_objects::{ImportSessionId, UserId},
};
use sqlx::SqlitePool;
pub struct SqliteImportSessionRepository {
pool: SqlitePool,
}
impl SqliteImportSessionRepository {
pub fn new(pool: SqlitePool) -> Self { Self { pool } }
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("DB error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
fn parse_dt(s: &str) -> Result<NaiveDateTime, DomainError> {
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S"))
.map_err(|e| DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e)))
}
}
#[async_trait]
impl ImportSessionRepository for SqliteImportSessionRepository {
async fn create(&self, s: &ImportSession) -> Result<(), DomainError> {
let id = s.id.value().to_string();
let user_id = s.user_id.value().to_string();
let created_at = s.created_at.format("%Y-%m-%d %H:%M:%S").to_string();
let expires_at = s.expires_at.format("%Y-%m-%d %H:%M:%S").to_string();
sqlx::query!(
"INSERT INTO import_sessions (id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)",
id, user_id, s.parsed_data, s.field_mappings, s.row_results, created_at, expires_at
)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
}
async fn get(&self, id: &ImportSessionId, user_id: &UserId) -> Result<Option<ImportSession>, DomainError> {
let id_str = id.value().to_string();
let uid_str = user_id.value().to_string();
let row = sqlx::query!(
"SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at
FROM import_sessions WHERE id = ? AND user_id = ?",
id_str, uid_str
)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(row.map(|r| -> Result<ImportSession, DomainError> {
Ok(ImportSession {
id: ImportSessionId::from_uuid(
r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
),
user_id: UserId::from_uuid(
r.user_id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
),
parsed_data: r.parsed_data,
field_mappings: r.field_mappings,
row_results: r.row_results,
created_at: Self::parse_dt(&r.created_at)?,
expires_at: Self::parse_dt(&r.expires_at)?,
})
}).transpose()?)
}
async fn update(&self, s: &ImportSession) -> Result<(), DomainError> {
let id = s.id.value().to_string();
sqlx::query!(
"UPDATE import_sessions SET field_mappings = ?, row_results = ? WHERE id = ?",
s.field_mappings, s.row_results, id
)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
}
async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> {
let id_str = id.value().to_string();
sqlx::query!("DELETE FROM import_sessions WHERE id = ?", id_str)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
}
async fn delete_expired(&self) -> Result<u64, DomainError> {
let result = sqlx::query!("DELETE FROM import_sessions WHERE expires_at < datetime('now')")
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(result.rows_affected())
}
async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
let uid = user_id.value().to_string();
sqlx::query!("DELETE FROM import_sessions WHERE user_id = ? AND expires_at < datetime('now')", uid)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
}
}

View File

@@ -12,6 +12,8 @@ use domain::{
};
use sqlx::SqlitePool;
mod import_profile;
mod import_session;
mod migrations;
mod models;
mod users;
@@ -21,6 +23,8 @@ use models::{
datetime_to_str,
};
pub use import_profile::SqliteImportProfileRepository;
pub use import_session::SqliteImportSessionRepository;
pub use users::SqliteUserRepository;
fn format_year_month(ym: &str) -> String {
@@ -766,6 +770,8 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
std::sync::Arc<dyn domain::ports::DiaryRepository>,
std::sync::Arc<dyn domain::ports::StatsRepository>,
std::sync::Arc<dyn domain::ports::UserRepository>,
std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
std::sync::Arc<dyn domain::ports::ImportProfileRepository>,
)> {
use std::str::FromStr;
use anyhow::Context;
@@ -786,6 +792,9 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
.map_err(|e| anyhow::anyhow!("{e}"))
.context("Database migration failed")?;
let import_session_repo = std::sync::Arc::new(SqliteImportSessionRepository::new(pool.clone()));
let import_profile_repo = std::sync::Arc::new(SqliteImportProfileRepository::new(pool.clone()));
Ok((
pool.clone(),
std::sync::Arc::clone(&repo) as _,
@@ -793,6 +802,8 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
std::sync::Arc::clone(&repo) as _,
std::sync::Arc::clone(&repo) as _,
std::sync::Arc::new(SqliteUserRepository::new(pool)) as _,
import_session_repo as _,
import_profile_repo as _,
))
}