init
Some checks failed
CI / ci (push) Failing after 1m48s

This commit is contained in:
2026-08-25 23:24:36 +02:00
commit 95739892de
466 changed files with 33918 additions and 0 deletions

View 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

View 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))
}
}

View File

@@ -0,0 +1,5 @@
mod jwt_service;
mod password_hasher;
pub use jwt_service::JwtAuthService;
pub use password_hasher::Argon2PasswordHasher;

View 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())
}
}

View 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

View 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)
}

View File

@@ -0,0 +1,5 @@
mod channel;
mod noop;
pub use channel::{ChannelEventPublisher, EventReceiver, create_event_channel};
pub use noop::NoopEventPublisher;

View 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(())
}
}

View 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

View 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(),
}
}
}

View File

@@ -0,0 +1,3 @@
mod json_export;
pub use json_export::JsonExportAdapter;

View 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

View 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()),
}
}

View File

@@ -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;

View 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;

View 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))
}

View 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)))
}
}

View 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)
}

View 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)
}

View 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 }))
}

View 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)))
}

View 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)
}

View 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;

View 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)
}

View 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)
}

View 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)
}

View 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;

View 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(),
),
);
}
}

View 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))
}

View 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")))
}

View 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,
}

View 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

View 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)
}
}

View 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}"
))),
}
}

View 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())
}
}

View 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};

View 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

View 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(())
}

View File

@@ -0,0 +1,4 @@
mod db;
pub mod repositories;
pub use db::{create_pool, run_migrations};

View 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);

View File

@@ -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);

View 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(())
}
}

View File

@@ -0,0 +1,6 @@
mod command;
mod query;
mod rows;
pub use command::SqliteActivityCommandRepository;
pub use query::SqliteActivityQueryRepository;

View 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())
}
}

View 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(),
)
}
}

View 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)
}
}

View 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())
}
}

View File

@@ -0,0 +1,6 @@
mod command;
mod query;
pub(crate) mod rows;
pub use command::SqliteEntryCommandRepository;
pub use query::SqliteEntryQueryRepository;

View 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
}
}

View 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)
}

View 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};

View File

@@ -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(())
}
}

View File

@@ -0,0 +1,6 @@
mod command;
mod query;
mod rows;
pub use command::SqlitePushSubscriptionCommandRepository;
pub use query::SqlitePushSubscriptionQueryRepository;

View File

@@ -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()))
}
}

View File

@@ -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(),
)
}
}

View File

@@ -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())
}
}

View File

@@ -0,0 +1,6 @@
mod command;
mod query;
mod rows;
pub use command::SqliteRefreshSessionCommandRepository;
pub use query::SqliteRefreshSessionQueryRepository;

View File

@@ -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))
}
}

View File

@@ -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(),
)
}
}

View 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(())
}
}

View File

@@ -0,0 +1,6 @@
mod command;
mod query;
mod rows;
pub use command::SqliteReminderCommandRepository;
pub use query::SqliteReminderQueryRepository;

View 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())
}
}

View 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()
}

View File

@@ -0,0 +1,5 @@
use domain::errors::DomainError;
pub fn db_err(e: sqlx::Error) -> DomainError {
DomainError::InvalidInput(format!("database error: {e}"))
}

View 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(())
}
}

View File

@@ -0,0 +1,6 @@
mod command;
mod query;
mod rows;
pub use command::SqliteUserCommandRepository;
pub use query::SqliteUserQueryRepository;

View 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))
}
}

View 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(),
})
}
}

View 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

View 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(),
),
}
}

View 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
}
}

View 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 }

View 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(())
}
}