14
crates/adapters/auth/Cargo.toml
Normal file
14
crates/adapters/auth/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "auth"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
config.workspace = true
|
||||
async-trait.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
serde.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
argon2.workspace = true
|
||||
71
crates/adapters/auth/src/jwt_service.rs
Normal file
71
crates/adapters/auth/src/jwt_service.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
|
||||
|
||||
use config::AuthConfig;
|
||||
use domain::auth::GeneratedToken;
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct Claims {
|
||||
sub: String,
|
||||
exp: u64,
|
||||
}
|
||||
|
||||
pub struct JwtAuthService {
|
||||
secret: String,
|
||||
ttl_seconds: i64,
|
||||
}
|
||||
|
||||
impl JwtAuthService {
|
||||
pub fn new(config: &AuthConfig) -> Result<Self, DomainError> {
|
||||
let secret = config
|
||||
.jwt_secret
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| DomainError::InvalidInput("JWT secret must be configured".into()))?;
|
||||
|
||||
Ok(Self {
|
||||
secret,
|
||||
ttl_seconds: config.access_token_ttl_seconds as i64,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::AuthServicePort for JwtAuthService {
|
||||
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||
let expires_at = Utc::now() + Duration::seconds(self.ttl_seconds);
|
||||
|
||||
let claims = Claims {
|
||||
sub: user_id.value().to_string(),
|
||||
exp: expires_at.timestamp() as u64,
|
||||
};
|
||||
|
||||
let token = jsonwebtoken::encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(self.secret.as_bytes()),
|
||||
)
|
||||
.map_err(|e| DomainError::InvalidInput(format!("failed to generate token: {e}")))?;
|
||||
|
||||
Ok(GeneratedToken::new(token, expires_at))
|
||||
}
|
||||
|
||||
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
|
||||
let data = jsonwebtoken::decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(self.secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|_| DomainError::Unauthorized("invalid or expired token".into()))?;
|
||||
|
||||
let uuid: uuid::Uuid = data
|
||||
.claims
|
||||
.sub
|
||||
.parse()
|
||||
.map_err(|_| DomainError::Unauthorized("invalid token subject".into()))?;
|
||||
|
||||
Ok(UserId::from_uuid(uuid))
|
||||
}
|
||||
}
|
||||
5
crates/adapters/auth/src/lib.rs
Normal file
5
crates/adapters/auth/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod jwt_service;
|
||||
mod password_hasher;
|
||||
|
||||
pub use jwt_service::JwtAuthService;
|
||||
pub use password_hasher::Argon2PasswordHasher;
|
||||
33
crates/adapters/auth/src/password_hasher.rs
Normal file
33
crates/adapters/auth/src/password_hasher.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use argon2::password_hash::SaltString;
|
||||
use argon2::password_hash::rand_core::OsRng;
|
||||
use argon2::{Argon2, PasswordHasher, PasswordVerifier};
|
||||
|
||||
use domain::errors::DomainError;
|
||||
|
||||
pub struct Argon2PasswordHasher;
|
||||
|
||||
impl domain::ports::PasswordHasherPort for Argon2PasswordHasher {
|
||||
fn hash(&self, raw_password: &str) -> Result<domain::user::PasswordHash, DomainError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
|
||||
let hash = Argon2::default()
|
||||
.hash_password(raw_password.as_bytes(), &salt)
|
||||
.map_err(|e| DomainError::InvalidInput(format!("failed to hash password: {e}")))?
|
||||
.to_string();
|
||||
|
||||
Ok(domain::user::PasswordHash::new(hash))
|
||||
}
|
||||
|
||||
fn verify(
|
||||
&self,
|
||||
raw_password: &str,
|
||||
hash: &domain::user::PasswordHash,
|
||||
) -> Result<bool, DomainError> {
|
||||
let parsed = argon2::password_hash::PasswordHash::new(hash.value())
|
||||
.map_err(|e| DomainError::InvalidInput(format!("invalid password hash: {e}")))?;
|
||||
|
||||
Ok(Argon2::default()
|
||||
.verify_password(raw_password.as_bytes(), &parsed)
|
||||
.is_ok())
|
||||
}
|
||||
}
|
||||
10
crates/adapters/event-publisher/Cargo.toml
Normal file
10
crates/adapters/event-publisher/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "event-publisher"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
async-trait.workspace = true
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
tracing.workspace = true
|
||||
35
crates/adapters/event-publisher/src/channel.rs
Normal file
35
crates/adapters/event-publisher/src/channel.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::events::EventEnvelope;
|
||||
|
||||
const DEFAULT_CHANNEL_CAPACITY: usize = 256;
|
||||
|
||||
pub type EventReceiver = mpsc::Receiver<EventEnvelope>;
|
||||
|
||||
pub struct ChannelEventPublisher {
|
||||
sender: mpsc::Sender<EventEnvelope>,
|
||||
}
|
||||
|
||||
impl ChannelEventPublisher {
|
||||
fn new(sender: mpsc::Sender<EventEnvelope>) -> Self {
|
||||
Self { sender }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::EventPublisherPort for ChannelEventPublisher {
|
||||
async fn publish(&self, envelope: EventEnvelope) -> Result<(), DomainError> {
|
||||
self.sender
|
||||
.send(envelope)
|
||||
.await
|
||||
.map_err(|_| DomainError::InvalidInput("event channel closed".into()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_event_channel() -> (ChannelEventPublisher, EventReceiver) {
|
||||
let (sender, receiver) = mpsc::channel(DEFAULT_CHANNEL_CAPACITY);
|
||||
(ChannelEventPublisher::new(sender), receiver)
|
||||
}
|
||||
5
crates/adapters/event-publisher/src/lib.rs
Normal file
5
crates/adapters/event-publisher/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod channel;
|
||||
mod noop;
|
||||
|
||||
pub use channel::{ChannelEventPublisher, EventReceiver, create_event_channel};
|
||||
pub use noop::NoopEventPublisher;
|
||||
11
crates/adapters/event-publisher/src/noop.rs
Normal file
11
crates/adapters/event-publisher/src/noop.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::events::EventEnvelope;
|
||||
|
||||
pub struct NoopEventPublisher;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::EventPublisherPort for NoopEventPublisher {
|
||||
async fn publish(&self, _envelope: EventEnvelope) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
11
crates/adapters/exporter/Cargo.toml
Normal file
11
crates/adapters/exporter/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "exporter"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
async-trait.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
zip.workspace = true
|
||||
127
crates/adapters/exporter/src/json_export.rs
Normal file
127
crates/adapters/exporter/src/json_export.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
use zip::ZipWriter;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::UserExport;
|
||||
|
||||
pub struct JsonExportAdapter;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ExportPort for JsonExportAdapter {
|
||||
async fn export_user_data(&self, data: &UserExport) -> Result<Vec<u8>, DomainError> {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let mut zip = ZipWriter::new(buf);
|
||||
let options =
|
||||
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
let json = build_data_json(data)?;
|
||||
zip.start_file("data.json", options).map_err(zip_err)?;
|
||||
zip.write_all(&json).map_err(io_err)?;
|
||||
|
||||
for photo in &data.photos {
|
||||
zip.start_file(format!("photos/{}", photo.id), options)
|
||||
.map_err(zip_err)?;
|
||||
zip.write_all(&photo.data).map_err(io_err)?;
|
||||
}
|
||||
|
||||
for memo in &data.voice_memos {
|
||||
zip.start_file(format!("voice_memos/{}", memo.id), options)
|
||||
.map_err(zip_err)?;
|
||||
zip.write_all(&memo.data).map_err(io_err)?;
|
||||
}
|
||||
|
||||
let cursor = zip.finish().map_err(zip_err)?;
|
||||
Ok(cursor.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_data_json(data: &UserExport) -> Result<Vec<u8>, DomainError> {
|
||||
let export = ExportData {
|
||||
version: "1.0",
|
||||
entries: data.entries.iter().map(EntryExport::from).collect(),
|
||||
activities: data.activities.iter().map(ActivityExport::from).collect(),
|
||||
reminder_count: data.reminders.len(),
|
||||
};
|
||||
|
||||
serde_json::to_vec_pretty(&export)
|
||||
.map_err(|e| DomainError::InvalidInput(format!("json serialization failed: {e}")))
|
||||
}
|
||||
|
||||
fn zip_err(e: zip::result::ZipError) -> DomainError {
|
||||
DomainError::InvalidInput(format!("zip error: {e}"))
|
||||
}
|
||||
|
||||
fn io_err(e: std::io::Error) -> DomainError {
|
||||
DomainError::InvalidInput(format!("io error: {e}"))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ExportData<'a> {
|
||||
version: &'a str,
|
||||
entries: Vec<EntryExport>,
|
||||
activities: Vec<ActivityExport>,
|
||||
reminder_count: usize,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EntryExport {
|
||||
id: String,
|
||||
mood: u8,
|
||||
mood_label: String,
|
||||
logged_at: String,
|
||||
activities: Vec<String>,
|
||||
content: Option<String>,
|
||||
photos: Vec<String>,
|
||||
voice_memos: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<&domain::entry::MoodEntry> for EntryExport {
|
||||
fn from(entry: &domain::entry::MoodEntry) -> Self {
|
||||
Self {
|
||||
id: entry.id().value().to_string(),
|
||||
mood: entry.mood().value(),
|
||||
mood_label: format!("{:?}", entry.mood()),
|
||||
logged_at: entry.logged_at().to_rfc3339(),
|
||||
activities: entry
|
||||
.activities()
|
||||
.iter()
|
||||
.map(|a| a.value().to_string())
|
||||
.collect(),
|
||||
content: entry.content().map(|c| c.value().to_string()),
|
||||
photos: entry
|
||||
.photos()
|
||||
.iter()
|
||||
.map(|p| p.value().to_string())
|
||||
.collect(),
|
||||
voice_memos: entry
|
||||
.voice_memos()
|
||||
.iter()
|
||||
.map(|v| v.value().to_string())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ActivityExport {
|
||||
id: String,
|
||||
name: String,
|
||||
category: Option<String>,
|
||||
archived: bool,
|
||||
}
|
||||
|
||||
impl From<&domain::activity::Activity> for ActivityExport {
|
||||
fn from(activity: &domain::activity::Activity) -> Self {
|
||||
Self {
|
||||
id: activity.id().value().to_string(),
|
||||
name: activity.name().value().to_string(),
|
||||
category: activity.category().map(|c| c.value().to_string()),
|
||||
archived: activity.is_archived(),
|
||||
}
|
||||
}
|
||||
}
|
||||
3
crates/adapters/exporter/src/lib.rs
Normal file
3
crates/adapters/exporter/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod json_export;
|
||||
|
||||
pub use json_export::JsonExportAdapter;
|
||||
20
crates/adapters/http-axum/Cargo.toml
Normal file
20
crates/adapters/http-axum/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "http-axum"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
application.workspace = true
|
||||
api-types.workspace = true
|
||||
config.workspace = true
|
||||
web-push-adapter.workspace = true
|
||||
axum.workspace = true
|
||||
tower-http.workspace = true
|
||||
async-trait.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
utoipa.workspace = true
|
||||
utoipa-scalar.workspace = true
|
||||
tracing.workspace = true
|
||||
56
crates/adapters/http-axum/src/errors.rs
Normal file
56
crates/adapters/http-axum/src/errors.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
use api_types::errors::ApiValidationError;
|
||||
use application::errors::ApplicationError;
|
||||
use domain::errors::DomainError;
|
||||
|
||||
pub struct ApiError(pub ApplicationError);
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, code, message) = match &self.0 {
|
||||
ApplicationError::Domain(domain_err) => domain_error_response(domain_err),
|
||||
ApplicationError::Validation(msg) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"VALIDATION_ERROR",
|
||||
msg.clone(),
|
||||
),
|
||||
};
|
||||
|
||||
let body = serde_json::json!({ "error": { "code": code, "message": message } });
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApplicationError> for ApiError {
|
||||
fn from(err: ApplicationError) -> Self {
|
||||
Self(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApiValidationError> for ApiError {
|
||||
fn from(err: ApiValidationError) -> Self {
|
||||
match err {
|
||||
ApiValidationError::Domain(e) => Self(ApplicationError::Domain(e)),
|
||||
ApiValidationError::Invalid(msg) => Self(ApplicationError::Validation(msg)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DomainError> for ApiError {
|
||||
fn from(err: DomainError) -> Self {
|
||||
Self(ApplicationError::Domain(err))
|
||||
}
|
||||
}
|
||||
|
||||
fn domain_error_response(err: &DomainError) -> (StatusCode, &'static str, String) {
|
||||
match err {
|
||||
DomainError::NotFound(msg) => (StatusCode::NOT_FOUND, "NOT_FOUND", msg.clone()),
|
||||
DomainError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, "INVALID_INPUT", msg.clone()),
|
||||
DomainError::Conflict(msg) => (StatusCode::CONFLICT, "CONFLICT", msg.clone()),
|
||||
DomainError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED", msg.clone()),
|
||||
DomainError::Forbidden(msg) => (StatusCode::FORBIDDEN, "FORBIDDEN", msg.clone()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use axum::Json;
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::request::Parts;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
use domain::user::UserId;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub struct AuthenticatedUser(pub UserId);
|
||||
|
||||
impl IntoResponse for AuthRejection {
|
||||
fn into_response(self) -> Response {
|
||||
let body = serde_json::json!({ "error": self.0 });
|
||||
(StatusCode::UNAUTHORIZED, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AuthRejection(String);
|
||||
|
||||
impl<S> FromRequestParts<S> for AuthenticatedUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
AppState: FromRef<S>,
|
||||
{
|
||||
type Rejection = AuthRejection;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let app_state = AppState::from_ref(state);
|
||||
|
||||
let token = extract_bearer_token(parts)
|
||||
.ok_or_else(|| AuthRejection("missing or invalid authorization header".into()))?;
|
||||
|
||||
let user_id = app_state
|
||||
.auth_service
|
||||
.validate_token(&token)
|
||||
.await
|
||||
.map_err(|_| AuthRejection("invalid or expired token".into()))?;
|
||||
|
||||
Ok(AuthenticatedUser(user_id))
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_bearer_token(parts: &Parts) -> Option<String> {
|
||||
let header = parts.headers.get("authorization")?.to_str().ok()?;
|
||||
let token = header.strip_prefix("Bearer ")?;
|
||||
Some(token.to_string())
|
||||
}
|
||||
|
||||
use axum::extract::FromRef;
|
||||
7
crates/adapters/http-axum/src/extractors/mod.rs
Normal file
7
crates/adapters/http-axum/src/extractors/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod authenticated_user;
|
||||
mod multipart;
|
||||
mod path_id;
|
||||
|
||||
pub use authenticated_user::AuthenticatedUser;
|
||||
pub use multipart::{extract_file_bytes, extract_media_upload};
|
||||
pub use path_id::PathId;
|
||||
66
crates/adapters/http-axum/src/extractors/multipart.rs
Normal file
66
crates/adapters/http-axum/src/extractors/multipart.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use axum::extract::Multipart;
|
||||
|
||||
use domain::attachment::{ContentType, MediaUpload};
|
||||
|
||||
use crate::errors::ApiError;
|
||||
|
||||
pub async fn extract_media_upload(mut multipart: Multipart) -> Result<MediaUpload, ApiError> {
|
||||
let mut file_data: Option<Vec<u8>> = None;
|
||||
let mut content_type_str: Option<String> = None;
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| validation_error(format!("invalid multipart data: {e}")))?
|
||||
{
|
||||
match field.name() {
|
||||
Some("file") => {
|
||||
if content_type_str.is_none() {
|
||||
content_type_str = field.content_type().map(|s| s.to_string());
|
||||
}
|
||||
let bytes = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| validation_error(format!("failed to read file: {e}")))?;
|
||||
file_data = Some(bytes.to_vec());
|
||||
}
|
||||
Some("content_type") | Some("contentType") => {
|
||||
let text = field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| validation_error(format!("failed to read content type: {e}")))?;
|
||||
content_type_str = Some(text);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let data = file_data.ok_or_else(|| validation_error("missing 'file' field".into()))?;
|
||||
let content_type_str =
|
||||
content_type_str.ok_or_else(|| validation_error("missing content type".into()))?;
|
||||
let content_type = ContentType::new(content_type_str)?;
|
||||
|
||||
MediaUpload::new(data, content_type).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn extract_file_bytes(mut multipart: Multipart) -> Result<Vec<u8>, ApiError> {
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| validation_error(format!("invalid multipart data: {e}")))?
|
||||
{
|
||||
if field.name() == Some("file") {
|
||||
let bytes = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| validation_error(format!("failed to read file: {e}")))?;
|
||||
return Ok(bytes.to_vec());
|
||||
}
|
||||
}
|
||||
|
||||
Err(validation_error("missing 'file' field".into()))
|
||||
}
|
||||
|
||||
fn validation_error(msg: String) -> ApiError {
|
||||
ApiError(application::errors::ApplicationError::Validation(msg))
|
||||
}
|
||||
38
crates/adapters/http-axum/src/extractors/path_id.rs
Normal file
38
crates/adapters/http-axum/src/extractors/path_id.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use axum::Json;
|
||||
use axum::extract::Path;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
pub struct PathId<T>(pub T);
|
||||
|
||||
pub struct PathIdRejection(String);
|
||||
|
||||
impl IntoResponse for PathIdRejection {
|
||||
fn into_response(self) -> Response {
|
||||
let body = serde_json::json!({ "error": self.0 });
|
||||
(StatusCode::BAD_REQUEST, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, T> axum::extract::FromRequestParts<S> for PathId<T>
|
||||
where
|
||||
S: Send + Sync,
|
||||
T: From<uuid::Uuid>,
|
||||
{
|
||||
type Rejection = PathIdRejection;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut axum::http::request::Parts,
|
||||
state: &S,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let Path(id_str) = Path::<String>::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(|e| PathIdRejection(format!("invalid path parameter: {e}")))?;
|
||||
|
||||
let uuid: uuid::Uuid = id_str
|
||||
.parse()
|
||||
.map_err(|_| PathIdRejection(format!("invalid UUID: {id_str}")))?;
|
||||
|
||||
Ok(PathId(T::from(uuid)))
|
||||
}
|
||||
}
|
||||
159
crates/adapters/http-axum/src/handlers/activities.rs
Normal file
159
crates/adapters/http-axum/src/handlers/activities.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use api_types::requests::{CreateActivityRequest, RenameActivityRequest, SetCategoryRequest};
|
||||
use api_types::responses::ActivityResponse;
|
||||
use application::activity::use_cases::{
|
||||
archive_activity, create_activity, delete_activity, get_activity, list_activities,
|
||||
rename_activity, set_category,
|
||||
};
|
||||
use domain::activity::ActivityId;
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::{AuthenticatedUser, PathId};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/activities", tag = "activities", security(("bearer" = [])),
|
||||
request_body = CreateActivityRequest,
|
||||
responses((status = 201, body = ActivityResponse))
|
||||
)]
|
||||
pub async fn handle_create(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Json(body): Json<CreateActivityRequest>,
|
||||
) -> Result<(StatusCode, Json<ActivityResponse>), ApiError> {
|
||||
let cmd = body.into_command(user_id)?;
|
||||
let deps = create_activity::Deps {
|
||||
activities: state.activity_command,
|
||||
events: state.event_publisher,
|
||||
};
|
||||
let activity = create_activity::execute(cmd, &deps).await?;
|
||||
Ok((StatusCode::CREATED, Json(ActivityResponse::from(activity))))
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/v1/activities/{id}", tag = "activities", security(("bearer" = [])),
|
||||
params(("id" = String, Path)),
|
||||
responses((status = 200, body = ActivityResponse))
|
||||
)]
|
||||
pub async fn handle_get(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
PathId(activity_id): PathId<ActivityId>,
|
||||
) -> Result<Json<ActivityResponse>, ApiError> {
|
||||
let deps = get_activity::Deps {
|
||||
query: state.activity_query,
|
||||
};
|
||||
let activity = get_activity::execute(activity_id, user_id, &deps).await?;
|
||||
Ok(Json(ActivityResponse::from(activity)))
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/v1/activities", tag = "activities", security(("bearer" = [])),
|
||||
responses((status = 200, body = Vec<ActivityResponse>))
|
||||
)]
|
||||
pub async fn handle_list(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
) -> Result<Json<Vec<ActivityResponse>>, ApiError> {
|
||||
let deps = list_activities::Deps {
|
||||
query: state.activity_query,
|
||||
};
|
||||
let activities = list_activities::active_only(user_id, &deps).await?;
|
||||
Ok(Json(
|
||||
activities.into_iter().map(ActivityResponse::from).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(patch, path = "/api/v1/activities/{id}/name", tag = "activities", security(("bearer" = [])),
|
||||
params(("id" = String, Path)),
|
||||
request_body = RenameActivityRequest,
|
||||
responses((status = 204))
|
||||
)]
|
||||
pub async fn handle_rename(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
PathId(activity_id): PathId<ActivityId>,
|
||||
Json(body): Json<RenameActivityRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let cmd = body.into_command(activity_id)?;
|
||||
let deps = rename_activity::Deps {
|
||||
command: state.activity_command,
|
||||
query: state.activity_query,
|
||||
events: state.event_publisher,
|
||||
};
|
||||
rename_activity::execute(cmd, user_id, &deps).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(patch, path = "/api/v1/activities/{id}/category", tag = "activities", security(("bearer" = [])),
|
||||
params(("id" = String, Path)),
|
||||
request_body = SetCategoryRequest,
|
||||
responses((status = 204))
|
||||
)]
|
||||
pub async fn handle_set_category(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
PathId(activity_id): PathId<ActivityId>,
|
||||
Json(body): Json<SetCategoryRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let cmd = body.into_command(activity_id)?;
|
||||
let deps = set_category::Deps {
|
||||
command: state.activity_command,
|
||||
query: state.activity_query,
|
||||
};
|
||||
set_category::execute(cmd, user_id, &deps).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/activities/{id}/archive", tag = "activities", security(("bearer" = [])),
|
||||
params(("id" = String, Path)),
|
||||
responses((status = 204))
|
||||
)]
|
||||
pub async fn handle_archive(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
PathId(activity_id): PathId<ActivityId>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let deps = archive_activity::Deps {
|
||||
command: state.activity_command,
|
||||
query: state.activity_query,
|
||||
events: state.event_publisher,
|
||||
};
|
||||
archive_activity::archive(activity_id, user_id, &deps).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/activities/{id}/unarchive", tag = "activities", security(("bearer" = [])),
|
||||
params(("id" = String, Path)),
|
||||
responses((status = 204))
|
||||
)]
|
||||
pub async fn handle_unarchive(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
PathId(activity_id): PathId<ActivityId>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let deps = archive_activity::Deps {
|
||||
command: state.activity_command,
|
||||
query: state.activity_query,
|
||||
events: state.event_publisher,
|
||||
};
|
||||
archive_activity::unarchive(activity_id, user_id, &deps).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(delete, path = "/api/v1/activities/{id}", tag = "activities", security(("bearer" = [])),
|
||||
params(("id" = String, Path)),
|
||||
responses((status = 204))
|
||||
)]
|
||||
pub async fn handle_delete(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
PathId(activity_id): PathId<ActivityId>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let deps = delete_activity::Deps {
|
||||
command: state.activity_command,
|
||||
query: state.activity_query,
|
||||
};
|
||||
delete_activity::execute(activity_id, user_id, &deps).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
89
crates/adapters/http-axum/src/handlers/auth.rs
Normal file
89
crates/adapters/http-axum/src/handlers/auth.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use api_types::requests::LoginRequest;
|
||||
use api_types::responses::UserResponse;
|
||||
use application::auth::use_cases::{login, logout, refresh};
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/auth/login", tag = "auth",
|
||||
request_body = LoginRequest,
|
||||
responses((status = 200, description = "Login successful"))
|
||||
)]
|
||||
pub async fn handle_login(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<LoginRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let cmd = body.into_command();
|
||||
let deps = login::Deps {
|
||||
user_query: state.user_query,
|
||||
password_hasher: state.password_hasher,
|
||||
auth_service: state.auth_service,
|
||||
refresh_session_command: state.refresh_session_command,
|
||||
refresh_token_ttl_seconds: state.auth_config.refresh_token_ttl_seconds as i64,
|
||||
};
|
||||
|
||||
let result = login::execute(cmd, &deps).await?;
|
||||
let user_response = UserResponse::from(result.user);
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"accessToken": result.access_token.token(),
|
||||
"refreshToken": result.refresh_token,
|
||||
"expiresAt": result.access_token.expires_at(),
|
||||
"user": user_response,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RefreshRequest {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/auth/refresh", tag = "auth",
|
||||
request_body = RefreshRequest,
|
||||
responses((status = 200, description = "Token refreshed"))
|
||||
)]
|
||||
pub async fn handle_refresh(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<RefreshRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let deps = refresh::Deps {
|
||||
auth_service: state.auth_service,
|
||||
refresh_session_command: state.refresh_session_command,
|
||||
refresh_session_query: state.refresh_session_query,
|
||||
refresh_token_ttl_seconds: state.auth_config.refresh_token_ttl_seconds as i64,
|
||||
};
|
||||
|
||||
let result = refresh::execute(&body.refresh_token, &deps).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"accessToken": result.access_token.token(),
|
||||
"refreshToken": result.refresh_token,
|
||||
"expiresAt": result.access_token.expires_at(),
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogoutRequest {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/auth/logout", tag = "auth",
|
||||
request_body = LogoutRequest,
|
||||
responses((status = 204, description = "Logged out"))
|
||||
)]
|
||||
pub async fn handle_logout(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<LogoutRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let deps = logout::Deps {
|
||||
refresh_session_command: state.refresh_session_command,
|
||||
};
|
||||
logout::execute(&body.refresh_token, &deps).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
260
crates/adapters/http-axum/src/handlers/entries.rs
Normal file
260
crates/adapters/http-axum/src/handlers/entries.rs
Normal file
@@ -0,0 +1,260 @@
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use api_types::mappers::correlation_response;
|
||||
use api_types::requests::{
|
||||
CreateEntryRequest, DateRangeParams, ListEntriesParams, ReplaceActivityRequest,
|
||||
UpdateEntryRequest,
|
||||
};
|
||||
use api_types::responses::{
|
||||
BulkActionResponse, CalendarDayResponse, CorrelationResponse, EntryResponse, MoodStatsResponse,
|
||||
};
|
||||
use application::entry::queries::{FilterByActivityQuery, FilterByMoodQuery, MoodStatsQuery};
|
||||
use application::entry::use_cases::{
|
||||
create_entry, delete_entries_by_date_range, delete_entry, filter_by_activity, filter_by_mood,
|
||||
get_activity_correlation, get_calendar, get_entry, get_mood_stats, list_entries,
|
||||
replace_activity, update_entry,
|
||||
};
|
||||
use domain::activity::ActivityId;
|
||||
use domain::entry::{Mood, MoodEntryId};
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::{AuthenticatedUser, PathId};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/entries", tag = "entries", security(("bearer" = [])),
|
||||
request_body = CreateEntryRequest,
|
||||
responses((status = 201, body = EntryResponse))
|
||||
)]
|
||||
pub async fn handle_create(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Json(body): Json<CreateEntryRequest>,
|
||||
) -> Result<(StatusCode, Json<EntryResponse>), ApiError> {
|
||||
let cmd = body.into_command(user_id, &state.entry_config)?;
|
||||
let deps = create_entry::Deps {
|
||||
entries: state.entry_command,
|
||||
events: state.event_publisher,
|
||||
};
|
||||
let entry = create_entry::execute(cmd, &deps).await?;
|
||||
Ok((StatusCode::CREATED, Json(EntryResponse::from(entry))))
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/v1/entries/{id}", tag = "entries", security(("bearer" = [])),
|
||||
params(("id" = String, Path, description = "Entry ID")),
|
||||
responses((status = 200, body = EntryResponse))
|
||||
)]
|
||||
pub async fn handle_get(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
PathId(entry_id): PathId<MoodEntryId>,
|
||||
) -> Result<Json<EntryResponse>, ApiError> {
|
||||
let deps = get_entry::Deps {
|
||||
query: state.entry_query,
|
||||
};
|
||||
let entry = get_entry::execute(entry_id, user_id, &deps).await?;
|
||||
Ok(Json(EntryResponse::from(entry)))
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/v1/entries", tag = "entries", security(("bearer" = [])),
|
||||
params(ListEntriesParams),
|
||||
responses((status = 200, body = Vec<EntryResponse>))
|
||||
)]
|
||||
pub async fn handle_list(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Query(params): Query<ListEntriesParams>,
|
||||
) -> Result<Json<Vec<EntryResponse>>, ApiError> {
|
||||
let query = params.into_query(user_id)?;
|
||||
let deps = list_entries::Deps {
|
||||
query: state.entry_query,
|
||||
};
|
||||
let entries = list_entries::execute(query, &deps).await?;
|
||||
Ok(Json(entries.into_iter().map(EntryResponse::from).collect()))
|
||||
}
|
||||
|
||||
#[utoipa::path(patch, path = "/api/v1/entries/{id}", tag = "entries", security(("bearer" = [])),
|
||||
params(("id" = String, Path, description = "Entry ID")),
|
||||
request_body = UpdateEntryRequest,
|
||||
responses((status = 200, body = EntryResponse))
|
||||
)]
|
||||
pub async fn handle_update(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
PathId(entry_id): PathId<MoodEntryId>,
|
||||
Json(body): Json<UpdateEntryRequest>,
|
||||
) -> Result<Json<EntryResponse>, ApiError> {
|
||||
let cmd = body.into_command(entry_id, &state.entry_config)?;
|
||||
let deps = update_entry::Deps {
|
||||
command: state.entry_command,
|
||||
query: state.entry_query,
|
||||
media_storage: state.media_storage,
|
||||
events: state.event_publisher,
|
||||
};
|
||||
let entry = update_entry::execute(cmd, user_id, &deps).await?;
|
||||
Ok(Json(EntryResponse::from(entry)))
|
||||
}
|
||||
|
||||
#[utoipa::path(delete, path = "/api/v1/entries/{id}", tag = "entries", security(("bearer" = [])),
|
||||
params(("id" = String, Path, description = "Entry ID")),
|
||||
responses((status = 204))
|
||||
)]
|
||||
pub async fn handle_delete(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
PathId(entry_id): PathId<MoodEntryId>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let deps = delete_entry::Deps {
|
||||
command: state.entry_command,
|
||||
query: state.entry_query,
|
||||
events: state.event_publisher,
|
||||
media_storage: state.media_storage,
|
||||
};
|
||||
delete_entry::execute(entry_id, user_id, &deps).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/v1/entries/filter/mood/{mood}", tag = "entries", security(("bearer" = [])),
|
||||
params(("mood" = u8, Path, description = "Mood value 1-5")),
|
||||
responses((status = 200, body = Vec<EntryResponse>))
|
||||
)]
|
||||
pub async fn handle_filter_by_mood(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(mood): Path<u8>,
|
||||
) -> Result<Json<Vec<EntryResponse>>, ApiError> {
|
||||
let mood = Mood::try_from(mood)?;
|
||||
let query = FilterByMoodQuery { user_id, mood };
|
||||
let deps = filter_by_mood::Deps {
|
||||
query: state.entry_query,
|
||||
};
|
||||
let entries = filter_by_mood::execute(query, &deps).await?;
|
||||
Ok(Json(entries.into_iter().map(EntryResponse::from).collect()))
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/v1/entries/filter/activity/{id}", tag = "entries", security(("bearer" = [])),
|
||||
params(("id" = String, Path, description = "Activity ID")),
|
||||
responses((status = 200, body = Vec<EntryResponse>))
|
||||
)]
|
||||
pub async fn handle_filter_by_activity(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
PathId(activity_id): PathId<ActivityId>,
|
||||
) -> Result<Json<Vec<EntryResponse>>, ApiError> {
|
||||
let query = FilterByActivityQuery {
|
||||
user_id,
|
||||
activity_id,
|
||||
};
|
||||
let deps = filter_by_activity::Deps {
|
||||
query: state.entry_query,
|
||||
};
|
||||
let entries = filter_by_activity::execute(query, &deps).await?;
|
||||
Ok(Json(entries.into_iter().map(EntryResponse::from).collect()))
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/v1/entries/stats", tag = "entries", security(("bearer" = [])),
|
||||
params(ListEntriesParams),
|
||||
responses((status = 200, body = MoodStatsResponse))
|
||||
)]
|
||||
pub async fn handle_stats(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Query(params): Query<ListEntriesParams>,
|
||||
) -> Result<Json<MoodStatsResponse>, ApiError> {
|
||||
let range = match (params.from, params.to) {
|
||||
(Some(from), Some(to)) => {
|
||||
let from = api_types::mappers::shared::parse_datetime(&from)?;
|
||||
let to = api_types::mappers::shared::parse_datetime(&to)?;
|
||||
Some(domain::entry::DateRange::new(from, to)?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let query = MoodStatsQuery { user_id, range };
|
||||
let deps = get_mood_stats::Deps {
|
||||
query: state.entry_query,
|
||||
};
|
||||
let stats = get_mood_stats::execute(query, &deps).await?;
|
||||
Ok(Json(MoodStatsResponse::from(stats)))
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/v1/entries/calendar", tag = "entries", security(("bearer" = [])),
|
||||
params(DateRangeParams),
|
||||
responses((status = 200, body = Vec<CalendarDayResponse>))
|
||||
)]
|
||||
pub async fn handle_calendar(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Query(params): Query<DateRangeParams>,
|
||||
) -> Result<Json<Vec<CalendarDayResponse>>, ApiError> {
|
||||
let range = params.into_date_range()?;
|
||||
let deps = get_calendar::Deps {
|
||||
query: state.entry_query,
|
||||
};
|
||||
let days = get_calendar::execute(user_id, range, &deps).await?;
|
||||
Ok(Json(
|
||||
days.into_iter().map(CalendarDayResponse::from).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/v1/entries/correlation/{id}", tag = "entries", security(("bearer" = [])),
|
||||
params(("id" = String, Path, description = "Activity ID"), ListEntriesParams),
|
||||
responses((status = 200, body = CorrelationResponse))
|
||||
)]
|
||||
pub async fn handle_activity_correlation(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
PathId(activity_id): PathId<ActivityId>,
|
||||
Query(params): Query<ListEntriesParams>,
|
||||
) -> Result<Json<CorrelationResponse>, ApiError> {
|
||||
let range = match (params.from, params.to) {
|
||||
(Some(from), Some(to)) => {
|
||||
let from = api_types::mappers::shared::parse_datetime(&from)?;
|
||||
let to = api_types::mappers::shared::parse_datetime(&to)?;
|
||||
Some(domain::entry::DateRange::new(from, to)?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let deps = get_activity_correlation::Deps {
|
||||
query: state.entry_query,
|
||||
};
|
||||
let correlation =
|
||||
get_activity_correlation::execute(user_id, activity_id.clone(), range, &deps).await?;
|
||||
Ok(Json(correlation_response(activity_id, correlation)))
|
||||
}
|
||||
|
||||
#[utoipa::path(delete, path = "/api/v1/entries/bulk/delete", tag = "entries", security(("bearer" = [])),
|
||||
params(DateRangeParams),
|
||||
responses((status = 200, body = BulkActionResponse))
|
||||
)]
|
||||
pub async fn handle_delete_by_date_range(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Query(params): Query<DateRangeParams>,
|
||||
) -> Result<Json<BulkActionResponse>, ApiError> {
|
||||
let range = params.into_date_range()?;
|
||||
let deps = delete_entries_by_date_range::Deps {
|
||||
cascade: state.cascade,
|
||||
media_storage: state.media_storage,
|
||||
};
|
||||
let affected_count = delete_entries_by_date_range::execute(user_id, &range, &deps).await?;
|
||||
Ok(Json(BulkActionResponse { affected_count }))
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/entries/bulk/replace-activity", tag = "entries", security(("bearer" = [])),
|
||||
request_body = ReplaceActivityRequest,
|
||||
responses((status = 200, body = BulkActionResponse))
|
||||
)]
|
||||
pub async fn handle_replace_activity(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Json(body): Json<ReplaceActivityRequest>,
|
||||
) -> Result<Json<BulkActionResponse>, ApiError> {
|
||||
let (user_id, old_id, new_id) = body.into_parts(user_id)?;
|
||||
let deps = replace_activity::Deps {
|
||||
entry_command: state.entry_command,
|
||||
activity_query: state.activity_query,
|
||||
};
|
||||
let affected_count = replace_activity::execute(user_id, old_id, new_id, &deps).await?;
|
||||
Ok(Json(BulkActionResponse { affected_count }))
|
||||
}
|
||||
64
crates/adapters/http-axum/src/handlers/import_export.rs
Normal file
64
crates/adapters/http-axum/src/handlers/import_export.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use axum::Json;
|
||||
use axum::extract::{Multipart, State};
|
||||
use axum::http::header;
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
use api_types::responses::ImportResultResponse;
|
||||
use application::export::use_cases::export_user_data;
|
||||
use application::import::commands::ImportCommand;
|
||||
use application::import::use_cases::import_entries;
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::{AuthenticatedUser, extract_file_bytes};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[utoipa::path(get, path = "/api/v1/data/export", tag = "data", security(("bearer" = [])),
|
||||
responses((status = 200, description = "ZIP archive with user data"))
|
||||
)]
|
||||
pub async fn handle_export(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let deps = export_user_data::Deps {
|
||||
entry_query: state.entry_query,
|
||||
activity_query: state.activity_query,
|
||||
reminder_query: state.reminder_query,
|
||||
media_storage: state.media_storage,
|
||||
exporter: state.export_port.clone(),
|
||||
};
|
||||
let data = export_user_data::execute(user_id, &deps).await?;
|
||||
|
||||
Ok((
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/zip"),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"k-mood-export.zip\"",
|
||||
),
|
||||
],
|
||||
data,
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/data/import", tag = "data", security(("bearer" = [])),
|
||||
responses((status = 200, body = ImportResultResponse))
|
||||
)]
|
||||
pub async fn handle_import(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
multipart: Multipart,
|
||||
) -> Result<Json<ImportResultResponse>, ApiError> {
|
||||
let data = extract_file_bytes(multipart).await?;
|
||||
|
||||
let cmd = ImportCommand { user_id, data };
|
||||
let deps = import_entries::Deps {
|
||||
source: state.import_source.clone(),
|
||||
entry_command: state.entry_command,
|
||||
entry_query: state.entry_query,
|
||||
activity_command: state.activity_command,
|
||||
activity_query: state.activity_query,
|
||||
preset: state.preset_config,
|
||||
};
|
||||
let result = import_entries::execute(cmd, &deps).await?;
|
||||
Ok(Json(ImportResultResponse::from(result)))
|
||||
}
|
||||
122
crates/adapters/http-axum/src/handlers/media.rs
Normal file
122
crates/adapters/http-axum/src/handlers/media.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
use axum::Json;
|
||||
use axum::extract::{Multipart, State};
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
use api_types::responses::MediaIdResponse;
|
||||
use application::media::use_cases::{
|
||||
delete_photo, delete_voice_memo, upload_photo, upload_voice_memo,
|
||||
};
|
||||
use domain::attachment::{PhotoId, VoiceMemoId};
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::{AuthenticatedUser, PathId, extract_media_upload};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/media/photos", tag = "media", security(("bearer" = [])),
|
||||
responses((status = 201, body = MediaIdResponse))
|
||||
)]
|
||||
pub async fn handle_upload_photo(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(_user_id): AuthenticatedUser,
|
||||
multipart: Multipart,
|
||||
) -> Result<(StatusCode, Json<MediaIdResponse>), ApiError> {
|
||||
let upload = extract_media_upload(multipart).await?;
|
||||
let deps = upload_photo::Deps {
|
||||
storage: state.media_storage,
|
||||
};
|
||||
let photo_id = upload_photo::execute(upload, &deps).await?;
|
||||
Ok((StatusCode::CREATED, Json(MediaIdResponse::from(photo_id))))
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/media/voice-memos", tag = "media", security(("bearer" = [])),
|
||||
responses((status = 201, body = MediaIdResponse))
|
||||
)]
|
||||
pub async fn handle_upload_voice_memo(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(_user_id): AuthenticatedUser,
|
||||
multipart: Multipart,
|
||||
) -> Result<(StatusCode, Json<MediaIdResponse>), ApiError> {
|
||||
let upload = extract_media_upload(multipart).await?;
|
||||
let deps = upload_voice_memo::Deps {
|
||||
storage: state.media_storage,
|
||||
};
|
||||
let voice_memo_id = upload_voice_memo::execute(upload, &deps).await?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(MediaIdResponse::from(voice_memo_id)),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/v1/media/photos/{id}", tag = "media",
|
||||
params(("id" = String, Path)),
|
||||
responses((status = 200, description = "Photo binary"))
|
||||
)]
|
||||
pub async fn handle_serve_photo(
|
||||
State(state): State<AppState>,
|
||||
PathId(photo_id): PathId<PhotoId>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let file = state
|
||||
.media_storage
|
||||
.get_photo(&photo_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("photo not found".into()))?;
|
||||
|
||||
Ok((
|
||||
[(header::CONTENT_TYPE, file.content_type.value().to_string())],
|
||||
file.data,
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/v1/media/voice-memos/{id}", tag = "media",
|
||||
params(("id" = String, Path)),
|
||||
responses((status = 200, description = "Voice memo binary"))
|
||||
)]
|
||||
pub async fn handle_serve_voice_memo(
|
||||
State(state): State<AppState>,
|
||||
PathId(voice_memo_id): PathId<VoiceMemoId>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let file = state
|
||||
.media_storage
|
||||
.get_voice_memo(&voice_memo_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("voice memo not found".into()))?;
|
||||
|
||||
Ok((
|
||||
[(header::CONTENT_TYPE, file.content_type.value().to_string())],
|
||||
file.data,
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(delete, path = "/api/v1/media/photos/{id}", tag = "media", security(("bearer" = [])),
|
||||
params(("id" = String, Path)),
|
||||
responses((status = 204))
|
||||
)]
|
||||
pub async fn handle_delete_photo(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(_user_id): AuthenticatedUser,
|
||||
PathId(photo_id): PathId<PhotoId>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let deps = delete_photo::Deps {
|
||||
storage: state.media_storage,
|
||||
};
|
||||
delete_photo::execute(photo_id, &deps).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(delete, path = "/api/v1/media/voice-memos/{id}", tag = "media", security(("bearer" = [])),
|
||||
params(("id" = String, Path)),
|
||||
responses((status = 204))
|
||||
)]
|
||||
pub async fn handle_delete_voice_memo(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(_user_id): AuthenticatedUser,
|
||||
PathId(voice_memo_id): PathId<VoiceMemoId>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let deps = delete_voice_memo::Deps {
|
||||
storage: state.media_storage,
|
||||
};
|
||||
delete_voice_memo::execute(voice_memo_id, &deps).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
8
crates/adapters/http-axum/src/handlers/mod.rs
Normal file
8
crates/adapters/http-axum/src/handlers/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub mod activities;
|
||||
pub mod auth;
|
||||
pub mod entries;
|
||||
pub mod import_export;
|
||||
pub mod media;
|
||||
pub mod push;
|
||||
pub mod reminders;
|
||||
pub mod users;
|
||||
77
crates/adapters/http-axum/src/handlers/push.rs
Normal file
77
crates/adapters/http-axum/src/handlers/push.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use api_types::requests::{PushSubscribeRequest, PushUnsubscribeRequest};
|
||||
use application::push::use_cases::{subscribe, unsubscribe};
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::AuthenticatedUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[utoipa::path(get, path = "/api/v1/push/vapid-key", tag = "push",
|
||||
responses((status = 200, description = "VAPID public key"))
|
||||
)]
|
||||
pub async fn handle_vapid_key(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
if !state.push_config.enabled {
|
||||
return Err(domain::errors::DomainError::NotFound(
|
||||
"push notifications are disabled".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let public_key = web_push_adapter::WebPushSender::public_key_base64(&state.push_config)?;
|
||||
Ok(Json(serde_json::json!({ "publicKey": public_key })))
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/push/subscribe", tag = "push", security(("bearer" = [])),
|
||||
request_body = PushSubscribeRequest,
|
||||
responses((status = 204))
|
||||
)]
|
||||
pub async fn handle_subscribe(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Json(body): Json<PushSubscribeRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let cmd = body.into_command(user_id);
|
||||
let deps = subscribe::Deps {
|
||||
push_command: state.push_subscription_command,
|
||||
push_query: state.push_subscription_query,
|
||||
};
|
||||
subscribe::execute(cmd, &deps).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/push/unsubscribe", tag = "push", security(("bearer" = [])),
|
||||
request_body = PushUnsubscribeRequest,
|
||||
responses((status = 204))
|
||||
)]
|
||||
pub async fn handle_unsubscribe(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(_user_id): AuthenticatedUser,
|
||||
Json(body): Json<PushUnsubscribeRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let cmd = body.into_command();
|
||||
let deps = unsubscribe::Deps {
|
||||
push_command: state.push_subscription_command,
|
||||
};
|
||||
unsubscribe::execute(cmd, &deps).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/push/test", tag = "push", security(("bearer" = [])),
|
||||
responses((status = 204))
|
||||
)]
|
||||
pub async fn handle_test(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let sender = state
|
||||
.reminder_sender
|
||||
.as_ref()
|
||||
.ok_or_else(|| domain::errors::DomainError::InvalidInput("push not enabled".into()))?;
|
||||
|
||||
sender.send_reminder(&user_id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
103
crates/adapters/http-axum/src/handlers/reminders.rs
Normal file
103
crates/adapters/http-axum/src/handlers/reminders.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use api_types::requests::{CreateReminderRequest, UpdateReminderRequest};
|
||||
use api_types::responses::ReminderResponse;
|
||||
use application::reminder::use_cases::{
|
||||
create_reminder, delete_reminder, get_reminder, list_reminders, update_reminder,
|
||||
};
|
||||
use domain::reminder::ReminderId;
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::{AuthenticatedUser, PathId};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/reminders", tag = "reminders", security(("bearer" = [])),
|
||||
request_body = CreateReminderRequest,
|
||||
responses((status = 201, body = ReminderResponse))
|
||||
)]
|
||||
pub async fn handle_create(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Json(body): Json<CreateReminderRequest>,
|
||||
) -> Result<(StatusCode, Json<ReminderResponse>), ApiError> {
|
||||
let cmd = body.into_command(user_id)?;
|
||||
let deps = create_reminder::Deps {
|
||||
reminders: state.reminder_command,
|
||||
events: state.event_publisher,
|
||||
};
|
||||
let reminder = create_reminder::execute(cmd, &deps).await?;
|
||||
Ok((StatusCode::CREATED, Json(ReminderResponse::from(reminder))))
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/v1/reminders/{id}", tag = "reminders", security(("bearer" = [])),
|
||||
params(("id" = String, Path)),
|
||||
responses((status = 200, body = ReminderResponse))
|
||||
)]
|
||||
pub async fn handle_get(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
PathId(reminder_id): PathId<ReminderId>,
|
||||
) -> Result<Json<ReminderResponse>, ApiError> {
|
||||
let deps = get_reminder::Deps {
|
||||
query: state.reminder_query,
|
||||
};
|
||||
let reminder = get_reminder::execute(reminder_id, user_id, &deps).await?;
|
||||
Ok(Json(ReminderResponse::from(reminder)))
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/v1/reminders", tag = "reminders", security(("bearer" = [])),
|
||||
responses((status = 200, body = Vec<ReminderResponse>))
|
||||
)]
|
||||
pub async fn handle_list(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
) -> Result<Json<Vec<ReminderResponse>>, ApiError> {
|
||||
let deps = list_reminders::Deps {
|
||||
query: state.reminder_query,
|
||||
};
|
||||
let reminders = list_reminders::execute(user_id, &deps).await?;
|
||||
Ok(Json(
|
||||
reminders.into_iter().map(ReminderResponse::from).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(patch, path = "/api/v1/reminders/{id}", tag = "reminders", security(("bearer" = [])),
|
||||
params(("id" = String, Path)),
|
||||
request_body = UpdateReminderRequest,
|
||||
responses((status = 200, body = ReminderResponse))
|
||||
)]
|
||||
pub async fn handle_update(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
PathId(reminder_id): PathId<ReminderId>,
|
||||
Json(body): Json<UpdateReminderRequest>,
|
||||
) -> Result<Json<ReminderResponse>, ApiError> {
|
||||
let cmd = body.into_command(reminder_id)?;
|
||||
let deps = update_reminder::Deps {
|
||||
command: state.reminder_command,
|
||||
query: state.reminder_query,
|
||||
events: state.event_publisher,
|
||||
};
|
||||
let reminder = update_reminder::execute(cmd, user_id, &deps).await?;
|
||||
Ok(Json(ReminderResponse::from(reminder)))
|
||||
}
|
||||
|
||||
#[utoipa::path(delete, path = "/api/v1/reminders/{id}", tag = "reminders", security(("bearer" = [])),
|
||||
params(("id" = String, Path)),
|
||||
responses((status = 204))
|
||||
)]
|
||||
pub async fn handle_delete(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
PathId(reminder_id): PathId<ReminderId>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let deps = delete_reminder::Deps {
|
||||
command: state.reminder_command,
|
||||
query: state.reminder_query,
|
||||
events: state.event_publisher,
|
||||
};
|
||||
delete_reminder::execute(reminder_id, user_id, &deps).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
124
crates/adapters/http-axum/src/handlers/users.rs
Normal file
124
crates/adapters/http-axum/src/handlers/users.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use api_types::requests::{ChangePasswordRequest, RegisterRequest, UpdateProfileRequest};
|
||||
use api_types::responses::UserResponse;
|
||||
use application::user::use_cases::{
|
||||
change_password, clear_data, delete_user, get_profile, register, update_profile,
|
||||
};
|
||||
|
||||
use crate::errors::ApiError;
|
||||
use crate::extractors::AuthenticatedUser;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[utoipa::path(post, path = "/api/v1/users/register", tag = "users",
|
||||
request_body = RegisterRequest,
|
||||
responses((status = 201, body = UserResponse))
|
||||
)]
|
||||
pub async fn handle_register(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<RegisterRequest>,
|
||||
) -> Result<(StatusCode, Json<UserResponse>), ApiError> {
|
||||
if !state.auth_config.allow_registration {
|
||||
return Err(
|
||||
domain::errors::DomainError::Forbidden("registration is disabled".into()).into(),
|
||||
);
|
||||
}
|
||||
let cmd = body.into_command()?;
|
||||
let deps = register::Deps {
|
||||
user_command: state.user_command,
|
||||
user_query: state.user_query,
|
||||
activity_command: state.activity_command,
|
||||
password_hasher: state.password_hasher,
|
||||
events: state.event_publisher,
|
||||
preset: state.preset_config,
|
||||
};
|
||||
let user = register::execute(cmd, &deps).await?;
|
||||
Ok((StatusCode::CREATED, Json(UserResponse::from(user))))
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/api/v1/users/me", tag = "users", security(("bearer" = [])),
|
||||
responses((status = 200, body = UserResponse))
|
||||
)]
|
||||
pub async fn handle_get_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
) -> Result<Json<UserResponse>, ApiError> {
|
||||
let deps = get_profile::Deps {
|
||||
user_query: state.user_query,
|
||||
};
|
||||
let user = get_profile::execute(user_id, &deps).await?;
|
||||
Ok(Json(UserResponse::from(user)))
|
||||
}
|
||||
|
||||
#[utoipa::path(patch, path = "/api/v1/users/me", tag = "users", security(("bearer" = [])),
|
||||
request_body = UpdateProfileRequest,
|
||||
responses((status = 200, body = UserResponse))
|
||||
)]
|
||||
pub async fn handle_update_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Json(body): Json<UpdateProfileRequest>,
|
||||
) -> Result<Json<UserResponse>, ApiError> {
|
||||
let cmd = body.into_command(user_id)?;
|
||||
let deps = update_profile::Deps {
|
||||
user_command: state.user_command,
|
||||
user_query: state.user_query,
|
||||
};
|
||||
let user = update_profile::execute(cmd, &deps).await?;
|
||||
Ok(Json(UserResponse::from(user)))
|
||||
}
|
||||
|
||||
#[utoipa::path(patch, path = "/api/v1/users/me/password", tag = "users", security(("bearer" = [])),
|
||||
request_body = ChangePasswordRequest,
|
||||
responses((status = 204))
|
||||
)]
|
||||
pub async fn handle_change_password(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Json(body): Json<ChangePasswordRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let cmd = body.into_command(user_id);
|
||||
let deps = change_password::Deps {
|
||||
user_command: state.user_command,
|
||||
user_query: state.user_query,
|
||||
password_hasher: state.password_hasher,
|
||||
};
|
||||
change_password::execute(cmd, &deps).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(delete, path = "/api/v1/users/me", tag = "users", security(("bearer" = [])),
|
||||
responses((status = 204))
|
||||
)]
|
||||
pub async fn handle_delete(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let deps = delete_user::Deps {
|
||||
user_query: state.user_query,
|
||||
entry_query: state.entry_query,
|
||||
cascade: state.cascade,
|
||||
media_storage: state.media_storage,
|
||||
events: state.event_publisher,
|
||||
};
|
||||
delete_user::execute(user_id, &deps).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(delete, path = "/api/v1/users/me/data", tag = "users", security(("bearer" = [])),
|
||||
responses((status = 204, description = "All user data cleared"))
|
||||
)]
|
||||
pub async fn handle_clear_data(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let deps = clear_data::Deps {
|
||||
entry_query: state.entry_query,
|
||||
cascade: state.cascade,
|
||||
media_storage: state.media_storage,
|
||||
};
|
||||
clear_data::execute(user_id, &deps).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
7
crates/adapters/http-axum/src/lib.rs
Normal file
7
crates/adapters/http-axum/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod errors;
|
||||
pub mod extractors;
|
||||
pub mod handlers;
|
||||
pub mod openapi;
|
||||
pub mod router;
|
||||
pub mod spa;
|
||||
pub mod state;
|
||||
120
crates/adapters/http-axum/src/openapi.rs
Normal file
120
crates/adapters/http-axum/src/openapi.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
|
||||
use utoipa::{Modify, OpenApi};
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
title = "k-mood API",
|
||||
version = "1.0.0",
|
||||
description = "Mood tracking journal API"
|
||||
),
|
||||
modifiers(&SecurityAddon),
|
||||
paths(
|
||||
crate::handlers::auth::handle_login,
|
||||
crate::handlers::auth::handle_refresh,
|
||||
crate::handlers::auth::handle_logout,
|
||||
crate::handlers::entries::handle_create,
|
||||
crate::handlers::entries::handle_get,
|
||||
crate::handlers::entries::handle_list,
|
||||
crate::handlers::entries::handle_update,
|
||||
crate::handlers::entries::handle_delete,
|
||||
crate::handlers::entries::handle_filter_by_mood,
|
||||
crate::handlers::entries::handle_filter_by_activity,
|
||||
crate::handlers::entries::handle_stats,
|
||||
crate::handlers::entries::handle_calendar,
|
||||
crate::handlers::entries::handle_activity_correlation,
|
||||
crate::handlers::entries::handle_delete_by_date_range,
|
||||
crate::handlers::entries::handle_replace_activity,
|
||||
crate::handlers::activities::handle_create,
|
||||
crate::handlers::activities::handle_get,
|
||||
crate::handlers::activities::handle_list,
|
||||
crate::handlers::activities::handle_rename,
|
||||
crate::handlers::activities::handle_set_category,
|
||||
crate::handlers::activities::handle_archive,
|
||||
crate::handlers::activities::handle_unarchive,
|
||||
crate::handlers::activities::handle_delete,
|
||||
crate::handlers::users::handle_register,
|
||||
crate::handlers::users::handle_get_profile,
|
||||
crate::handlers::users::handle_update_profile,
|
||||
crate::handlers::users::handle_change_password,
|
||||
crate::handlers::users::handle_delete,
|
||||
crate::handlers::users::handle_clear_data,
|
||||
crate::handlers::reminders::handle_create,
|
||||
crate::handlers::reminders::handle_get,
|
||||
crate::handlers::reminders::handle_list,
|
||||
crate::handlers::reminders::handle_update,
|
||||
crate::handlers::reminders::handle_delete,
|
||||
crate::handlers::media::handle_upload_photo,
|
||||
crate::handlers::media::handle_upload_voice_memo,
|
||||
crate::handlers::media::handle_serve_photo,
|
||||
crate::handlers::media::handle_serve_voice_memo,
|
||||
crate::handlers::media::handle_delete_photo,
|
||||
crate::handlers::media::handle_delete_voice_memo,
|
||||
crate::handlers::import_export::handle_export,
|
||||
crate::handlers::import_export::handle_import,
|
||||
crate::handlers::push::handle_vapid_key,
|
||||
crate::handlers::push::handle_subscribe,
|
||||
crate::handlers::push::handle_unsubscribe,
|
||||
crate::handlers::push::handle_test,
|
||||
),
|
||||
components(schemas(
|
||||
api_types::requests::CreateEntryRequest,
|
||||
api_types::requests::UpdateEntryRequest,
|
||||
api_types::requests::ListEntriesParams,
|
||||
api_types::requests::DateRangeParams,
|
||||
api_types::requests::ReplaceActivityRequest,
|
||||
api_types::requests::CreateActivityRequest,
|
||||
api_types::requests::RenameActivityRequest,
|
||||
api_types::requests::SetCategoryRequest,
|
||||
api_types::requests::RegisterRequest,
|
||||
api_types::requests::LoginRequest,
|
||||
api_types::requests::UpdateProfileRequest,
|
||||
api_types::requests::ChangePasswordRequest,
|
||||
api_types::requests::CreateReminderRequest,
|
||||
api_types::requests::UpdateReminderRequest,
|
||||
crate::handlers::auth::RefreshRequest,
|
||||
crate::handlers::auth::LogoutRequest,
|
||||
api_types::responses::EntryResponse,
|
||||
api_types::responses::ActivityResponse,
|
||||
api_types::responses::UserResponse,
|
||||
api_types::responses::ReminderResponse,
|
||||
api_types::responses::DayScheduleResponse,
|
||||
api_types::responses::MoodStatsResponse,
|
||||
api_types::responses::MoodFrequency,
|
||||
api_types::responses::CalendarDayResponse,
|
||||
api_types::responses::BulkActionResponse,
|
||||
api_types::responses::CorrelationResponse,
|
||||
api_types::responses::ImportResultResponse,
|
||||
api_types::responses::MediaIdResponse,
|
||||
api_types::requests::PushSubscribeRequest,
|
||||
api_types::requests::PushUnsubscribeRequest,
|
||||
)),
|
||||
tags(
|
||||
(name = "auth", description = "Authentication"),
|
||||
(name = "entries", description = "Mood entries"),
|
||||
(name = "activities", description = "Activity catalog"),
|
||||
(name = "users", description = "User management"),
|
||||
(name = "reminders", description = "Reminder schedules"),
|
||||
(name = "media", description = "Photo and voice memo storage"),
|
||||
(name = "data", description = "Import and export"),
|
||||
(name = "push", description = "Push notifications"),
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
struct SecurityAddon;
|
||||
|
||||
impl Modify for SecurityAddon {
|
||||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||
let components = openapi.components.get_or_insert_with(Default::default);
|
||||
components.add_security_scheme(
|
||||
"bearer",
|
||||
SecurityScheme::Http(
|
||||
HttpBuilder::new()
|
||||
.scheme(HttpAuthScheme::Bearer)
|
||||
.bearer_format("JWT")
|
||||
.build(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
172
crates/adapters/http-axum/src/router.rs
Normal file
172
crates/adapters/http-axum/src/router.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::http::HeaderValue;
|
||||
use axum::routing::{delete, get, patch, post};
|
||||
use axum::{Json, Router};
|
||||
use tower_http::cors::{AllowOrigin, Any, CorsLayer};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use utoipa::OpenApi;
|
||||
use utoipa_scalar::{Scalar, Servable};
|
||||
|
||||
use crate::handlers::{activities, auth, entries, import_export, media, push, reminders, users};
|
||||
use crate::openapi::ApiDoc;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let cors = build_cors(&state.server_config.cors);
|
||||
let body_limit = DefaultBodyLimit::max(state.server_config.max_body_size);
|
||||
|
||||
Router::new()
|
||||
.nest("/api/v1", api_routes())
|
||||
.route("/health", get(health))
|
||||
.route("/openapi.json", get(openapi_json))
|
||||
.merge(Scalar::with_url("/docs", ApiDoc::openapi()))
|
||||
.fallback_service(crate::spa::serve_spa(&state.server_config.spa_dir))
|
||||
.layer(body_limit)
|
||||
.layer(cors)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn openapi_json() -> Json<utoipa::openapi::OpenApi> {
|
||||
Json(ApiDoc::openapi())
|
||||
}
|
||||
|
||||
fn build_cors(config: &config::CorsConfig) -> CorsLayer {
|
||||
let layer = CorsLayer::new().allow_methods(Any).allow_headers(Any);
|
||||
|
||||
if config.allow_any_origin {
|
||||
return layer.allow_origin(Any);
|
||||
}
|
||||
|
||||
let origins: Vec<HeaderValue> = config
|
||||
.allowed_origins
|
||||
.iter()
|
||||
.filter_map(|o| o.parse().ok())
|
||||
.collect();
|
||||
|
||||
layer.allow_origin(AllowOrigin::list(origins))
|
||||
}
|
||||
|
||||
async fn health() -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!({
|
||||
"status": "ok",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
}))
|
||||
}
|
||||
|
||||
fn api_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.nest("/auth", auth_routes())
|
||||
.nest("/entries", entry_routes())
|
||||
.nest("/activities", activity_routes())
|
||||
.nest("/users", user_routes())
|
||||
.nest("/reminders", reminder_routes())
|
||||
.nest("/media", media_routes())
|
||||
.nest("/push", push_routes())
|
||||
.nest("/data", data_routes())
|
||||
}
|
||||
|
||||
fn auth_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/login", post(auth::handle_login))
|
||||
.route("/refresh", post(auth::handle_refresh))
|
||||
.route("/logout", post(auth::handle_logout))
|
||||
}
|
||||
|
||||
fn entry_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(entries::handle_list).post(entries::handle_create))
|
||||
.route(
|
||||
"/{id}",
|
||||
get(entries::handle_get)
|
||||
.patch(entries::handle_update)
|
||||
.delete(entries::handle_delete),
|
||||
)
|
||||
.route("/stats", get(entries::handle_stats))
|
||||
.route("/calendar", get(entries::handle_calendar))
|
||||
.route("/filter/mood/{mood}", get(entries::handle_filter_by_mood))
|
||||
.route(
|
||||
"/filter/activity/{id}",
|
||||
get(entries::handle_filter_by_activity),
|
||||
)
|
||||
.route(
|
||||
"/correlation/{id}",
|
||||
get(entries::handle_activity_correlation),
|
||||
)
|
||||
.route("/bulk/delete", delete(entries::handle_delete_by_date_range))
|
||||
.route(
|
||||
"/bulk/replace-activity",
|
||||
post(entries::handle_replace_activity),
|
||||
)
|
||||
}
|
||||
|
||||
fn activity_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/",
|
||||
get(activities::handle_list).post(activities::handle_create),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
get(activities::handle_get).delete(activities::handle_delete),
|
||||
)
|
||||
.route("/{id}/name", patch(activities::handle_rename))
|
||||
.route("/{id}/category", patch(activities::handle_set_category))
|
||||
.route("/{id}/archive", post(activities::handle_archive))
|
||||
.route("/{id}/unarchive", post(activities::handle_unarchive))
|
||||
}
|
||||
|
||||
fn user_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/register", post(users::handle_register))
|
||||
.route(
|
||||
"/me",
|
||||
get(users::handle_get_profile)
|
||||
.patch(users::handle_update_profile)
|
||||
.delete(users::handle_delete),
|
||||
)
|
||||
.route("/me/password", patch(users::handle_change_password))
|
||||
.route("/me/data", delete(users::handle_clear_data))
|
||||
}
|
||||
|
||||
fn reminder_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/",
|
||||
get(reminders::handle_list).post(reminders::handle_create),
|
||||
)
|
||||
.route(
|
||||
"/{id}",
|
||||
get(reminders::handle_get)
|
||||
.patch(reminders::handle_update)
|
||||
.delete(reminders::handle_delete),
|
||||
)
|
||||
}
|
||||
|
||||
fn media_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/photos", post(media::handle_upload_photo))
|
||||
.route(
|
||||
"/photos/{id}",
|
||||
get(media::handle_serve_photo).delete(media::handle_delete_photo),
|
||||
)
|
||||
.route("/voice-memos", post(media::handle_upload_voice_memo))
|
||||
.route(
|
||||
"/voice-memos/{id}",
|
||||
get(media::handle_serve_voice_memo).delete(media::handle_delete_voice_memo),
|
||||
)
|
||||
}
|
||||
|
||||
fn push_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/vapid-key", get(push::handle_vapid_key))
|
||||
.route("/subscribe", post(push::handle_subscribe))
|
||||
.route("/unsubscribe", post(push::handle_unsubscribe))
|
||||
.route("/test", post(push::handle_test))
|
||||
}
|
||||
|
||||
fn data_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/export", get(import_export::handle_export))
|
||||
.route("/import", post(import_export::handle_import))
|
||||
}
|
||||
5
crates/adapters/http-axum/src/spa.rs
Normal file
5
crates/adapters/http-axum/src/spa.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
pub fn serve_spa(spa_dir: &str) -> ServeDir<ServeFile> {
|
||||
ServeDir::new(spa_dir).fallback(ServeFile::new(format!("{spa_dir}/index.html")))
|
||||
}
|
||||
39
crates/adapters/http-axum/src/state.rs
Normal file
39
crates/adapters/http-axum/src/state.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use config::{AuthConfig, EntryConfig, PresetConfig, PushConfig, ServerConfig};
|
||||
use domain::ports::{
|
||||
ActivityCommandPort, ActivityQueryPort, AuthServicePort, CascadeDeletePort, EventPublisherPort,
|
||||
ExportPort, ImportSourcePort, MediaStoragePort, MoodEntryCommandPort, MoodEntryQueryPort,
|
||||
PasswordHasherPort, PushSubscriptionCommandPort, PushSubscriptionQueryPort,
|
||||
RefreshSessionCommandPort, RefreshSessionQueryPort, ReminderCommandPort, ReminderQueryPort,
|
||||
ReminderSenderPort, UserCommandPort, UserQueryPort,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub entry_command: Arc<dyn MoodEntryCommandPort>,
|
||||
pub entry_query: Arc<dyn MoodEntryQueryPort>,
|
||||
pub activity_command: Arc<dyn ActivityCommandPort>,
|
||||
pub activity_query: Arc<dyn ActivityQueryPort>,
|
||||
pub user_command: Arc<dyn UserCommandPort>,
|
||||
pub user_query: Arc<dyn UserQueryPort>,
|
||||
pub reminder_command: Arc<dyn ReminderCommandPort>,
|
||||
pub reminder_query: Arc<dyn ReminderQueryPort>,
|
||||
pub refresh_session_command: Arc<dyn RefreshSessionCommandPort>,
|
||||
pub refresh_session_query: Arc<dyn RefreshSessionQueryPort>,
|
||||
pub cascade: Arc<dyn CascadeDeletePort>,
|
||||
pub auth_service: Arc<dyn AuthServicePort>,
|
||||
pub password_hasher: Arc<dyn PasswordHasherPort>,
|
||||
pub event_publisher: Arc<dyn EventPublisherPort>,
|
||||
pub media_storage: Arc<dyn MediaStoragePort>,
|
||||
pub export_port: Arc<dyn ExportPort>,
|
||||
pub import_source: Arc<dyn ImportSourcePort>,
|
||||
pub push_subscription_command: Arc<dyn PushSubscriptionCommandPort>,
|
||||
pub push_subscription_query: Arc<dyn PushSubscriptionQueryPort>,
|
||||
pub reminder_sender: Option<Arc<dyn ReminderSenderPort>>,
|
||||
pub server_config: ServerConfig,
|
||||
pub entry_config: EntryConfig,
|
||||
pub auth_config: AuthConfig,
|
||||
pub push_config: PushConfig,
|
||||
pub preset_config: PresetConfig,
|
||||
}
|
||||
13
crates/adapters/importer/Cargo.toml
Normal file
13
crates/adapters/importer/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "importer"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
async-trait.workspace = true
|
||||
csv.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
zip.workspace = true
|
||||
tracing.workspace = true
|
||||
133
crates/adapters/importer/src/csv_generic.rs
Normal file
133
crates/adapters/importer/src/csv_generic.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::ImportedRow;
|
||||
|
||||
pub struct CsvImportConfig {
|
||||
pub date_column: usize,
|
||||
pub time_column: usize,
|
||||
pub mood_column: usize,
|
||||
pub activities_column: Option<usize>,
|
||||
pub note_column: Option<usize>,
|
||||
pub activities_separator: String,
|
||||
pub mood_mapping: Vec<(String, u8)>,
|
||||
pub delimiter: u8,
|
||||
}
|
||||
|
||||
impl Default for CsvImportConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
date_column: 0,
|
||||
time_column: 1,
|
||||
mood_column: 2,
|
||||
activities_column: Some(3),
|
||||
note_column: Some(4),
|
||||
activities_separator: "|".into(),
|
||||
mood_mapping: vec![
|
||||
("1".into(), 1),
|
||||
("2".into(), 2),
|
||||
("3".into(), 3),
|
||||
("4".into(), 4),
|
||||
("5".into(), 5),
|
||||
("awful".into(), 1),
|
||||
("bad".into(), 2),
|
||||
("meh".into(), 3),
|
||||
("good".into(), 4),
|
||||
("rad".into(), 5),
|
||||
],
|
||||
delimiter: b',',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CsvImportAdapter {
|
||||
config: CsvImportConfig,
|
||||
}
|
||||
|
||||
impl CsvImportAdapter {
|
||||
pub fn new(config: CsvImportConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
fn map_mood(&self, value: &str) -> Result<u8, DomainError> {
|
||||
let normalized = value.trim().to_lowercase();
|
||||
|
||||
if let Ok(num) = normalized.parse::<u8>()
|
||||
&& (1..=5).contains(&num)
|
||||
{
|
||||
return Ok(num);
|
||||
}
|
||||
|
||||
self.config
|
||||
.mood_mapping
|
||||
.iter()
|
||||
.find(|(label, _)| label.to_lowercase() == normalized)
|
||||
.map(|(_, value)| *value)
|
||||
.ok_or_else(|| DomainError::InvalidInput(format!("unknown mood value: {value}")))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ImportSourcePort for CsvImportAdapter {
|
||||
async fn read_entries(&self, data: &[u8]) -> Result<Vec<ImportedRow>, DomainError> {
|
||||
let content = std::str::from_utf8(data)
|
||||
.map_err(|e| DomainError::InvalidInput(format!("invalid UTF-8: {e}")))?;
|
||||
|
||||
let mut reader = csv::ReaderBuilder::new()
|
||||
.has_headers(true)
|
||||
.delimiter(self.config.delimiter)
|
||||
.from_reader(content.as_bytes());
|
||||
|
||||
let mut rows = Vec::new();
|
||||
|
||||
for result in reader.records() {
|
||||
let record =
|
||||
result.map_err(|e| DomainError::InvalidInput(format!("CSV parse error: {e}")))?;
|
||||
|
||||
let mood_str = record.get(self.config.mood_column).unwrap_or("").trim();
|
||||
let mood = self.map_mood(mood_str)?;
|
||||
|
||||
let date = record
|
||||
.get(self.config.date_column)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
let time = record
|
||||
.get(self.config.time_column)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let activities = match self.config.activities_column {
|
||||
Some(col) => {
|
||||
let raw = record.get(col).unwrap_or("").trim();
|
||||
if raw.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
raw.split(&self.config.activities_separator)
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
let note = self
|
||||
.config
|
||||
.note_column
|
||||
.and_then(|col| record.get(col))
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
rows.push(ImportedRow {
|
||||
mood,
|
||||
date,
|
||||
time,
|
||||
activities,
|
||||
note,
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!(row_count = rows.len(), "parsed CSV import");
|
||||
Ok(rows)
|
||||
}
|
||||
}
|
||||
69
crates/adapters/importer/src/daylio.rs
Normal file
69
crates/adapters/importer/src/daylio.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::ImportedRow;
|
||||
|
||||
pub struct DaylioImportAdapter;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ImportSourcePort for DaylioImportAdapter {
|
||||
async fn read_entries(&self, data: &[u8]) -> Result<Vec<ImportedRow>, DomainError> {
|
||||
let content = std::str::from_utf8(data)
|
||||
.map_err(|e| DomainError::InvalidInput(format!("invalid UTF-8: {e}")))?;
|
||||
|
||||
let mut reader = csv::ReaderBuilder::new()
|
||||
.has_headers(true)
|
||||
.from_reader(content.as_bytes());
|
||||
|
||||
let mut rows = Vec::new();
|
||||
|
||||
for result in reader.records() {
|
||||
let record =
|
||||
result.map_err(|e| DomainError::InvalidInput(format!("CSV parse error: {e}")))?;
|
||||
|
||||
let mood_str = record.get(4).unwrap_or("").trim();
|
||||
let mood = map_daylio_mood(mood_str)?;
|
||||
|
||||
let date = record.get(0).unwrap_or("").trim().to_string();
|
||||
let time = record.get(3).unwrap_or("").trim().to_string();
|
||||
|
||||
let activities_str = record.get(5).unwrap_or("").trim();
|
||||
let activities = if activities_str.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
activities_str
|
||||
.split('|')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
};
|
||||
|
||||
let note = record
|
||||
.get(7)
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
rows.push(ImportedRow {
|
||||
mood,
|
||||
date,
|
||||
time,
|
||||
activities,
|
||||
note,
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!(row_count = rows.len(), "parsed Daylio export");
|
||||
Ok(rows)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_daylio_mood(mood: &str) -> Result<u8, DomainError> {
|
||||
match mood.to_lowercase().as_str() {
|
||||
"awful" => Ok(1),
|
||||
"bad" => Ok(2),
|
||||
"meh" => Ok(3),
|
||||
"good" => Ok(4),
|
||||
"rad" => Ok(5),
|
||||
other => Err(DomainError::InvalidInput(format!(
|
||||
"unknown Daylio mood: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
139
crates/adapters/importer/src/kmood_zip.rs
Normal file
139
crates/adapters/importer/src/kmood_zip.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Cursor, Read};
|
||||
|
||||
use zip::ZipArchive;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::ImportedRow;
|
||||
|
||||
pub struct KmoodZipImportAdapter;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ExportData {
|
||||
entries: Vec<EntryData>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EntryData {
|
||||
mood: u8,
|
||||
logged_at: String,
|
||||
activities: Vec<String>,
|
||||
content: Option<String>,
|
||||
photos: Vec<String>,
|
||||
voice_memos: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct KmoodImportEntry {
|
||||
pub row: ImportedRow,
|
||||
pub photo_ids: Vec<String>,
|
||||
pub voice_memo_ids: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct KmoodImportResult {
|
||||
pub entries: Vec<KmoodImportEntry>,
|
||||
pub photos: HashMap<String, Vec<u8>>,
|
||||
pub voice_memos: HashMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl KmoodZipImportAdapter {
|
||||
pub fn extract(data: &[u8]) -> Result<KmoodImportResult, DomainError> {
|
||||
let cursor = Cursor::new(data);
|
||||
let mut archive = ZipArchive::new(cursor)
|
||||
.map_err(|e| DomainError::InvalidInput(format!("invalid zip file: {e}")))?;
|
||||
|
||||
let json_data = read_file_from_zip(&mut archive, "data.json")?
|
||||
.ok_or_else(|| DomainError::InvalidInput("missing data.json in archive".into()))?;
|
||||
|
||||
let export: ExportData = serde_json::from_slice(&json_data)
|
||||
.map_err(|e| DomainError::InvalidInput(format!("invalid data.json: {e}")))?;
|
||||
|
||||
let mut photos = HashMap::new();
|
||||
let mut voice_memos = HashMap::new();
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| DomainError::InvalidInput(format!("zip read error: {e}")))?;
|
||||
|
||||
let name = file.name().to_string();
|
||||
|
||||
if let Some(id) = name.strip_prefix("photos/")
|
||||
&& !id.is_empty()
|
||||
{
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf)
|
||||
.map_err(|e| DomainError::InvalidInput(format!("failed to read photo: {e}")))?;
|
||||
photos.insert(id.to_string(), buf);
|
||||
} else if let Some(id) = name.strip_prefix("voice_memos/")
|
||||
&& !id.is_empty()
|
||||
{
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf).map_err(|e| {
|
||||
DomainError::InvalidInput(format!("failed to read voice memo: {e}"))
|
||||
})?;
|
||||
voice_memos.insert(id.to_string(), buf);
|
||||
}
|
||||
}
|
||||
|
||||
let entries: Vec<KmoodImportEntry> = export
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let (date, time) = split_datetime(&e.logged_at);
|
||||
KmoodImportEntry {
|
||||
row: ImportedRow {
|
||||
mood: e.mood,
|
||||
date,
|
||||
time,
|
||||
activities: e.activities,
|
||||
note: e.content,
|
||||
},
|
||||
photo_ids: e.photos,
|
||||
voice_memo_ids: e.voice_memos,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
tracing::info!(
|
||||
entries = entries.len(),
|
||||
photos = photos.len(),
|
||||
voice_memos = voice_memos.len(),
|
||||
"extracted k-mood archive"
|
||||
);
|
||||
|
||||
Ok(KmoodImportResult {
|
||||
entries,
|
||||
photos,
|
||||
voice_memos,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn read_file_from_zip(
|
||||
archive: &mut ZipArchive<Cursor<&[u8]>>,
|
||||
name: &str,
|
||||
) -> Result<Option<Vec<u8>>, DomainError> {
|
||||
let mut file = match archive.by_name(name) {
|
||||
Ok(f) => f,
|
||||
Err(zip::result::ZipError::FileNotFound) => return Ok(None),
|
||||
Err(e) => return Err(DomainError::InvalidInput(format!("zip error: {e}"))),
|
||||
};
|
||||
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf)
|
||||
.map_err(|e| DomainError::InvalidInput(format!("failed to read {name}: {e}")))?;
|
||||
|
||||
Ok(Some(buf))
|
||||
}
|
||||
|
||||
fn split_datetime(rfc3339: &str) -> (String, String) {
|
||||
if let Some(t_pos) = rfc3339.find('T') {
|
||||
let date = rfc3339[..t_pos].to_string();
|
||||
let time = rfc3339[t_pos + 1..].to_string();
|
||||
(date, time)
|
||||
} else {
|
||||
(rfc3339.to_string(), "00:00".to_string())
|
||||
}
|
||||
}
|
||||
7
crates/adapters/importer/src/lib.rs
Normal file
7
crates/adapters/importer/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod csv_generic;
|
||||
mod daylio;
|
||||
mod kmood_zip;
|
||||
|
||||
pub use csv_generic::{CsvImportAdapter, CsvImportConfig};
|
||||
pub use daylio::DaylioImportAdapter;
|
||||
pub use kmood_zip::{KmoodImportEntry, KmoodImportResult, KmoodZipImportAdapter};
|
||||
13
crates/adapters/sqlite/Cargo.toml
Normal file
13
crates/adapters/sqlite/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "sqlite"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
config.workspace = true
|
||||
async-trait.workspace = true
|
||||
sqlx.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
tracing.workspace = true
|
||||
30
crates/adapters/sqlite/src/db.rs
Normal file
30
crates/adapters/sqlite/src/db.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use sqlx::SqlitePool;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
|
||||
const MIGRATIONS: &[&str] = &[
|
||||
include_str!("migrations/001_initial.sql"),
|
||||
include_str!("migrations/002_push_subscriptions.sql"),
|
||||
];
|
||||
|
||||
pub async fn create_pool(database_url: &str) -> Result<SqlitePool, sqlx::Error> {
|
||||
let options: SqliteConnectOptions = database_url
|
||||
.parse::<SqliteConnectOptions>()?
|
||||
.create_if_missing(true)
|
||||
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
|
||||
.foreign_keys(true);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect_with(options)
|
||||
.await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> {
|
||||
for migration in MIGRATIONS {
|
||||
sqlx::raw_sql(*migration).execute(pool).await?;
|
||||
}
|
||||
tracing::info!("database migrations completed");
|
||||
Ok(())
|
||||
}
|
||||
4
crates/adapters/sqlite/src/lib.rs
Normal file
4
crates/adapters/sqlite/src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
mod db;
|
||||
pub mod repositories;
|
||||
|
||||
pub use db::{create_pool, run_migrations};
|
||||
77
crates/adapters/sqlite/src/migrations/001_initial.sql
Normal file
77
crates/adapters/sqlite/src/migrations/001_initial.sql
Normal file
@@ -0,0 +1,77 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
timezone TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'User',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
category TEXT,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mood_entries (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
mood INTEGER NOT NULL,
|
||||
logged_at TEXT NOT NULL,
|
||||
content TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_activities (
|
||||
entry_id TEXT NOT NULL REFERENCES mood_entries(id) ON DELETE CASCADE,
|
||||
activity_id TEXT NOT NULL REFERENCES activities(id),
|
||||
PRIMARY KEY (entry_id, activity_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_photos (
|
||||
entry_id TEXT NOT NULL REFERENCES mood_entries(id) ON DELETE CASCADE,
|
||||
photo_id TEXT NOT NULL,
|
||||
PRIMARY KEY (entry_id, photo_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_voice_memos (
|
||||
entry_id TEXT NOT NULL REFERENCES mood_entries(id) ON DELETE CASCADE,
|
||||
voice_memo_id TEXT NOT NULL,
|
||||
PRIMARY KEY (entry_id, voice_memo_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reminders (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
monday TEXT,
|
||||
tuesday TEXT,
|
||||
wednesday TEXT,
|
||||
thursday TEXT,
|
||||
friday TEXT,
|
||||
saturday TEXT,
|
||||
sunday TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_sessions (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_user_id ON activities(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mood_entries_user_id ON mood_entries(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mood_entries_logged_at ON mood_entries(logged_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_reminders_user_id ON reminders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_token ON refresh_sessions(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user_id ON refresh_sessions(user_id);
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(user_id);
|
||||
58
crates/adapters/sqlite/src/repositories/activity/command.rs
Normal file
58
crates/adapters/sqlite/src/repositories/activity/command.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::activity::{Activity, ActivityId};
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteActivityCommandRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteActivityCommandRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ActivityCommandPort for SqliteActivityCommandRepository {
|
||||
async fn save(&self, activity: &Activity) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO activities (id, user_id, name, category, archived, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name, category = excluded.category,
|
||||
archived = excluded.archived",
|
||||
)
|
||||
.bind(activity.id().value().to_string())
|
||||
.bind(activity.user_id().value().to_string())
|
||||
.bind(activity.name().value())
|
||||
.bind(activity.category().map(|c| c.value().to_string()))
|
||||
.bind(activity.is_archived())
|
||||
.bind(activity.created_at().to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &ActivityId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM activities WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM activities WHERE user_id = ?")
|
||||
.bind(user_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
6
crates/adapters/sqlite/src/repositories/activity/mod.rs
Normal file
6
crates/adapters/sqlite/src/repositories/activity/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod command;
|
||||
mod query;
|
||||
mod rows;
|
||||
|
||||
pub use command::SqliteActivityCommandRepository;
|
||||
pub use query::SqliteActivityQueryRepository;
|
||||
52
crates/adapters/sqlite/src/repositories/activity/query.rs
Normal file
52
crates/adapters/sqlite/src/repositories/activity/query.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::activity::{Activity, ActivityId};
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::rows::ActivityRow;
|
||||
|
||||
pub struct SqliteActivityQueryRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteActivityQueryRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ActivityQueryPort for SqliteActivityQueryRepository {
|
||||
async fn find_by_id(&self, id: &ActivityId) -> Result<Option<Activity>, DomainError> {
|
||||
let row = sqlx::query_as::<_, ActivityRow>("SELECT * FROM activities WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(row.map(ActivityRow::into_domain))
|
||||
}
|
||||
|
||||
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<Activity>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, ActivityRow>(
|
||||
"SELECT * FROM activities WHERE user_id = ? ORDER BY name",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(rows.into_iter().map(ActivityRow::into_domain).collect())
|
||||
}
|
||||
|
||||
async fn find_active_by_user(&self, user_id: &UserId) -> Result<Vec<Activity>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, ActivityRow>(
|
||||
"SELECT * FROM activities WHERE user_id = ? AND archived = 0 ORDER BY name",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(rows.into_iter().map(ActivityRow::into_domain).collect())
|
||||
}
|
||||
}
|
||||
25
crates/adapters/sqlite/src/repositories/activity/rows.rs
Normal file
25
crates/adapters/sqlite/src/repositories/activity/rows.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use domain::activity::{Activity, ActivityId, ActivityName, CategoryName};
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct ActivityRow {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub name: String,
|
||||
pub category: Option<String>,
|
||||
pub archived: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl ActivityRow {
|
||||
pub fn into_domain(self) -> Activity {
|
||||
Activity::from_persistence(
|
||||
ActivityId::from_uuid(self.id.parse().unwrap()),
|
||||
UserId::from_uuid(self.user_id.parse().unwrap()),
|
||||
ActivityName::from_persistence(self.name),
|
||||
self.category.map(CategoryName::from_persistence),
|
||||
self.archived,
|
||||
self.created_at.parse().unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
127
crates/adapters/sqlite/src/repositories/cascade/mod.rs
Normal file
127
crates/adapters/sqlite/src/repositories/cascade/mod.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::entry::{DateRange, MoodEntry};
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::entry::rows::{EntryRow, hydrate_batch};
|
||||
use super::shared::db_err;
|
||||
|
||||
pub struct SqliteCascadeDeleteRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteCascadeDeleteRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::CascadeDeletePort for SqliteCascadeDeleteRepository {
|
||||
async fn delete_all_user_data(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let mut tx = self.pool.begin().await.map_err(db_err)?;
|
||||
|
||||
sqlx::query("DELETE FROM mood_entries WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
sqlx::query("DELETE FROM activities WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
sqlx::query("DELETE FROM reminders WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_user_account(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let mut tx = self.pool.begin().await.map_err(db_err)?;
|
||||
|
||||
sqlx::query("DELETE FROM mood_entries WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
sqlx::query("DELETE FROM activities WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
sqlx::query("DELETE FROM reminders WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
sqlx::query("DELETE FROM refresh_sessions WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
sqlx::query("DELETE FROM push_subscriptions WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
sqlx::query("DELETE FROM users WHERE id = ?")
|
||||
.bind(&uid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_entries_in_range(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
range: &DateRange,
|
||||
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
let rows = sqlx::query_as::<_, EntryRow>(
|
||||
"SELECT * FROM mood_entries WHERE user_id = ? AND logged_at >= ? AND logged_at <= ? ORDER BY logged_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(range.start().to_rfc3339())
|
||||
.bind(range.end().to_rfc3339())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
let entries = hydrate_batch(&self.pool, rows).await?;
|
||||
|
||||
let mut tx = self.pool.begin().await.map_err(db_err)?;
|
||||
|
||||
sqlx::query(
|
||||
"DELETE FROM mood_entries WHERE user_id = ? AND logged_at >= ? AND logged_at <= ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(range.start().to_rfc3339())
|
||||
.bind(range.end().to_rfc3339())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
}
|
||||
203
crates/adapters/sqlite/src/repositories/entry/command.rs
Normal file
203
crates/adapters/sqlite/src/repositories/entry/command.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::entry::{DateRange, MoodEntry, MoodEntryId};
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteEntryCommandRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteEntryCommandRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
async fn save_relations(&self, entry: &MoodEntry) -> Result<(), DomainError> {
|
||||
let entry_id = entry.id().value().to_string();
|
||||
|
||||
sqlx::query("DELETE FROM entry_activities WHERE entry_id = ?")
|
||||
.bind(&entry_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
for activity_id in entry.activities() {
|
||||
sqlx::query("INSERT INTO entry_activities (entry_id, activity_id) VALUES (?, ?)")
|
||||
.bind(&entry_id)
|
||||
.bind(activity_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM entry_photos WHERE entry_id = ?")
|
||||
.bind(&entry_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
for photo_id in entry.photos() {
|
||||
sqlx::query("INSERT INTO entry_photos (entry_id, photo_id) VALUES (?, ?)")
|
||||
.bind(&entry_id)
|
||||
.bind(photo_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM entry_voice_memos WHERE entry_id = ?")
|
||||
.bind(&entry_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
for voice_memo_id in entry.voice_memos() {
|
||||
sqlx::query("INSERT INTO entry_voice_memos (entry_id, voice_memo_id) VALUES (?, ?)")
|
||||
.bind(&entry_id)
|
||||
.bind(voice_memo_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::MoodEntryCommandPort for SqliteEntryCommandRepository {
|
||||
async fn save(&self, entry: &MoodEntry) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO mood_entries (id, user_id, mood, logged_at, content, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
mood = excluded.mood, logged_at = excluded.logged_at,
|
||||
content = excluded.content, updated_at = excluded.updated_at"
|
||||
)
|
||||
.bind(entry.id().value().to_string())
|
||||
.bind(entry.user_id().value().to_string())
|
||||
.bind(entry.mood().value() as i32)
|
||||
.bind(entry.logged_at().to_rfc3339())
|
||||
.bind(entry.content().map(|c| c.value().to_string()))
|
||||
.bind(entry.created_at().to_rfc3339())
|
||||
.bind(entry.updated_at().to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
self.save_relations(entry).await
|
||||
}
|
||||
|
||||
async fn save_batch(&self, entries: &[MoodEntry]) -> Result<(), DomainError> {
|
||||
let mut tx = self.pool.begin().await.map_err(db_err)?;
|
||||
|
||||
for entry in entries {
|
||||
let entry_id = entry.id().value().to_string();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO mood_entries (id, user_id, mood, logged_at, content, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
mood = excluded.mood, logged_at = excluded.logged_at,
|
||||
content = excluded.content, updated_at = excluded.updated_at"
|
||||
)
|
||||
.bind(&entry_id)
|
||||
.bind(entry.user_id().value().to_string())
|
||||
.bind(entry.mood().value() as i32)
|
||||
.bind(entry.logged_at().to_rfc3339())
|
||||
.bind(entry.content().map(|c| c.value().to_string()))
|
||||
.bind(entry.created_at().to_rfc3339())
|
||||
.bind(entry.updated_at().to_rfc3339())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
for activity_id in entry.activities() {
|
||||
sqlx::query("INSERT INTO entry_activities (entry_id, activity_id) VALUES (?, ?)")
|
||||
.bind(&entry_id)
|
||||
.bind(activity_id.value().to_string())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
|
||||
for photo_id in entry.photos() {
|
||||
sqlx::query("INSERT INTO entry_photos (entry_id, photo_id) VALUES (?, ?)")
|
||||
.bind(&entry_id)
|
||||
.bind(photo_id.value().to_string())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
|
||||
for voice_memo_id in entry.voice_memos() {
|
||||
sqlx::query(
|
||||
"INSERT INTO entry_voice_memos (entry_id, voice_memo_id) VALUES (?, ?)",
|
||||
)
|
||||
.bind(&entry_id)
|
||||
.bind(voice_memo_id.value().to_string())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &MoodEntryId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM mood_entries WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM mood_entries WHERE user_id = ?")
|
||||
.bind(user_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_by_date_range(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
range: &DateRange,
|
||||
) -> Result<u64, DomainError> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM mood_entries WHERE user_id = ? AND logged_at >= ? AND logged_at <= ?",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.bind(range.start().to_rfc3339())
|
||||
.bind(range.end().to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn replace_activity(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
old_activity_id: &ActivityId,
|
||||
new_activity_id: &ActivityId,
|
||||
) -> Result<u64, DomainError> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE entry_activities SET activity_id = ?
|
||||
WHERE activity_id = ? AND entry_id IN (SELECT id FROM mood_entries WHERE user_id = ?)",
|
||||
)
|
||||
.bind(new_activity_id.value().to_string())
|
||||
.bind(old_activity_id.value().to_string())
|
||||
.bind(user_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
6
crates/adapters/sqlite/src/repositories/entry/mod.rs
Normal file
6
crates/adapters/sqlite/src/repositories/entry/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod command;
|
||||
mod query;
|
||||
pub(crate) mod rows;
|
||||
|
||||
pub use command::SqliteEntryCommandRepository;
|
||||
pub use query::SqliteEntryQueryRepository;
|
||||
110
crates/adapters/sqlite/src/repositories/entry/query.rs
Normal file
110
crates/adapters/sqlite/src/repositories/entry/query.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::entry::{DateRange, Mood, MoodEntry, MoodEntryId};
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::rows::{EntryRow, hydrate_batch, hydrate_single};
|
||||
|
||||
pub struct SqliteEntryQueryRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteEntryQueryRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::MoodEntryQueryPort for SqliteEntryQueryRepository {
|
||||
async fn find_by_id(&self, id: &MoodEntryId) -> Result<Option<MoodEntry>, DomainError> {
|
||||
let row = sqlx::query_as::<_, EntryRow>("SELECT * FROM mood_entries WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Some(hydrate_single(&self.pool, r).await?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn find_by_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
let limit = limit.unwrap_or(i64::MAX);
|
||||
let offset = offset.unwrap_or(0);
|
||||
|
||||
let rows = sqlx::query_as::<_, EntryRow>(
|
||||
"SELECT * FROM mood_entries WHERE user_id = ? ORDER BY logged_at DESC LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
hydrate_batch(&self.pool, rows).await
|
||||
}
|
||||
|
||||
async fn find_by_date_range(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
range: &DateRange,
|
||||
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, EntryRow>(
|
||||
"SELECT * FROM mood_entries WHERE user_id = ? AND logged_at >= ? AND logged_at <= ? ORDER BY logged_at DESC",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.bind(range.start().to_rfc3339())
|
||||
.bind(range.end().to_rfc3339())
|
||||
.fetch_all(&self.pool).await.map_err(db_err)?;
|
||||
|
||||
hydrate_batch(&self.pool, rows).await
|
||||
}
|
||||
|
||||
async fn find_by_mood(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
mood: Mood,
|
||||
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, EntryRow>(
|
||||
"SELECT * FROM mood_entries WHERE user_id = ? AND mood = ? ORDER BY logged_at DESC",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.bind(mood.value() as i32)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
hydrate_batch(&self.pool, rows).await
|
||||
}
|
||||
|
||||
async fn find_by_activity(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
activity_id: &ActivityId,
|
||||
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, EntryRow>(
|
||||
"SELECT me.* FROM mood_entries me
|
||||
INNER JOIN entry_activities ea ON ea.entry_id = me.id
|
||||
WHERE me.user_id = ? AND ea.activity_id = ?
|
||||
ORDER BY me.logged_at DESC",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.bind(activity_id.value().to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
hydrate_batch(&self.pool, rows).await
|
||||
}
|
||||
}
|
||||
153
crates/adapters/sqlite/src/repositories/entry/rows.rs
Normal file
153
crates/adapters/sqlite/src/repositories/entry/rows.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::attachment::{PhotoId, VoiceMemoId};
|
||||
use domain::entry::{Content, Mood, MoodEntry, MoodEntryData, MoodEntryId};
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct EntryRow {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub mood: i32,
|
||||
pub logged_at: String,
|
||||
pub content: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RelationRow {
|
||||
entry_id: String,
|
||||
related_id: String,
|
||||
}
|
||||
|
||||
pub fn row_to_entry(
|
||||
row: EntryRow,
|
||||
activity_ids: Vec<String>,
|
||||
photo_ids: Vec<String>,
|
||||
voice_memo_ids: Vec<String>,
|
||||
) -> Result<MoodEntry, DomainError> {
|
||||
Ok(MoodEntry::from_persistence(MoodEntryData {
|
||||
id: MoodEntryId::from_uuid(row.id.parse().unwrap()),
|
||||
user_id: UserId::from_uuid(row.user_id.parse().unwrap()),
|
||||
mood: Mood::try_from(row.mood as u8)?,
|
||||
logged_at: row.logged_at.parse().unwrap(),
|
||||
activities: activity_ids
|
||||
.into_iter()
|
||||
.map(|id| ActivityId::from_uuid(id.parse().unwrap()))
|
||||
.collect(),
|
||||
content: row.content.map(Content::from_persistence),
|
||||
photos: photo_ids
|
||||
.into_iter()
|
||||
.map(|id| PhotoId::from_uuid(id.parse().unwrap()))
|
||||
.collect(),
|
||||
voice_memos: voice_memo_ids
|
||||
.into_iter()
|
||||
.map(|id| VoiceMemoId::from_uuid(id.parse().unwrap()))
|
||||
.collect(),
|
||||
created_at: row.created_at.parse().unwrap(),
|
||||
updated_at: row.updated_at.parse().unwrap(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn hydrate_single(pool: &SqlitePool, row: EntryRow) -> Result<MoodEntry, DomainError> {
|
||||
let entry_id = row.id.clone();
|
||||
|
||||
let activities: Vec<RelationRow> = sqlx::query_as(
|
||||
"SELECT entry_id, activity_id AS related_id FROM entry_activities WHERE entry_id = ?",
|
||||
)
|
||||
.bind(&entry_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
let photos: Vec<RelationRow> = sqlx::query_as(
|
||||
"SELECT entry_id, photo_id AS related_id FROM entry_photos WHERE entry_id = ?",
|
||||
)
|
||||
.bind(&entry_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
let voice_memos: Vec<RelationRow> = sqlx::query_as(
|
||||
"SELECT entry_id, voice_memo_id AS related_id FROM entry_voice_memos WHERE entry_id = ?",
|
||||
)
|
||||
.bind(&entry_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
row_to_entry(
|
||||
row,
|
||||
activities.into_iter().map(|r| r.related_id).collect(),
|
||||
photos.into_iter().map(|r| r.related_id).collect(),
|
||||
voice_memos.into_iter().map(|r| r.related_id).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn hydrate_batch(
|
||||
pool: &SqlitePool,
|
||||
rows: Vec<EntryRow>,
|
||||
) -> Result<Vec<MoodEntry>, DomainError> {
|
||||
if rows.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let entry_ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
|
||||
let placeholders = vec!["?"; entry_ids.len()].join(",");
|
||||
|
||||
let activities = batch_load(
|
||||
pool,
|
||||
&format!("SELECT entry_id, activity_id AS related_id FROM entry_activities WHERE entry_id IN ({placeholders})"),
|
||||
&entry_ids,
|
||||
).await?;
|
||||
|
||||
let photos = batch_load(
|
||||
pool,
|
||||
&format!("SELECT entry_id, photo_id AS related_id FROM entry_photos WHERE entry_id IN ({placeholders})"),
|
||||
&entry_ids,
|
||||
).await?;
|
||||
|
||||
let voice_memos = batch_load(
|
||||
pool,
|
||||
&format!("SELECT entry_id, voice_memo_id AS related_id FROM entry_voice_memos WHERE entry_id IN ({placeholders})"),
|
||||
&entry_ids,
|
||||
).await?;
|
||||
|
||||
let mut entries = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let id = row.id.clone();
|
||||
entries.push(row_to_entry(
|
||||
row,
|
||||
activities.get(&id).cloned().unwrap_or_default(),
|
||||
photos.get(&id).cloned().unwrap_or_default(),
|
||||
voice_memos.get(&id).cloned().unwrap_or_default(),
|
||||
)?);
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
async fn batch_load(
|
||||
pool: &SqlitePool,
|
||||
sql: &str,
|
||||
entry_ids: &[String],
|
||||
) -> Result<HashMap<String, Vec<String>>, DomainError> {
|
||||
let mut query = sqlx::query_as::<_, RelationRow>(sqlx::AssertSqlSafe(sql));
|
||||
for id in entry_ids {
|
||||
query = query.bind(id);
|
||||
}
|
||||
|
||||
let rows = query.fetch_all(pool).await.map_err(db_err)?;
|
||||
|
||||
let mut map: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for row in rows {
|
||||
map.entry(row.entry_id).or_default().push(row.related_id);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
21
crates/adapters/sqlite/src/repositories/mod.rs
Normal file
21
crates/adapters/sqlite/src/repositories/mod.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
pub mod shared;
|
||||
|
||||
mod activity;
|
||||
mod cascade;
|
||||
mod entry;
|
||||
mod push_subscription;
|
||||
mod refresh_session;
|
||||
mod reminder;
|
||||
mod user;
|
||||
|
||||
pub use activity::{SqliteActivityCommandRepository, SqliteActivityQueryRepository};
|
||||
pub use cascade::SqliteCascadeDeleteRepository;
|
||||
pub use entry::{SqliteEntryCommandRepository, SqliteEntryQueryRepository};
|
||||
pub use push_subscription::{
|
||||
SqlitePushSubscriptionCommandRepository, SqlitePushSubscriptionQueryRepository,
|
||||
};
|
||||
pub use refresh_session::{
|
||||
SqliteRefreshSessionCommandRepository, SqliteRefreshSessionQueryRepository,
|
||||
};
|
||||
pub use reminder::{SqliteReminderCommandRepository, SqliteReminderQueryRepository};
|
||||
pub use user::{SqliteUserCommandRepository, SqliteUserQueryRepository};
|
||||
@@ -0,0 +1,68 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::push::{PushSubscription, PushSubscriptionId};
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqlitePushSubscriptionCommandRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqlitePushSubscriptionCommandRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::PushSubscriptionCommandPort for SqlitePushSubscriptionCommandRepository {
|
||||
async fn save(&self, sub: &PushSubscription) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO push_subscriptions (id, user_id, endpoint, p256dh, auth, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth",
|
||||
)
|
||||
.bind(sub.id().value().to_string())
|
||||
.bind(sub.user_id().value().to_string())
|
||||
.bind(sub.endpoint())
|
||||
.bind(sub.p256dh())
|
||||
.bind(sub.auth())
|
||||
.bind(sub.created_at().to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &PushSubscriptionId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM push_subscriptions WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_by_endpoint(&self, endpoint: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM push_subscriptions WHERE endpoint = ?")
|
||||
.bind(endpoint)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM push_subscriptions WHERE user_id = ?")
|
||||
.bind(user_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod command;
|
||||
mod query;
|
||||
mod rows;
|
||||
|
||||
pub use command::SqlitePushSubscriptionCommandRepository;
|
||||
pub use query::SqlitePushSubscriptionQueryRepository;
|
||||
@@ -0,0 +1,48 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::push::PushSubscription;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::rows::PushSubscriptionRow;
|
||||
|
||||
pub struct SqlitePushSubscriptionQueryRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqlitePushSubscriptionQueryRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::PushSubscriptionQueryPort for SqlitePushSubscriptionQueryRepository {
|
||||
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<PushSubscription>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, PushSubscriptionRow>(
|
||||
"SELECT * FROM push_subscriptions WHERE user_id = ?",
|
||||
)
|
||||
.bind(user_id.value().to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| r.into_entity()).collect())
|
||||
}
|
||||
|
||||
async fn find_by_endpoint(
|
||||
&self,
|
||||
endpoint: &str,
|
||||
) -> Result<Option<PushSubscription>, DomainError> {
|
||||
let row = sqlx::query_as::<_, PushSubscriptionRow>(
|
||||
"SELECT * FROM push_subscriptions WHERE endpoint = ?",
|
||||
)
|
||||
.bind(endpoint)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(row.map(|r| r.into_entity()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use domain::push::{PushSubscription, PushSubscriptionId};
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct PushSubscriptionRow {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub endpoint: String,
|
||||
pub p256dh: String,
|
||||
pub auth: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl PushSubscriptionRow {
|
||||
pub fn into_entity(self) -> PushSubscription {
|
||||
PushSubscription::from_persistence(
|
||||
PushSubscriptionId::from_uuid(self.id.parse().unwrap()),
|
||||
UserId::from_uuid(self.user_id.parse().unwrap()),
|
||||
self.endpoint,
|
||||
self.p256dh,
|
||||
self.auth,
|
||||
self.created_at.parse().unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::auth::RefreshSession;
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteRefreshSessionCommandRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteRefreshSessionCommandRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::RefreshSessionCommandPort for SqliteRefreshSessionCommandRepository {
|
||||
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO refresh_sessions (id, user_id, token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(session.id().value().to_string())
|
||||
.bind(session.user_id().value().to_string())
|
||||
.bind(session.token())
|
||||
.bind(session.expires_at().to_rfc3339())
|
||||
.bind(session.created_at().to_rfc3339())
|
||||
.execute(&self.pool).await.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke(&self, token: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM refresh_sessions WHERE token = ?")
|
||||
.bind(token)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM refresh_sessions WHERE user_id = ?")
|
||||
.bind(user_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
let result = sqlx::query("DELETE FROM refresh_sessions WHERE expires_at < ?")
|
||||
.bind(chrono::Utc::now().to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod command;
|
||||
mod query;
|
||||
mod rows;
|
||||
|
||||
pub use command::SqliteRefreshSessionCommandRepository;
|
||||
pub use query::SqliteRefreshSessionQueryRepository;
|
||||
@@ -0,0 +1,31 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::auth::RefreshSession;
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::rows::RefreshSessionRow;
|
||||
|
||||
pub struct SqliteRefreshSessionQueryRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteRefreshSessionQueryRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::RefreshSessionQueryPort for SqliteRefreshSessionQueryRepository {
|
||||
async fn find_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError> {
|
||||
let row = sqlx::query_as::<_, RefreshSessionRow>(
|
||||
"SELECT * FROM refresh_sessions WHERE token = ?",
|
||||
)
|
||||
.bind(token)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(row.map(RefreshSessionRow::into_domain))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use domain::auth::{RefreshSession, RefreshSessionId};
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct RefreshSessionRow {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub token: String,
|
||||
pub expires_at: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl RefreshSessionRow {
|
||||
pub fn into_domain(self) -> RefreshSession {
|
||||
RefreshSession::from_persistence(
|
||||
RefreshSessionId::from_uuid(self.id.parse().unwrap()),
|
||||
UserId::from_uuid(self.user_id.parse().unwrap()),
|
||||
self.token,
|
||||
self.expires_at.parse().unwrap(),
|
||||
self.created_at.parse().unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
65
crates/adapters/sqlite/src/repositories/reminder/command.rs
Normal file
65
crates/adapters/sqlite/src/repositories/reminder/command.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use chrono::Weekday;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::reminder::{Reminder, ReminderId};
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::rows::format_time;
|
||||
|
||||
pub struct SqliteReminderCommandRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteReminderCommandRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ReminderCommandPort for SqliteReminderCommandRepository {
|
||||
async fn save(&self, reminder: &Reminder) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO reminders (id, user_id, monday, tuesday, wednesday, thursday, friday, saturday, sunday, enabled, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
monday = excluded.monday, tuesday = excluded.tuesday,
|
||||
wednesday = excluded.wednesday, thursday = excluded.thursday,
|
||||
friday = excluded.friday, saturday = excluded.saturday,
|
||||
sunday = excluded.sunday, enabled = excluded.enabled"
|
||||
)
|
||||
.bind(reminder.id().value().to_string())
|
||||
.bind(reminder.user_id().value().to_string())
|
||||
.bind(reminder.schedule().time_for(Weekday::Mon).map(format_time))
|
||||
.bind(reminder.schedule().time_for(Weekday::Tue).map(format_time))
|
||||
.bind(reminder.schedule().time_for(Weekday::Wed).map(format_time))
|
||||
.bind(reminder.schedule().time_for(Weekday::Thu).map(format_time))
|
||||
.bind(reminder.schedule().time_for(Weekday::Fri).map(format_time))
|
||||
.bind(reminder.schedule().time_for(Weekday::Sat).map(format_time))
|
||||
.bind(reminder.schedule().time_for(Weekday::Sun).map(format_time))
|
||||
.bind(reminder.is_enabled())
|
||||
.bind(reminder.created_at().to_rfc3339())
|
||||
.execute(&self.pool).await.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &ReminderId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM reminders WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM reminders WHERE user_id = ?")
|
||||
.bind(user_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
6
crates/adapters/sqlite/src/repositories/reminder/mod.rs
Normal file
6
crates/adapters/sqlite/src/repositories/reminder/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod command;
|
||||
mod query;
|
||||
mod rows;
|
||||
|
||||
pub use command::SqliteReminderCommandRepository;
|
||||
pub use query::SqliteReminderQueryRepository;
|
||||
47
crates/adapters/sqlite/src/repositories/reminder/query.rs
Normal file
47
crates/adapters/sqlite/src/repositories/reminder/query.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::reminder::{Reminder, ReminderId};
|
||||
use domain::user::UserId;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::rows::ReminderRow;
|
||||
|
||||
pub struct SqliteReminderQueryRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteReminderQueryRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ReminderQueryPort for SqliteReminderQueryRepository {
|
||||
async fn find_by_id(&self, id: &ReminderId) -> Result<Option<Reminder>, DomainError> {
|
||||
let row = sqlx::query_as::<_, ReminderRow>("SELECT * FROM reminders WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(row.map(ReminderRow::into_domain))
|
||||
}
|
||||
|
||||
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<Reminder>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, ReminderRow>("SELECT * FROM reminders WHERE user_id = ?")
|
||||
.bind(user_id.value().to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(rows.into_iter().map(ReminderRow::into_domain).collect())
|
||||
}
|
||||
|
||||
async fn find_all_enabled(&self) -> Result<Vec<Reminder>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, ReminderRow>("SELECT * FROM reminders WHERE enabled = 1")
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(rows.into_iter().map(ReminderRow::into_domain).collect())
|
||||
}
|
||||
}
|
||||
49
crates/adapters/sqlite/src/repositories/reminder/rows.rs
Normal file
49
crates/adapters/sqlite/src/repositories/reminder/rows.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use chrono::NaiveTime;
|
||||
|
||||
use domain::reminder::{DaySchedule, Reminder, ReminderId};
|
||||
use domain::user::UserId;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct ReminderRow {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub monday: Option<String>,
|
||||
pub tuesday: Option<String>,
|
||||
pub wednesday: Option<String>,
|
||||
pub thursday: Option<String>,
|
||||
pub friday: Option<String>,
|
||||
pub saturday: Option<String>,
|
||||
pub sunday: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl ReminderRow {
|
||||
pub fn into_domain(self) -> Reminder {
|
||||
let schedule = DaySchedule::from_persistence(
|
||||
self.monday.and_then(|s| parse_time(&s)),
|
||||
self.tuesday.and_then(|s| parse_time(&s)),
|
||||
self.wednesday.and_then(|s| parse_time(&s)),
|
||||
self.thursday.and_then(|s| parse_time(&s)),
|
||||
self.friday.and_then(|s| parse_time(&s)),
|
||||
self.saturday.and_then(|s| parse_time(&s)),
|
||||
self.sunday.and_then(|s| parse_time(&s)),
|
||||
);
|
||||
|
||||
Reminder::from_persistence(
|
||||
ReminderId::from_uuid(self.id.parse().unwrap()),
|
||||
UserId::from_uuid(self.user_id.parse().unwrap()),
|
||||
schedule,
|
||||
self.enabled,
|
||||
self.created_at.parse().unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_time(s: &str) -> Option<NaiveTime> {
|
||||
NaiveTime::parse_from_str(s, "%H:%M").ok()
|
||||
}
|
||||
|
||||
pub fn format_time(t: NaiveTime) -> String {
|
||||
t.format("%H:%M").to_string()
|
||||
}
|
||||
5
crates/adapters/sqlite/src/repositories/shared.rs
Normal file
5
crates/adapters/sqlite/src/repositories/shared.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use domain::errors::DomainError;
|
||||
|
||||
pub fn db_err(e: sqlx::Error) -> DomainError {
|
||||
DomainError::InvalidInput(format!("database error: {e}"))
|
||||
}
|
||||
53
crates/adapters/sqlite/src/repositories/user/command.rs
Normal file
53
crates/adapters/sqlite/src/repositories/user/command.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::{User, UserId};
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteUserCommandRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteUserCommandRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::UserCommandPort for SqliteUserCommandRepository {
|
||||
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, username, email, password_hash, display_name, timezone, role, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
username = excluded.username, email = excluded.email,
|
||||
password_hash = excluded.password_hash, display_name = excluded.display_name,
|
||||
timezone = excluded.timezone, role = excluded.role,
|
||||
updated_at = excluded.updated_at"
|
||||
)
|
||||
.bind(user.id().value().to_string())
|
||||
.bind(user.username().value())
|
||||
.bind(user.email().value())
|
||||
.bind(user.password_hash().value())
|
||||
.bind(user.display_name().map(|d| d.value().to_string()))
|
||||
.bind(user.timezone().map(|t| t.value().to_string()))
|
||||
.bind(format!("{:?}", user.role()))
|
||||
.bind(user.created_at().to_rfc3339())
|
||||
.bind(user.updated_at().to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM users WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
6
crates/adapters/sqlite/src/repositories/user/mod.rs
Normal file
6
crates/adapters/sqlite/src/repositories/user/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod command;
|
||||
mod query;
|
||||
mod rows;
|
||||
|
||||
pub use command::SqliteUserCommandRepository;
|
||||
pub use query::SqliteUserQueryRepository;
|
||||
47
crates/adapters/sqlite/src/repositories/user/query.rs
Normal file
47
crates/adapters/sqlite/src/repositories/user/query.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::user::{Email, User, UserId, Username};
|
||||
|
||||
use super::super::shared::db_err;
|
||||
use super::rows::UserRow;
|
||||
|
||||
pub struct SqliteUserQueryRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteUserQueryRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::UserQueryPort for SqliteUserQueryRepository {
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
let row = sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(id.value().to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(row.map(UserRow::into_domain))
|
||||
}
|
||||
|
||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||
let row = sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE username = ?")
|
||||
.bind(username.value())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(row.map(UserRow::into_domain))
|
||||
}
|
||||
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||
let row = sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE email = ?")
|
||||
.bind(email.value())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(row.map(UserRow::into_domain))
|
||||
}
|
||||
}
|
||||
37
crates/adapters/sqlite/src/repositories/user/rows.rs
Normal file
37
crates/adapters/sqlite/src/repositories/user/rows.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use domain::user::{
|
||||
DisplayName, Email, PasswordHash, Timezone, User, UserData, UserId, UserRole, Username,
|
||||
};
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct UserRow {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub display_name: Option<String>,
|
||||
pub timezone: Option<String>,
|
||||
pub role: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl UserRow {
|
||||
pub fn into_domain(self) -> User {
|
||||
let role = match self.role.as_str() {
|
||||
"Admin" => UserRole::Admin,
|
||||
_ => UserRole::User,
|
||||
};
|
||||
|
||||
User::from_persistence(UserData {
|
||||
id: UserId::from_uuid(self.id.parse().unwrap()),
|
||||
username: Username::from_persistence(self.username),
|
||||
email: Email::from_persistence(self.email),
|
||||
password_hash: PasswordHash::new(self.password_hash),
|
||||
display_name: self.display_name.map(DisplayName::from_persistence),
|
||||
timezone: self.timezone.map(Timezone::from_persistence),
|
||||
role,
|
||||
created_at: self.created_at.parse().unwrap(),
|
||||
updated_at: self.updated_at.parse().unwrap(),
|
||||
})
|
||||
}
|
||||
}
|
||||
14
crates/adapters/storage/Cargo.toml
Normal file
14
crates/adapters/storage/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "storage"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
config.workspace = true
|
||||
async-trait.workspace = true
|
||||
uuid.workspace = true
|
||||
object_store.workspace = true
|
||||
bytes.workspace = true
|
||||
futures-util = "0.3"
|
||||
tracing.workspace = true
|
||||
30
crates/adapters/storage/src/lib.rs
Normal file
30
crates/adapters/storage/src/lib.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
mod media_storage;
|
||||
|
||||
pub use media_storage::ObjectStoreMediaStorage;
|
||||
|
||||
use config::{MediaBackend, StorageConfig};
|
||||
use domain::errors::DomainError;
|
||||
|
||||
pub fn create_media_storage(
|
||||
config: &StorageConfig,
|
||||
) -> Result<ObjectStoreMediaStorage, DomainError> {
|
||||
match &config.media {
|
||||
MediaBackend::Local { media_dir } => {
|
||||
let base = std::path::Path::new(&config.data_dir).join(media_dir);
|
||||
ObjectStoreMediaStorage::local(base)
|
||||
}
|
||||
MediaBackend::S3 {
|
||||
bucket,
|
||||
region,
|
||||
endpoint,
|
||||
access_key,
|
||||
secret_key,
|
||||
} => ObjectStoreMediaStorage::s3(
|
||||
bucket,
|
||||
region,
|
||||
endpoint.as_deref(),
|
||||
access_key.as_deref(),
|
||||
secret_key.as_deref(),
|
||||
),
|
||||
}
|
||||
}
|
||||
177
crates/adapters/storage/src/media_storage.rs
Normal file
177
crates/adapters/storage/src/media_storage.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use object_store::aws::AmazonS3Builder;
|
||||
use object_store::local::LocalFileSystem;
|
||||
use object_store::path::Path;
|
||||
use object_store::{GetOptions, ObjectStore, PutOptions, PutPayload};
|
||||
|
||||
use domain::attachment::{ContentType, MediaUpload, PhotoId, VoiceMemoId};
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::MediaFile;
|
||||
|
||||
pub struct ObjectStoreMediaStorage {
|
||||
store: Arc<dyn ObjectStore>,
|
||||
}
|
||||
|
||||
impl ObjectStoreMediaStorage {
|
||||
pub fn local(base_path: std::path::PathBuf) -> Result<Self, DomainError> {
|
||||
std::fs::create_dir_all(&base_path).map_err(|e| {
|
||||
DomainError::InvalidInput(format!("failed to create media directory: {e}"))
|
||||
})?;
|
||||
|
||||
let store = LocalFileSystem::new_with_prefix(base_path)
|
||||
.map_err(|e| DomainError::InvalidInput(format!("failed to init local storage: {e}")))?;
|
||||
|
||||
Ok(Self {
|
||||
store: Arc::new(store),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn s3(
|
||||
bucket: &str,
|
||||
region: &str,
|
||||
endpoint: Option<&str>,
|
||||
access_key: Option<&str>,
|
||||
secret_key: Option<&str>,
|
||||
) -> Result<Self, DomainError> {
|
||||
let mut builder = AmazonS3Builder::new()
|
||||
.with_bucket_name(bucket)
|
||||
.with_region(region);
|
||||
|
||||
if let Some(endpoint) = endpoint {
|
||||
builder = builder
|
||||
.with_endpoint(endpoint)
|
||||
.with_virtual_hosted_style_request(false);
|
||||
}
|
||||
if let Some(key) = access_key {
|
||||
builder = builder.with_access_key_id(key);
|
||||
}
|
||||
if let Some(secret) = secret_key {
|
||||
builder = builder.with_secret_access_key(secret);
|
||||
}
|
||||
|
||||
let store = builder
|
||||
.build()
|
||||
.map_err(|e| DomainError::InvalidInput(format!("failed to init S3 storage: {e}")))?;
|
||||
|
||||
Ok(Self {
|
||||
store: Arc::new(store),
|
||||
})
|
||||
}
|
||||
|
||||
async fn store_blob(
|
||||
&self,
|
||||
prefix: &str,
|
||||
data: &[u8],
|
||||
content_type: &str,
|
||||
) -> Result<uuid::Uuid, DomainError> {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
|
||||
let blob_path = Path::from(format!("{prefix}/{id}"));
|
||||
let payload = PutPayload::from(Bytes::copy_from_slice(data));
|
||||
self.store
|
||||
.put_opts(&blob_path, payload, PutOptions::default())
|
||||
.await
|
||||
.map_err(|e| DomainError::InvalidInput(format!("failed to store media: {e}")))?;
|
||||
|
||||
let meta_path = Path::from(format!("{prefix}/{id}.meta"));
|
||||
let meta_payload = PutPayload::from(Bytes::from(content_type.to_string()));
|
||||
self.store
|
||||
.put_opts(&meta_path, meta_payload, PutOptions::default())
|
||||
.await
|
||||
.map_err(|e| DomainError::InvalidInput(format!("failed to store metadata: {e}")))?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn get_blob(
|
||||
&self,
|
||||
prefix: &str,
|
||||
id: uuid::Uuid,
|
||||
) -> Result<Option<MediaFile>, DomainError> {
|
||||
let blob_path = Path::from(format!("{prefix}/{id}"));
|
||||
let data = match self.store.get_opts(&blob_path, GetOptions::default()).await {
|
||||
Ok(result) => result
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| DomainError::InvalidInput(format!("failed to read media: {e}")))?
|
||||
.to_vec(),
|
||||
Err(object_store::Error::NotFound { .. }) => return Ok(None),
|
||||
Err(e) => {
|
||||
return Err(DomainError::InvalidInput(format!(
|
||||
"failed to get media: {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let meta_path = Path::from(format!("{prefix}/{id}.meta"));
|
||||
let content_type = match self.store.get_opts(&meta_path, GetOptions::default()).await {
|
||||
Ok(result) => {
|
||||
let bytes = result.bytes().await.unwrap_or_default();
|
||||
let ct_str = String::from_utf8(bytes.to_vec()).unwrap_or_default();
|
||||
ContentType::from_persistence(ct_str)
|
||||
}
|
||||
_ => ContentType::from_persistence("application/octet-stream".into()),
|
||||
};
|
||||
|
||||
Ok(Some(MediaFile { data, content_type }))
|
||||
}
|
||||
|
||||
async fn remove_blob(&self, prefix: &str, id: uuid::Uuid) -> Result<(), DomainError> {
|
||||
let blob_path = Path::from(format!("{prefix}/{id}"));
|
||||
let meta_path = Path::from(format!("{prefix}/{id}.meta"));
|
||||
|
||||
let stream = futures_util::stream::iter(vec![Ok(blob_path), Ok(meta_path)]);
|
||||
let results: Vec<_> = self.store.delete_stream(Box::pin(stream)).collect().await;
|
||||
|
||||
for result in results {
|
||||
match result {
|
||||
Err(object_store::Error::NotFound { .. }) => {}
|
||||
Err(e) => {
|
||||
return Err(DomainError::InvalidInput(format!(
|
||||
"failed to delete media: {e}"
|
||||
)));
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
use futures_util::StreamExt;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::MediaStoragePort for ObjectStoreMediaStorage {
|
||||
async fn store_photo(&self, upload: MediaUpload) -> Result<PhotoId, DomainError> {
|
||||
let id = self
|
||||
.store_blob("photos", upload.data(), upload.content_type().value())
|
||||
.await?;
|
||||
Ok(PhotoId::from_uuid(id))
|
||||
}
|
||||
|
||||
async fn store_voice_memo(&self, upload: MediaUpload) -> Result<VoiceMemoId, DomainError> {
|
||||
let id = self
|
||||
.store_blob("voice_memos", upload.data(), upload.content_type().value())
|
||||
.await?;
|
||||
Ok(VoiceMemoId::from_uuid(id))
|
||||
}
|
||||
|
||||
async fn get_photo(&self, id: &PhotoId) -> Result<Option<MediaFile>, DomainError> {
|
||||
self.get_blob("photos", id.value()).await
|
||||
}
|
||||
|
||||
async fn get_voice_memo(&self, id: &VoiceMemoId) -> Result<Option<MediaFile>, DomainError> {
|
||||
self.get_blob("voice_memos", id.value()).await
|
||||
}
|
||||
|
||||
async fn delete_photo(&self, id: &PhotoId) -> Result<(), DomainError> {
|
||||
self.remove_blob("photos", id.value()).await
|
||||
}
|
||||
|
||||
async fn delete_voice_memo(&self, id: &VoiceMemoId) -> Result<(), DomainError> {
|
||||
self.remove_blob("voice_memos", id.value()).await
|
||||
}
|
||||
}
|
||||
13
crates/adapters/web-push/Cargo.toml
Normal file
13
crates/adapters/web-push/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "web-push-adapter"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
config = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
web-push = "0.11"
|
||||
serde_json = { workspace = true }
|
||||
129
crates/adapters/web-push/src/lib.rs
Normal file
129
crates/adapters/web-push/src/lib.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use web_push::{
|
||||
ContentEncoding, IsahcWebPushClient, SubscriptionInfo, VapidSignatureBuilder, WebPushClient,
|
||||
WebPushMessageBuilder,
|
||||
};
|
||||
|
||||
use config::PushConfig;
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::PushSubscriptionQueryPort;
|
||||
use domain::user::UserId;
|
||||
|
||||
pub struct WebPushSender {
|
||||
client: IsahcWebPushClient,
|
||||
vapid_private_key: Vec<u8>,
|
||||
vapid_subject: String,
|
||||
subscription_query: Arc<dyn PushSubscriptionQueryPort>,
|
||||
}
|
||||
|
||||
impl WebPushSender {
|
||||
pub fn new(
|
||||
config: &PushConfig,
|
||||
subscription_query: Arc<dyn PushSubscriptionQueryPort>,
|
||||
) -> Result<Self, DomainError> {
|
||||
let private_key = config
|
||||
.vapid_private_key
|
||||
.as_deref()
|
||||
.ok_or_else(|| DomainError::InvalidInput("vapid_private_key is required".into()))?;
|
||||
|
||||
let subject = config
|
||||
.vapid_subject
|
||||
.as_deref()
|
||||
.ok_or_else(|| DomainError::InvalidInput("vapid_subject is required".into()))?;
|
||||
|
||||
let decoded = base64_decode(private_key)?;
|
||||
|
||||
let client = IsahcWebPushClient::new()
|
||||
.map_err(|e| DomainError::InvalidInput(format!("failed to create push client: {e}")))?;
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
vapid_private_key: decoded,
|
||||
vapid_subject: subject.to_string(),
|
||||
subscription_query,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn public_key_base64(config: &PushConfig) -> Result<String, DomainError> {
|
||||
let private_key = config
|
||||
.vapid_private_key
|
||||
.as_deref()
|
||||
.ok_or_else(|| DomainError::InvalidInput("vapid_private_key is required".into()))?;
|
||||
|
||||
let decoded = base64_decode(private_key)?;
|
||||
|
||||
let sig_builder = VapidSignatureBuilder::from_pem_no_sub(std::io::Cursor::new(&decoded))
|
||||
.map_err(|e| DomainError::InvalidInput(format!("invalid VAPID key: {e}")))?;
|
||||
|
||||
let public_key = sig_builder.get_public_key();
|
||||
Ok(base64_url_encode(&public_key))
|
||||
}
|
||||
}
|
||||
|
||||
fn base64_decode(input: &str) -> Result<Vec<u8>, DomainError> {
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(input)
|
||||
.map_err(|e| DomainError::InvalidInput(format!("invalid base64: {e}")))
|
||||
}
|
||||
|
||||
fn base64_url_encode(input: &[u8]) -> String {
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(input)
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ReminderSenderPort for WebPushSender {
|
||||
async fn send_reminder(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
let subscriptions = self.subscription_query.find_by_user(user_id).await?;
|
||||
|
||||
if subscriptions.is_empty() {
|
||||
tracing::debug!(%user_id, "no push subscriptions, skipping");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"title": "K-Mood",
|
||||
"body": "How are you feeling right now?",
|
||||
"url": "/"
|
||||
});
|
||||
let payload_str = payload.to_string();
|
||||
|
||||
for sub in &subscriptions {
|
||||
let subscription_info = SubscriptionInfo::new(sub.endpoint(), sub.p256dh(), sub.auth());
|
||||
|
||||
let mut sig_builder = VapidSignatureBuilder::from_pem(
|
||||
std::io::Cursor::new(&self.vapid_private_key),
|
||||
&subscription_info,
|
||||
)
|
||||
.map_err(|e| {
|
||||
DomainError::InvalidInput(format!("failed to build VAPID signature: {e}"))
|
||||
})?;
|
||||
|
||||
sig_builder.add_claim("sub", &*self.vapid_subject);
|
||||
let signature = sig_builder.build().map_err(|e| {
|
||||
DomainError::InvalidInput(format!("failed to sign push message: {e}"))
|
||||
})?;
|
||||
|
||||
let mut builder = WebPushMessageBuilder::new(&subscription_info);
|
||||
builder.set_payload(ContentEncoding::Aes128Gcm, payload_str.as_bytes());
|
||||
builder.set_vapid_signature(signature);
|
||||
|
||||
let message = builder.build().map_err(|e| {
|
||||
DomainError::InvalidInput(format!("failed to build push message: {e}"))
|
||||
})?;
|
||||
|
||||
match self.client.send(message).await {
|
||||
Ok(_) => {
|
||||
tracing::info!(%user_id, endpoint = sub.endpoint(), "push notification sent");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(%user_id, endpoint = sub.endpoint(), error = %e, "failed to send push");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user