//! `/api/v1/admin/apps/{id}/dead_letters/*` — dashboard surface for //! the no-default-handler model (design notes §4). //! //! Endpoints: //! - `GET /apps/{id}/dead_letters?unresolved=true` — list view //! - `GET /apps/{id}/dead_letters/count` — badge count //! - `GET /apps/{id}/dead_letters/{dl_id}` — row detail //! - `POST /apps/{id}/dead_letters/{dl_id}/replay` — re-enqueue //! - `POST /apps/{id}/dead_letters/{dl_id}/resolve` — mark resolved //! //! All gated on `Capability::AppDeadLetterManage(app_id)`. use std::sync::Arc; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; use axum::routing::{get, post}; use axum::{Extension, Router}; use picloud_shared::{AppId, DeadLetterId, DeadLetterService, Principal, SdkCallCx}; use serde::{Deserialize, Serialize}; use serde_json::json; use crate::app_repo::AppRepository; use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability}; use crate::dead_letter_repo::{DeadLetterRepo, DeadLetterRepoError, DeadLetterRow}; #[derive(Clone)] pub struct DeadLettersState { pub repo: Arc, pub service: Arc, pub apps: Arc, pub authz: Arc, } pub fn dead_letters_router(state: DeadLettersState) -> Router { Router::new() .route("/apps/{app_id}/dead_letters", get(list)) .route("/apps/{app_id}/dead_letters/count", get(count)) .route("/apps/{app_id}/dead_letters/{dl_id}", get(detail)) .route("/apps/{app_id}/dead_letters/{dl_id}/replay", post(replay)) .route("/apps/{app_id}/dead_letters/{dl_id}/resolve", post(resolve)) .with_state(state) } #[derive(Debug, Deserialize)] pub struct ListQuery { #[serde(default)] pub unresolved: bool, #[serde(default = "default_limit")] pub limit: i64, #[serde(default)] pub offset: i64, } const fn default_limit() -> i64 { 50 } #[derive(Debug, Serialize)] pub struct ListResponse { pub dead_letters: Vec, } #[derive(Debug, Serialize)] pub struct CountResponse { pub unresolved: i64, } #[derive(Debug, Deserialize)] pub struct ResolveBody { pub reason: String, } #[derive(Debug, Serialize)] pub struct DeadLetterDto { pub id: DeadLetterId, pub app_id: AppId, pub source: String, pub op: String, pub trigger_id: Option, pub script_id: Option, pub payload: serde_json::Value, pub attempt_count: u32, pub first_attempt_at: chrono::DateTime, pub last_attempt_at: chrono::DateTime, pub last_error: String, pub created_at: chrono::DateTime, pub resolved_at: Option>, pub resolution: Option, } impl From for DeadLetterDto { fn from(r: DeadLetterRow) -> Self { Self { id: r.id, app_id: r.app_id, source: r.source, op: r.op, trigger_id: r.trigger_id, script_id: r.script_id, payload: r.payload, attempt_count: r.attempt_count, first_attempt_at: r.first_attempt_at, last_attempt_at: r.last_attempt_at, last_error: r.last_error, created_at: r.created_at, resolved_at: r.resolved_at, resolution: r.resolution, } } } async fn list( State(s): State, Extension(principal): Extension, Path(app_id): Path, Query(q): Query, ) -> Result, DeadLettersApiError> { ensure_app(&*s.apps, app_id).await?; require( s.authz.as_ref(), &principal, Capability::AppDeadLetterManage(app_id), ) .await?; let rows = s .repo .list_for_app(app_id, q.unresolved, q.limit.clamp(1, 200), q.offset.max(0)) .await?; Ok(Json(ListResponse { dead_letters: rows.into_iter().map(Into::into).collect(), })) } async fn count( State(s): State, Extension(principal): Extension, Path(app_id): Path, ) -> Result, DeadLettersApiError> { ensure_app(&*s.apps, app_id).await?; require( s.authz.as_ref(), &principal, Capability::AppDeadLetterManage(app_id), ) .await?; let n = s.repo.unresolved_count(app_id).await?; Ok(Json(CountResponse { unresolved: n })) } async fn detail( State(s): State, Extension(principal): Extension, Path((app_id, dl_id)): Path<(AppId, DeadLetterId)>, ) -> Result, DeadLettersApiError> { ensure_app(&*s.apps, app_id).await?; require( s.authz.as_ref(), &principal, Capability::AppDeadLetterManage(app_id), ) .await?; let row = s .repo .get(dl_id) .await? .ok_or(DeadLettersApiError::NotFound(dl_id))?; if row.app_id != app_id { return Err(DeadLettersApiError::NotFound(dl_id)); } Ok(Json(row.into())) } async fn replay( State(s): State, Extension(principal): Extension, Path((app_id, dl_id)): Path<(AppId, DeadLetterId)>, ) -> Result { ensure_app(&*s.apps, app_id).await?; // Authz handled inside the service via SdkCallCx. let cx = admin_cx(app_id, &principal); s.service .replay(&cx, dl_id) .await .map_err(map_service_err)?; Ok(StatusCode::NO_CONTENT) } async fn resolve( State(s): State, Extension(principal): Extension, Path((app_id, dl_id)): Path<(AppId, DeadLetterId)>, Json(body): Json, ) -> Result { ensure_app(&*s.apps, app_id).await?; let cx = admin_cx(app_id, &principal); s.service .resolve(&cx, dl_id, &body.reason) .await .map_err(map_service_err)?; Ok(StatusCode::NO_CONTENT) } /// Synthesize an `SdkCallCx` for the admin path. The service layer /// reads `cx.app_id` + `cx.principal` and ignores the trigger / /// execution fields, so the per-call ids are arbitrary. fn admin_cx(app_id: AppId, principal: &Principal) -> SdkCallCx { SdkCallCx { app_id, // Admin-plane cx (dead-letter replay/resolve) — no script is // executing, so this attribution id is a fresh sentinel. script_id: picloud_shared::ScriptId::new(), principal: Some(principal.clone()), execution_id: picloud_shared::ExecutionId::new(), request_id: picloud_shared::RequestId::new(), trigger_depth: 0, root_execution_id: picloud_shared::ExecutionId::new(), is_dead_letter_handler: false, event: None, } } async fn ensure_app(apps: &dyn AppRepository, app_id: AppId) -> Result<(), DeadLettersApiError> { apps.get_by_id(app_id) .await .map_err(|e| DeadLettersApiError::Backend(e.to_string()))? .ok_or_else(|| DeadLettersApiError::AppNotFound(app_id.to_string()))?; Ok(()) } fn map_service_err(e: picloud_shared::DeadLetterError) -> DeadLettersApiError { match e { picloud_shared::DeadLetterError::NotFound => { DeadLettersApiError::NotFound(DeadLetterId::new()) } picloud_shared::DeadLetterError::Forbidden => DeadLettersApiError::Forbidden, picloud_shared::DeadLetterError::InvalidResolution(s) => { DeadLettersApiError::Invalid(format!("invalid resolution: {s}")) } picloud_shared::DeadLetterError::Backend(s) => DeadLettersApiError::Backend(s), } } #[derive(Debug, thiserror::Error)] pub enum DeadLettersApiError { #[error("app not found: {0}")] AppNotFound(String), #[error("dead-letter not found: {0}")] NotFound(DeadLetterId), #[error("invalid: {0}")] Invalid(String), #[error("forbidden")] Forbidden, #[error("authorization repo error: {0}")] AuthzRepo(String), #[error("dead-letter backend: {0}")] Backend(String), } impl From for DeadLettersApiError { fn from(d: AuthzDenied) -> Self { match d { AuthzDenied::Denied => Self::Forbidden, AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), } } } impl From for DeadLettersApiError { fn from(e: AuthzError) -> Self { Self::AuthzRepo(e.to_string()) } } impl From for DeadLettersApiError { fn from(e: DeadLetterRepoError) -> Self { match e { DeadLetterRepoError::NotFound(id) => Self::NotFound(id), DeadLetterRepoError::InvalidResolution(s) => Self::Invalid(s), DeadLetterRepoError::Db(e) => Self::Backend(e.to_string()), } } } impl IntoResponse for DeadLettersApiError { fn into_response(self) -> Response { let (status, body) = match &self { Self::AppNotFound(_) | Self::NotFound(_) => { (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })) } Self::Invalid(_) => ( StatusCode::UNPROCESSABLE_ENTITY, json!({ "error": self.to_string() }), ), Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })), Self::AuthzRepo(e) => { tracing::error!(error = %e, "dead_letters authz repo error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } Self::Backend(e) => { tracing::error!(error = %e, "dead_letters api backend error"); ( StatusCode::INTERNAL_SERVER_ERROR, json!({ "error": "internal error" }), ) } }; (status, Json(body)).into_response() } }