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