diff --git a/Cargo.lock b/Cargo.lock index 071ff72..eb8a803 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -330,6 +330,16 @@ dependencies = [ "syn", ] +[[package]] +name = "doctate-common" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "time", + "uuid", +] + [[package]] name = "doctate-server" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index d3dc2a7..3d11be8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["server"] +members = ["server", "doctate-common"] resolver = "3" [workspace.package] diff --git a/doctate-common/Cargo.toml b/doctate-common/Cargo.toml new file mode 100644 index 0000000..18ef6e7 --- /dev/null +++ b/doctate-common/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "doctate-common" +version.workspace = true +edition.workspace = true +description = "Shared types and helpers between doctate server and clients" + +[dependencies] +serde = { workspace = true } +uuid = { workspace = true } +time = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/doctate-common/src/ack.rs b/doctate-common/src/ack.rs new file mode 100644 index 0000000..58ef06e --- /dev/null +++ b/doctate-common/src/ack.rs @@ -0,0 +1,54 @@ +use serde::{Deserialize, Serialize}; + +/// Server response to `POST /api/upload`. Shape is stable across all clients. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AckResponse { + pub case_id: String, + pub recorded_at: String, + pub status: AckStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AckStatus { + /// Recording accepted and persisted. + Received, + /// Case no longer exists server-side; client should delete its local copy. + Gone, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_serializes_lowercase() { + assert_eq!( + serde_json::to_string(&AckStatus::Received).unwrap(), + "\"received\"" + ); + assert_eq!(serde_json::to_string(&AckStatus::Gone).unwrap(), "\"gone\""); + } + + #[test] + fn status_deserializes_lowercase() { + let got: AckStatus = serde_json::from_str("\"received\"").unwrap(); + assert_eq!(got, AckStatus::Received); + let got: AckStatus = serde_json::from_str("\"gone\"").unwrap(); + assert_eq!(got, AckStatus::Gone); + } + + #[test] + fn response_roundtrip() { + let original = AckResponse { + case_id: "550e8400-e29b-41d4-a716-446655440000".into(), + recorded_at: "2026-04-15T10:32:00Z".into(), + status: AckStatus::Received, + }; + let json = serde_json::to_string(&original).unwrap(); + let decoded: AckResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.case_id, original.case_id); + assert_eq!(decoded.recorded_at, original.recorded_at); + assert_eq!(decoded.status, original.status); + } +} diff --git a/doctate-common/src/constants.rs b/doctate-common/src/constants.rs new file mode 100644 index 0000000..13e751d --- /dev/null +++ b/doctate-common/src/constants.rs @@ -0,0 +1,14 @@ +/// HTTP header that carries the per-user API key on `/api/...` requests. +pub const API_KEY_HEADER: &str = "X-API-Key"; + +/// Server endpoint path for recording upload (relative to the server base URL). +pub const UPLOAD_PATH: &str = "/api/upload"; + +// -- Multipart field names for POST /api/upload -- + +pub const FIELD_CASE_ID: &str = "case_id"; +pub const FIELD_RECORDED_AT: &str = "recorded_at"; +pub const FIELD_AUDIO: &str = "audio"; + +/// Content-Type of the audio part in the upload multipart body (m4a/AAC). +pub const CONTENT_TYPE_AUDIO_MP4: &str = "audio/mp4"; diff --git a/doctate-common/src/lib.rs b/doctate-common/src/lib.rs new file mode 100644 index 0000000..6d421d9 --- /dev/null +++ b/doctate-common/src/lib.rs @@ -0,0 +1,10 @@ +pub mod ack; +pub mod constants; +pub mod timestamp; + +pub use ack::{AckResponse, AckStatus}; +pub use constants::{ + API_KEY_HEADER, CONTENT_TYPE_AUDIO_MP4, FIELD_AUDIO, FIELD_CASE_ID, FIELD_RECORDED_AT, + UPLOAD_PATH, +}; +pub use timestamp::{filename_stem_to_recorded_at, now_rfc3339, recorded_at_to_filename_stem}; diff --git a/doctate-common/src/timestamp.rs b/doctate-common/src/timestamp.rs new file mode 100644 index 0000000..889da7b --- /dev/null +++ b/doctate-common/src/timestamp.rs @@ -0,0 +1,72 @@ +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +/// Current UTC time as an RFC3339 string (e.g. `2026-04-15T10:32:00.123Z`). +/// +/// Formatting an `OffsetDateTime` as RFC3339 is infallible — every required +/// component is always present — so we panic on error rather than propagate. +pub fn now_rfc3339() -> String { + OffsetDateTime::now_utc() + .format(&Rfc3339) + .expect("RFC3339 formatting of OffsetDateTime is infallible") +} + +/// Map an RFC3339 timestamp to a filesystem-safe stem by replacing every +/// `:` with `-`. Intentionally naive — RFC3339 has no colons outside the +/// time/offset portion, so the result is bijective against +/// `filename_stem_to_recorded_at`. +/// +/// Example: `"2026-04-15T10:32:00Z"` → `"2026-04-15T10-32-00Z"`. +pub fn recorded_at_to_filename_stem(recorded_at: &str) -> String { + recorded_at.replace(':', "-") +} + +/// Reverse of `recorded_at_to_filename_stem`. Only undoes the mapping in +/// the time portion (after `T`) so date-part hyphens stay intact. +/// +/// Example: `"2026-04-15T10-32-00Z"` → `"2026-04-15T10:32:00Z"`. +pub 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(), + } +} + +#[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" + ); + assert_eq!( + filename_stem_to_recorded_at("2026-04-15T10-32-00.123Z"), + "2026-04-15T10:32:00.123Z" + ); + } + + #[test] + fn recorded_at_stem_roundtrip_utc() { + let original = "2026-04-15T10:32:00Z"; + let stem = recorded_at_to_filename_stem(original); + assert_eq!(stem, "2026-04-15T10-32-00Z"); + assert_eq!(filename_stem_to_recorded_at(&stem), original); + } + + #[test] + fn recorded_at_stem_roundtrip_with_milliseconds() { + let original = "2026-04-15T10:32:00.123Z"; + let stem = recorded_at_to_filename_stem(original); + assert_eq!(filename_stem_to_recorded_at(&stem), original); + } + + #[test] + fn now_rfc3339_has_expected_shape() { + let now = now_rfc3339(); + assert!(now.starts_with("20"), "unexpected year prefix: {now}"); + assert!(now.contains('T'), "missing T separator: {now}"); + } +}