Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I9f113e6402030c46ad97f636985b5d6c6a6a6964
693 lines
19 KiB
Rust
693 lines
19 KiB
Rust
use std::net::SocketAddr;
|
|
|
|
use axum::{
|
|
Json,
|
|
extract::{ConnectInfo, Extension, Path, Query, State},
|
|
http::StatusCode,
|
|
};
|
|
use chrono::Utc;
|
|
use pinakes_core::{
|
|
model::{MediaId, Pagination},
|
|
sharing::{
|
|
Share,
|
|
ShareActivity,
|
|
ShareActivityAction,
|
|
ShareId,
|
|
ShareMutatePermissions,
|
|
ShareNotification,
|
|
ShareNotificationType,
|
|
SharePermissions,
|
|
ShareRecipient,
|
|
ShareTarget,
|
|
ShareViewPermissions,
|
|
generate_share_token,
|
|
hash_share_password,
|
|
verify_share_password,
|
|
},
|
|
users::UserId,
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
use crate::{
|
|
auth::resolve_user_id,
|
|
dto::{
|
|
AccessSharedRequest,
|
|
BatchDeleteSharesRequest,
|
|
CreateShareRequest,
|
|
MediaResponse,
|
|
PaginationParams,
|
|
ShareActivityResponse,
|
|
ShareNotificationResponse,
|
|
ShareResponse,
|
|
SharedContentResponse,
|
|
UpdateShareRequest,
|
|
},
|
|
error::{ApiError, ApiResult},
|
|
state::AppState,
|
|
};
|
|
|
|
/// Create a new share
|
|
/// POST /api/shares
|
|
pub async fn create_share(
|
|
State(state): State<AppState>,
|
|
Extension(username): Extension<String>,
|
|
Json(req): Json<CreateShareRequest>,
|
|
) -> ApiResult<Json<ShareResponse>> {
|
|
let config = state.config.read().await;
|
|
if !config.sharing.enabled() {
|
|
return Err(ApiError::bad_request("Sharing is not enabled"));
|
|
}
|
|
|
|
// Validate public links are allowed
|
|
if req.recipient_type == "public_link" && !config.sharing.allow_public_links()
|
|
{
|
|
return Err(ApiError::bad_request("Public links are not allowed"));
|
|
}
|
|
|
|
// Enforce password requirement for public links if configured
|
|
if req.recipient_type == "public_link"
|
|
&& config.sharing.require_public_link_password
|
|
&& req.password.is_none()
|
|
{
|
|
return Err(ApiError::bad_request(
|
|
"Public links require a password per server policy",
|
|
));
|
|
}
|
|
drop(config);
|
|
|
|
let owner_id = resolve_user_id(&state.storage, &username).await?;
|
|
|
|
// Parse target
|
|
let target_id: Uuid = req
|
|
.target_id
|
|
.parse()
|
|
.map_err(|_| ApiError::bad_request("Invalid target_id"))?;
|
|
|
|
let target = match req.target_type.as_str() {
|
|
"media" => {
|
|
ShareTarget::Media {
|
|
media_id: MediaId(target_id),
|
|
}
|
|
},
|
|
"collection" => {
|
|
ShareTarget::Collection {
|
|
collection_id: target_id,
|
|
}
|
|
},
|
|
"tag" => ShareTarget::Tag { tag_id: target_id },
|
|
"saved_search" => {
|
|
ShareTarget::SavedSearch {
|
|
search_id: target_id,
|
|
}
|
|
},
|
|
_ => return Err(ApiError::bad_request("Invalid target_type")),
|
|
};
|
|
|
|
// Parse recipient
|
|
let recipient = match req.recipient_type.as_str() {
|
|
"public_link" => {
|
|
let token = generate_share_token();
|
|
let password_hash = req
|
|
.password
|
|
.as_ref()
|
|
.map(|p| hash_share_password(p))
|
|
.transpose()
|
|
.map_err(ApiError)?;
|
|
ShareRecipient::PublicLink {
|
|
token,
|
|
password_hash,
|
|
}
|
|
},
|
|
"user" => {
|
|
let recipient_user_id = req.recipient_user_id.ok_or_else(|| {
|
|
ApiError::bad_request("recipient_user_id required for user share")
|
|
})?;
|
|
ShareRecipient::User {
|
|
user_id: UserId(recipient_user_id),
|
|
}
|
|
},
|
|
"group" => {
|
|
let group_id = req.recipient_group_id.ok_or_else(|| {
|
|
ApiError::bad_request("recipient_group_id required for group share")
|
|
})?;
|
|
ShareRecipient::Group { group_id }
|
|
},
|
|
_ => return Err(ApiError::bad_request("Invalid recipient_type")),
|
|
};
|
|
|
|
// Parse permissions
|
|
let permissions = if let Some(perms) = req.permissions {
|
|
SharePermissions {
|
|
view: ShareViewPermissions {
|
|
can_view: perms.can_view.unwrap_or(true),
|
|
can_download: perms.can_download.unwrap_or(false),
|
|
can_reshare: perms.can_reshare.unwrap_or(false),
|
|
},
|
|
mutate: ShareMutatePermissions {
|
|
can_edit: perms.can_edit.unwrap_or(false),
|
|
can_delete: perms.can_delete.unwrap_or(false),
|
|
can_add: perms.can_add.unwrap_or(false),
|
|
},
|
|
}
|
|
} else {
|
|
SharePermissions::view_only()
|
|
};
|
|
|
|
// Calculate expiration
|
|
let expires_at = req
|
|
.expires_in_hours
|
|
.map(|hours| Utc::now() + chrono::Duration::hours(hours as i64));
|
|
|
|
let share = Share {
|
|
id: ShareId(Uuid::now_v7()),
|
|
target,
|
|
owner_id,
|
|
recipient,
|
|
permissions,
|
|
note: req.note,
|
|
expires_at,
|
|
access_count: 0,
|
|
last_accessed: None,
|
|
inherit_to_children: req.inherit_to_children.unwrap_or(true),
|
|
parent_share_id: None,
|
|
created_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
};
|
|
|
|
let created =
|
|
state.storage.create_share(&share).await.map_err(|e| {
|
|
ApiError::internal(format!("Failed to create share: {e}"))
|
|
})?;
|
|
|
|
// Send notification to recipient if it's a user share
|
|
if let ShareRecipient::User { user_id } = &created.recipient {
|
|
let notification = ShareNotification {
|
|
id: Uuid::now_v7(),
|
|
user_id: *user_id,
|
|
share_id: created.id,
|
|
notification_type: ShareNotificationType::NewShare,
|
|
is_read: false,
|
|
created_at: Utc::now(),
|
|
};
|
|
|
|
if let Err(e) = state.storage.create_share_notification(¬ification).await
|
|
{
|
|
tracing::warn!(error = %e, "failed to send share notification");
|
|
}
|
|
}
|
|
|
|
Ok(Json(created.into()))
|
|
}
|
|
|
|
/// List outgoing shares (shares I created)
|
|
/// GET /api/shares/outgoing
|
|
pub async fn list_outgoing(
|
|
State(state): State<AppState>,
|
|
Extension(username): Extension<String>,
|
|
Query(params): Query<PaginationParams>,
|
|
) -> ApiResult<Json<Vec<ShareResponse>>> {
|
|
let user_id = resolve_user_id(&state.storage, &username).await?;
|
|
let pagination = Pagination {
|
|
offset: params.offset.unwrap_or(0),
|
|
limit: params.limit.unwrap_or(50).min(1000),
|
|
sort: params.sort,
|
|
};
|
|
|
|
let shares = state
|
|
.storage
|
|
.list_shares_by_owner(user_id, &pagination)
|
|
.await
|
|
.map_err(|e| ApiError::internal(format!("Failed to list shares: {e}")))?;
|
|
|
|
Ok(Json(shares.into_iter().map(Into::into).collect()))
|
|
}
|
|
|
|
/// List incoming shares (shares shared with me)
|
|
/// GET /api/shares/incoming
|
|
pub async fn list_incoming(
|
|
State(state): State<AppState>,
|
|
Extension(username): Extension<String>,
|
|
Query(params): Query<PaginationParams>,
|
|
) -> ApiResult<Json<Vec<ShareResponse>>> {
|
|
let user_id = resolve_user_id(&state.storage, &username).await?;
|
|
let pagination = Pagination {
|
|
offset: params.offset.unwrap_or(0),
|
|
limit: params.limit.unwrap_or(50).min(1000),
|
|
sort: params.sort,
|
|
};
|
|
|
|
let shares = state
|
|
.storage
|
|
.list_shares_for_user(user_id, &pagination)
|
|
.await
|
|
.map_err(|e| ApiError::internal(format!("Failed to list shares: {e}")))?;
|
|
|
|
Ok(Json(shares.into_iter().map(Into::into).collect()))
|
|
}
|
|
|
|
/// Get share details
|
|
/// GET /api/shares/{id}
|
|
pub async fn get_share(
|
|
State(state): State<AppState>,
|
|
Extension(username): Extension<String>,
|
|
Path(id): Path<Uuid>,
|
|
) -> ApiResult<Json<ShareResponse>> {
|
|
let user_id = resolve_user_id(&state.storage, &username).await?;
|
|
let share = state
|
|
.storage
|
|
.get_share(ShareId(id))
|
|
.await
|
|
.map_err(|e| ApiError::not_found(format!("Share not found: {e}")))?;
|
|
|
|
// Check authorization
|
|
let is_owner = share.owner_id == user_id;
|
|
let is_recipient = match &share.recipient {
|
|
ShareRecipient::User {
|
|
user_id: recipient_id,
|
|
} => *recipient_id == user_id,
|
|
_ => false,
|
|
};
|
|
|
|
if !is_owner && !is_recipient {
|
|
return Err(ApiError::forbidden("Not authorized to view this share"));
|
|
}
|
|
|
|
Ok(Json(share.into()))
|
|
}
|
|
|
|
/// Update a share
|
|
/// PATCH /api/shares/{id}
|
|
pub async fn update_share(
|
|
State(state): State<AppState>,
|
|
Extension(username): Extension<String>,
|
|
Path(id): Path<Uuid>,
|
|
Json(req): Json<UpdateShareRequest>,
|
|
) -> ApiResult<Json<ShareResponse>> {
|
|
let user_id = resolve_user_id(&state.storage, &username).await?;
|
|
let mut share = state
|
|
.storage
|
|
.get_share(ShareId(id))
|
|
.await
|
|
.map_err(|e| ApiError::not_found(format!("Share not found: {e}")))?;
|
|
|
|
// Only owner can update
|
|
if share.owner_id != user_id {
|
|
return Err(ApiError::forbidden("Only the owner can update this share"));
|
|
}
|
|
|
|
// Update fields
|
|
if let Some(perms) = req.permissions {
|
|
share.permissions = SharePermissions {
|
|
view: ShareViewPermissions {
|
|
can_view: perms.can_view.unwrap_or(share.permissions.view.can_view),
|
|
can_download: perms
|
|
.can_download
|
|
.unwrap_or(share.permissions.view.can_download),
|
|
can_reshare: perms
|
|
.can_reshare
|
|
.unwrap_or(share.permissions.view.can_reshare),
|
|
},
|
|
mutate: ShareMutatePermissions {
|
|
can_edit: perms.can_edit.unwrap_or(share.permissions.mutate.can_edit),
|
|
can_delete: perms
|
|
.can_delete
|
|
.unwrap_or(share.permissions.mutate.can_delete),
|
|
can_add: perms.can_add.unwrap_or(share.permissions.mutate.can_add),
|
|
},
|
|
};
|
|
}
|
|
|
|
if let Some(note) = req.note {
|
|
share.note = Some(note);
|
|
}
|
|
|
|
if let Some(expires_at) = req.expires_at {
|
|
share.expires_at = Some(expires_at);
|
|
}
|
|
|
|
if let Some(inherit) = req.inherit_to_children {
|
|
share.inherit_to_children = inherit;
|
|
}
|
|
|
|
share.updated_at = Utc::now();
|
|
|
|
let updated =
|
|
state.storage.update_share(&share).await.map_err(|e| {
|
|
ApiError::internal(format!("Failed to update share: {e}"))
|
|
})?;
|
|
|
|
// Notify recipient of update
|
|
if let ShareRecipient::User { user_id } = &updated.recipient {
|
|
let notification = ShareNotification {
|
|
id: Uuid::now_v7(),
|
|
user_id: *user_id,
|
|
share_id: updated.id,
|
|
notification_type: ShareNotificationType::ShareUpdated,
|
|
is_read: false,
|
|
created_at: Utc::now(),
|
|
};
|
|
if let Err(e) = state.storage.create_share_notification(¬ification).await
|
|
{
|
|
tracing::warn!(error = %e, "failed to send share update notification");
|
|
}
|
|
}
|
|
|
|
Ok(Json(updated.into()))
|
|
}
|
|
|
|
/// Delete (revoke) a share
|
|
/// DELETE /api/shares/{id}
|
|
pub async fn delete_share(
|
|
State(state): State<AppState>,
|
|
Extension(username): Extension<String>,
|
|
Path(id): Path<Uuid>,
|
|
) -> ApiResult<StatusCode> {
|
|
let user_id = resolve_user_id(&state.storage, &username).await?;
|
|
let share = state
|
|
.storage
|
|
.get_share(ShareId(id))
|
|
.await
|
|
.map_err(|e| ApiError::not_found(format!("Share not found: {e}")))?;
|
|
|
|
// Only owner can delete
|
|
if share.owner_id != user_id {
|
|
return Err(ApiError::forbidden("Only the owner can revoke this share"));
|
|
}
|
|
|
|
// Notify recipient before deletion
|
|
if let ShareRecipient::User { user_id } = &share.recipient {
|
|
let notification = ShareNotification {
|
|
id: Uuid::now_v7(),
|
|
user_id: *user_id,
|
|
share_id: share.id,
|
|
notification_type: ShareNotificationType::ShareRevoked,
|
|
is_read: false,
|
|
created_at: Utc::now(),
|
|
};
|
|
if let Err(e) = state.storage.create_share_notification(¬ification).await
|
|
{
|
|
tracing::warn!(error = %e, "failed to send share revocation notification");
|
|
}
|
|
}
|
|
|
|
state
|
|
.storage
|
|
.delete_share(ShareId(id))
|
|
.await
|
|
.map_err(|e| ApiError::internal(format!("Failed to delete share: {e}")))?;
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
/// Batch delete shares
|
|
/// POST /api/shares/batch/delete
|
|
pub async fn batch_delete(
|
|
State(state): State<AppState>,
|
|
Extension(username): Extension<String>,
|
|
Json(req): Json<BatchDeleteSharesRequest>,
|
|
) -> ApiResult<Json<serde_json::Value>> {
|
|
let user_id = resolve_user_id(&state.storage, &username).await?;
|
|
let share_ids: Vec<ShareId> =
|
|
req.share_ids.into_iter().map(ShareId).collect();
|
|
|
|
// Verify ownership of all shares
|
|
for share_id in &share_ids {
|
|
let share = state
|
|
.storage
|
|
.get_share(*share_id)
|
|
.await
|
|
.map_err(|e| ApiError::not_found(format!("Share not found: {e}")))?;
|
|
|
|
if share.owner_id != user_id {
|
|
return Err(ApiError::forbidden(format!(
|
|
"Not authorized to delete share {}",
|
|
share_id.0
|
|
)));
|
|
}
|
|
}
|
|
|
|
let deleted = state
|
|
.storage
|
|
.batch_delete_shares(&share_ids)
|
|
.await
|
|
.map_err(|e| ApiError::internal(format!("Failed to batch delete: {e}")))?;
|
|
|
|
Ok(Json(serde_json::json!({ "deleted": deleted })))
|
|
}
|
|
|
|
/// Access a public shared resource
|
|
/// GET /api/shared/{token}
|
|
pub async fn access_shared(
|
|
State(state): State<AppState>,
|
|
Path(token): Path<String>,
|
|
Query(params): Query<AccessSharedRequest>,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
) -> ApiResult<Json<SharedContentResponse>> {
|
|
let share = state
|
|
.storage
|
|
.get_share_by_token(&token)
|
|
.await
|
|
.map_err(|e| ApiError::not_found(format!("Share not found: {e}")))?;
|
|
|
|
// Check expiration
|
|
if let Some(expires_at) = share.expires_at
|
|
&& Utc::now() > expires_at
|
|
{
|
|
return Err(ApiError::not_found("Share has expired"));
|
|
}
|
|
|
|
// Check password if required
|
|
if let ShareRecipient::PublicLink {
|
|
password_hash: Some(hash),
|
|
..
|
|
} = &share.recipient
|
|
{
|
|
let provided_password = params
|
|
.password
|
|
.as_ref()
|
|
.ok_or_else(|| ApiError::unauthorized("Password required"))?;
|
|
|
|
if !verify_share_password(provided_password, hash) {
|
|
// Log failed attempt
|
|
let activity = ShareActivity {
|
|
id: Uuid::now_v7(),
|
|
share_id: share.id,
|
|
actor_id: None,
|
|
actor_ip: Some(addr.ip().to_string()),
|
|
action: ShareActivityAction::PasswordFailed,
|
|
details: None,
|
|
timestamp: Utc::now(),
|
|
};
|
|
if let Err(e) = state.storage.record_share_activity(&activity).await {
|
|
tracing::warn!(error = %e, "failed to record share activity");
|
|
}
|
|
|
|
return Err(ApiError::unauthorized("Invalid password"));
|
|
}
|
|
}
|
|
|
|
// Record access
|
|
state
|
|
.storage
|
|
.record_share_access(share.id)
|
|
.await
|
|
.map_err(|e| ApiError::internal(format!("Failed to record access: {e}")))?;
|
|
|
|
// Log the access
|
|
let activity = ShareActivity {
|
|
id: Uuid::now_v7(),
|
|
share_id: share.id,
|
|
actor_id: None,
|
|
actor_ip: Some(addr.ip().to_string()),
|
|
action: ShareActivityAction::Accessed,
|
|
details: None,
|
|
timestamp: Utc::now(),
|
|
};
|
|
let _ = state.storage.record_share_activity(&activity).await;
|
|
|
|
// Return the shared content
|
|
let roots = state.config.read().await.directories.roots.clone();
|
|
match &share.target {
|
|
ShareTarget::Media { media_id } => {
|
|
let item = state
|
|
.storage
|
|
.get_media(*media_id)
|
|
.await
|
|
.map_err(|e| ApiError::not_found(format!("Media not found: {e}")))?;
|
|
|
|
Ok(Json(SharedContentResponse::Single(MediaResponse::new(
|
|
item, &roots,
|
|
))))
|
|
},
|
|
ShareTarget::Collection { collection_id } => {
|
|
let members = state
|
|
.storage
|
|
.get_collection_members(*collection_id)
|
|
.await
|
|
.map_err(|e| {
|
|
ApiError::not_found(format!("Collection not found: {e}"))
|
|
})?;
|
|
|
|
let items: Vec<MediaResponse> = members
|
|
.into_iter()
|
|
.map(|item| MediaResponse::new(item, &roots))
|
|
.collect();
|
|
|
|
Ok(Json(SharedContentResponse::Multiple { items }))
|
|
},
|
|
ShareTarget::Tag { tag_id } => {
|
|
let tag = state
|
|
.storage
|
|
.get_tag(*tag_id)
|
|
.await
|
|
.map_err(|e| ApiError::not_found(format!("Tag not found: {e}")))?;
|
|
|
|
let request = pinakes_core::search::SearchRequest {
|
|
query: pinakes_core::search::SearchQuery::TagFilter(
|
|
tag.name.clone(),
|
|
),
|
|
sort: pinakes_core::search::SortOrder::default(),
|
|
pagination: Pagination::new(0, 100, None),
|
|
};
|
|
|
|
let results = state
|
|
.storage
|
|
.search(&request)
|
|
.await
|
|
.map_err(|e| ApiError::internal(format!("Search failed: {e}")))?;
|
|
|
|
let items: Vec<MediaResponse> = results
|
|
.items
|
|
.into_iter()
|
|
.map(|item| MediaResponse::new(item, &roots))
|
|
.collect();
|
|
|
|
Ok(Json(SharedContentResponse::Multiple { items }))
|
|
},
|
|
ShareTarget::SavedSearch { search_id } => {
|
|
let saved =
|
|
state
|
|
.storage
|
|
.get_saved_search(*search_id)
|
|
.await
|
|
.map_err(|e| {
|
|
ApiError::not_found(format!("Saved search not found: {e}"))
|
|
})?;
|
|
|
|
let parsed_query = pinakes_core::search::parse_search_query(&saved.query)
|
|
.map_err(|e| {
|
|
ApiError::internal(format!("Failed to parse search query: {e}"))
|
|
})?;
|
|
|
|
let request = pinakes_core::search::SearchRequest {
|
|
query: parsed_query,
|
|
sort: pinakes_core::search::SortOrder::default(),
|
|
pagination: Pagination::new(0, 100, None),
|
|
};
|
|
|
|
let results = state
|
|
.storage
|
|
.search(&request)
|
|
.await
|
|
.map_err(|e| ApiError::internal(format!("Search failed: {e}")))?;
|
|
|
|
let items: Vec<MediaResponse> = results
|
|
.items
|
|
.into_iter()
|
|
.map(|item| MediaResponse::new(item, &roots))
|
|
.collect();
|
|
|
|
Ok(Json(SharedContentResponse::Multiple { items }))
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Get share activity log
|
|
/// GET /api/shares/{id}/activity
|
|
pub async fn get_activity(
|
|
State(state): State<AppState>,
|
|
Extension(username): Extension<String>,
|
|
Path(id): Path<Uuid>,
|
|
Query(params): Query<PaginationParams>,
|
|
) -> ApiResult<Json<Vec<ShareActivityResponse>>> {
|
|
let user_id = resolve_user_id(&state.storage, &username).await?;
|
|
let share = state
|
|
.storage
|
|
.get_share(ShareId(id))
|
|
.await
|
|
.map_err(|e| ApiError::not_found(format!("Share not found: {e}")))?;
|
|
|
|
// Only owner can view activity
|
|
if share.owner_id != user_id {
|
|
return Err(ApiError::forbidden(
|
|
"Only the owner can view share activity",
|
|
));
|
|
}
|
|
|
|
let pagination = Pagination {
|
|
offset: params.offset.unwrap_or(0),
|
|
limit: params.limit.unwrap_or(50).min(1000),
|
|
sort: params.sort,
|
|
};
|
|
|
|
let activity = state
|
|
.storage
|
|
.get_share_activity(ShareId(id), &pagination)
|
|
.await
|
|
.map_err(|e| ApiError::internal(format!("Failed to get activity: {e}")))?;
|
|
|
|
Ok(Json(activity.into_iter().map(Into::into).collect()))
|
|
}
|
|
|
|
/// Get unread share notifications
|
|
/// GET /api/notifications/shares
|
|
pub async fn get_notifications(
|
|
State(state): State<AppState>,
|
|
Extension(username): Extension<String>,
|
|
) -> ApiResult<Json<Vec<ShareNotificationResponse>>> {
|
|
let user_id = resolve_user_id(&state.storage, &username).await?;
|
|
let notifications = state
|
|
.storage
|
|
.get_unread_notifications(user_id)
|
|
.await
|
|
.map_err(|e| {
|
|
ApiError::internal(format!("Failed to get notifications: {e}"))
|
|
})?;
|
|
|
|
Ok(Json(notifications.into_iter().map(Into::into).collect()))
|
|
}
|
|
|
|
/// Mark a notification as read
|
|
/// POST /api/notifications/shares/{id}/read
|
|
pub async fn mark_notification_read(
|
|
State(state): State<AppState>,
|
|
Extension(username): Extension<String>,
|
|
Path(id): Path<Uuid>,
|
|
) -> ApiResult<StatusCode> {
|
|
let user_id = resolve_user_id(&state.storage, &username).await?;
|
|
state
|
|
.storage
|
|
.mark_notification_read(id, user_id)
|
|
.await
|
|
.map_err(|e| ApiError::internal(format!("Failed to mark as read: {e}")))?;
|
|
|
|
Ok(StatusCode::OK)
|
|
}
|
|
|
|
/// Mark all notifications as read
|
|
/// POST /api/notifications/shares/read-all
|
|
pub async fn mark_all_read(
|
|
State(state): State<AppState>,
|
|
Extension(username): Extension<String>,
|
|
) -> ApiResult<StatusCode> {
|
|
let user_id = resolve_user_id(&state.storage, &username).await?;
|
|
state
|
|
.storage
|
|
.mark_all_notifications_read(user_id)
|
|
.await
|
|
.map_err(|e| {
|
|
ApiError::internal(format!("Failed to mark all as read: {e}"))
|
|
})?;
|
|
|
|
Ok(StatusCode::OK)
|
|
}
|