Refactor: Use doctate-common for shared logic

Moves shared types and utility functions to a new `doctate-common`
crate.
This includes:
- API key header constant
- AckResponse and AckStatus types
- Timestamp formatting and parsing utilities

This change centralizes common functionality, reducing duplication and
improving maintainability.
The `server` crate now depends on `doctate-common`.
This commit is contained in:
2026-04-17 15:25:11 +02:00
parent ed5047a3ff
commit a2c1ff49ac
6 changed files with 10 additions and 53 deletions
Generated
+1
View File
@@ -348,6 +348,7 @@ dependencies = [
"axum", "axum",
"axum-extra", "axum-extra",
"bcrypt", "bcrypt",
"doctate-common",
"dotenvy", "dotenvy",
"pulldown-cmark", "pulldown-cmark",
"rand 0.8.6", "rand 0.8.6",
+1
View File
@@ -5,6 +5,7 @@ edition.workspace = true
default-run = "doctate-server" default-run = "doctate-server"
[dependencies] [dependencies]
doctate-common = { path = "../doctate-common" }
axum = { version = "0.8", features = ["multipart"] } axum = { version = "0.8", features = ["multipart"] }
tokio.workspace = true tokio.workspace = true
reqwest.workspace = true reqwest.workspace = true
+2 -1
View File
@@ -5,6 +5,7 @@ use std::time::Instant;
use axum::extract::{FromRef, FromRequestParts}; use axum::extract::{FromRef, FromRequestParts};
use axum::http::request::Parts; use axum::http::request::Parts;
use axum_extra::extract::cookie::CookieJar; use axum_extra::extract::cookie::CookieJar;
use doctate_common::API_KEY_HEADER;
use tracing::warn; use tracing::warn;
use crate::config::Config; use crate::config::Config;
@@ -43,7 +44,7 @@ where
let api_key = parts let api_key = parts
.headers .headers
.get("X-API-Key") .get(API_KEY_HEADER)
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.ok_or(AppError::Unauthorized)?; .ok_or(AppError::Unauthorized)?;
+1 -15
View File
@@ -1,15 +1 @@
use serde::Serialize; pub use doctate_common::ack::{AckResponse, AckStatus};
#[derive(Serialize)]
pub struct AckResponse {
pub case_id: String,
pub recorded_at: String,
pub status: AckStatus,
}
#[derive(Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AckStatus {
Received,
Gone,
}
+3 -36
View File
@@ -10,6 +10,8 @@ use time::OffsetDateTime;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use tracing::{info, warn}; use tracing::{info, warn};
use doctate_common::timestamp::{filename_stem_to_recorded_at, now_rfc3339};
use crate::analyze::{ use crate::analyze::{
AnalysisInput, AnalyzeJob, AnalyzeSender, RecordingInput, ANALYSIS_INPUT_FILE, DOCUMENT_FILE, AnalysisInput, AnalyzeJob, AnalyzeSender, RecordingInput, ANALYSIS_INPUT_FILE, DOCUMENT_FILE,
}; };
@@ -229,16 +231,6 @@ pub(crate) async fn write_input_create_new(
Ok(()) Ok(())
} }
/// Reverse the filesystem-safe timestamp mapping applied in `upload.rs`:
/// `:` in the time portion was replaced with `-`. Split at `T`, undo hyphens
/// only in the time part to avoid touching date hyphens.
fn filename_stem_to_recorded_at(stem: &str) -> String {
match stem.split_once('T') {
Some((date, time)) => format!("{}T{}", date, time.replace('-', ":")),
None => stem.to_string(),
}
}
/// Read `case_dir/document.md`. Returns `None` if no document exists. /// Read `case_dir/document.md`. Returns `None` if no document exists.
pub(crate) async fn read_document(case_dir: &Path) -> Option<String> { pub(crate) async fn read_document(case_dir: &Path) -> Option<String> {
tokio::fs::read_to_string(case_dir.join(DOCUMENT_FILE)).await.ok() tokio::fs::read_to_string(case_dir.join(DOCUMENT_FILE)).await.ok()
@@ -295,7 +287,7 @@ pub async fn handle_delete_case(
let marker = DeleteMarker { let marker = DeleteMarker {
batch: uuid::Uuid::new_v4(), batch: uuid::Uuid::new_v4(),
deleted_at: now_rfc3339()?, deleted_at: now_rfc3339(),
}; };
write_delete_marker(&case_dir, &marker).await?; write_delete_marker(&case_dir, &marker).await?;
@@ -444,28 +436,3 @@ async fn restore_batch(user_root: &Path, batch: uuid::Uuid) -> usize {
} }
count count
} }
fn now_rfc3339() -> Result<String, AppError> {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.map_err(|e| AppError::Internal(format!("format timestamp: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filename_stem_roundtrip() {
assert_eq!(
filename_stem_to_recorded_at("2026-04-15T10-32-00Z"),
"2026-04-15T10:32:00Z"
);
// Date-only hyphens are preserved.
assert_eq!(
filename_stem_to_recorded_at("2026-04-15T10-32-00.123Z"),
"2026-04-15T10:32:00.123Z"
);
}
}
+2 -1
View File
@@ -2,6 +2,7 @@ use std::path::{Path, PathBuf};
use axum::extract::{Multipart, State}; use axum::extract::{Multipart, State};
use axum::Json; use axum::Json;
use doctate_common::timestamp::recorded_at_to_filename_stem;
use tokio::sync::mpsc::error::TrySendError; use tokio::sync::mpsc::error::TrySendError;
use tracing::{info, warn}; use tracing::{info, warn};
@@ -72,7 +73,7 @@ pub async fn handle_upload(
let case_dir = resolve_case_dir(&user.data_dir, &case_id).await?; let case_dir = resolve_case_dir(&user.data_dir, &case_id).await?;
// Build a filesystem-safe filename from the timestamp (colons → hyphens). // Build a filesystem-safe filename from the timestamp (colons → hyphens).
let safe_timestamp = recorded_at.replace(':', "-"); let safe_timestamp = recorded_at_to_filename_stem(&recorded_at);
let file_path = case_dir.join(format!("{safe_timestamp}.m4a")); let file_path = case_dir.join(format!("{safe_timestamp}.m4a"));
tokio::fs::write(&file_path, &audio).await?; tokio::fs::write(&file_path, &audio).await?;