Skip to main content

oximedia_review/
lib.rs

1//! Collaborative review and approval workflow for `OxiMedia`.
2//!
3//! This crate provides comprehensive review and approval capabilities for video content,
4//! including:
5//!
6//! - Frame-accurate comments and annotations
7//! - Real-time collaboration with multiple reviewers
8//! - Version comparison and tracking
9//! - Multi-stage approval workflows
10//! - Task assignment and tracking
11//! - Drawing tools for visual feedback
12//! - Notification system (email, webhook)
13//! - Export capabilities (PDF, CSV, EDL)
14//!
15//! # Example
16//!
17//! ```
18//! use oximedia_review::{ReviewSession, SessionConfig, AnnotationType};
19//!
20//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
21//! // Create a review session
22//! let config = SessionConfig::builder()
23//!     .title("Final Cut Review")
24//!     .content_id("video-123")
25//!     .workflow_type(oximedia_review::WorkflowType::MultiStage)
26//!     .build()?;
27//!
28//! let session = ReviewSession::create(config).await?;
29//!
30//! // Add a frame-accurate comment
31//! session.add_comment(
32//!     1000, // frame number
33//!     "Please adjust color grading",
34//!     AnnotationType::Issue,
35//! ).await?;
36//!
37//! // Invite reviewers
38//! session.invite_user("reviewer@example.com").await?;
39//! # Ok(())
40//! # }
41//! ```
42
43#![warn(missing_docs)]
44#![allow(clippy::cast_precision_loss)]
45#![allow(clippy::cast_possible_truncation)]
46#![allow(clippy::cast_sign_loss)]
47#![allow(clippy::unused_async)]
48#![allow(clippy::struct_excessive_bools)]
49#![allow(clippy::similar_names)]
50#![allow(clippy::match_same_arms)]
51#![allow(clippy::unused_self)]
52#![allow(clippy::missing_errors_doc)]
53#![allow(clippy::format_push_string)]
54#![allow(clippy::match_like_matches_macro)]
55
56use chrono::{DateTime, Utc};
57use serde::{Deserialize, Serialize};
58use std::collections::HashMap;
59use uuid::Uuid;
60
61pub mod annotation;
62pub mod annotation_export;
63pub mod annotations;
64pub mod approval;
65pub mod approval_workflow;
66pub mod batch_ops;
67pub mod change;
68pub mod comment;
69pub mod comment_thread;
70pub mod compare;
71pub mod comparison_mode;
72pub mod deadline;
73pub mod delivery;
74pub mod drawing;
75pub mod export;
76pub mod feedback_round;
77pub mod marker;
78pub mod notify;
79pub mod offline_review;
80pub mod realtime;
81pub mod realtime_delta;
82pub mod report;
83pub mod review_api;
84pub mod review_automation;
85pub mod review_checklist;
86pub mod review_comparator;
87pub mod review_diff;
88pub mod review_export;
89pub mod review_history;
90pub mod review_link;
91pub mod review_metrics;
92pub mod review_notification_rule;
93pub mod review_permission;
94pub mod review_playlist;
95pub mod review_priority;
96pub mod review_session;
97pub mod review_snapshot;
98pub mod review_status;
99pub mod review_tag;
100pub mod review_template;
101pub mod session;
102pub mod status;
103pub mod task;
104pub mod timeline_note;
105pub mod version;
106pub mod version_compare;
107pub mod version_lazy;
108
109/// Error types for review operations.
110pub mod error;
111
112pub use compare::{
113    apply_compare_filter, CompareFilter, CompareLayout, CompareResult, CompareVersion, DiffStats,
114    MediaComparator, WipeAngle,
115};
116pub use error::{ReviewError, ReviewResult};
117pub use session::ReviewSession;
118pub use timeline_note::{NoteType, TimeRange, TimelineNote, TimelineNoteCollection};
119
120/// Unique identifier for a review session.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
122pub struct SessionId(Uuid);
123
124impl SessionId {
125    /// Create a new session ID.
126    #[must_use]
127    pub fn new() -> Self {
128        Self(Uuid::new_v4())
129    }
130
131    /// Get the inner UUID.
132    #[must_use]
133    pub fn as_uuid(&self) -> &Uuid {
134        &self.0
135    }
136}
137
138impl Default for SessionId {
139    fn default() -> Self {
140        Self::new()
141    }
142}
143
144impl std::fmt::Display for SessionId {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        write!(f, "{}", self.0)
147    }
148}
149
150/// Unique identifier for a comment.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
152pub struct CommentId(Uuid);
153
154impl CommentId {
155    /// Create a new comment ID.
156    #[must_use]
157    pub fn new() -> Self {
158        Self(Uuid::new_v4())
159    }
160
161    /// Get the inner UUID.
162    #[must_use]
163    pub fn as_uuid(&self) -> &Uuid {
164        &self.0
165    }
166}
167
168impl Default for CommentId {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174impl std::fmt::Display for CommentId {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        write!(f, "{}", self.0)
177    }
178}
179
180/// Unique identifier for a drawing/annotation.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
182pub struct DrawingId(Uuid);
183
184impl DrawingId {
185    /// Create a new drawing ID.
186    #[must_use]
187    pub fn new() -> Self {
188        Self(Uuid::new_v4())
189    }
190
191    /// Get the inner UUID.
192    #[must_use]
193    pub fn as_uuid(&self) -> &Uuid {
194        &self.0
195    }
196}
197
198impl Default for DrawingId {
199    fn default() -> Self {
200        Self::new()
201    }
202}
203
204impl std::fmt::Display for DrawingId {
205    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206        write!(f, "{}", self.0)
207    }
208}
209
210/// Unique identifier for a task.
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
212pub struct TaskId(Uuid);
213
214impl TaskId {
215    /// Create a new task ID.
216    #[must_use]
217    pub fn new() -> Self {
218        Self(Uuid::new_v4())
219    }
220
221    /// Get the inner UUID.
222    #[must_use]
223    pub fn as_uuid(&self) -> &Uuid {
224        &self.0
225    }
226}
227
228impl Default for TaskId {
229    fn default() -> Self {
230        Self::new()
231    }
232}
233
234impl std::fmt::Display for TaskId {
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        write!(f, "{}", self.0)
237    }
238}
239
240/// Unique identifier for a version.
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
242pub struct VersionId(Uuid);
243
244impl VersionId {
245    /// Create a new version ID.
246    #[must_use]
247    pub fn new() -> Self {
248        Self(Uuid::new_v4())
249    }
250
251    /// Get the inner UUID.
252    #[must_use]
253    pub fn as_uuid(&self) -> &Uuid {
254        &self.0
255    }
256}
257
258impl Default for VersionId {
259    fn default() -> Self {
260        Self::new()
261    }
262}
263
264impl std::fmt::Display for VersionId {
265    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266        write!(f, "{}", self.0)
267    }
268}
269
270/// User information.
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct User {
273    /// User ID.
274    pub id: String,
275    /// User name.
276    pub name: String,
277    /// User email.
278    pub email: String,
279    /// User role in the review.
280    pub role: UserRole,
281}
282
283/// User role in a review session.
284#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
285pub enum UserRole {
286    /// Session owner/creator.
287    Owner,
288    /// Reviewer with approval rights.
289    Approver,
290    /// Reviewer without approval rights.
291    Reviewer,
292    /// Observer (read-only).
293    Observer,
294}
295
296/// Type of annotation/comment.
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
298pub enum AnnotationType {
299    /// General feedback comment.
300    General,
301    /// Issue that needs to be fixed.
302    Issue,
303    /// Optional suggestion for improvement.
304    Suggestion,
305    /// Question requiring clarification.
306    Question,
307    /// Approval marker.
308    Approval,
309    /// Rejection marker.
310    Rejection,
311}
312
313/// Workflow type for review sessions.
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
315pub enum WorkflowType {
316    /// Simple workflow: Creator → Reviewer → Approved.
317    Simple,
318    /// Multi-stage workflow: Multiple sequential stages.
319    MultiStage,
320    /// Parallel workflow: Multiple reviewers simultaneously.
321    Parallel,
322    /// Sequential workflow: One reviewer after another.
323    Sequential,
324}
325
326/// Configuration for creating a review session.
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct SessionConfig {
329    /// Session title.
330    pub title: String,
331    /// ID of the content being reviewed.
332    pub content_id: String,
333    /// Workflow type.
334    pub workflow_type: WorkflowType,
335    /// Optional description.
336    pub description: Option<String>,
337    /// Optional deadline.
338    pub deadline: Option<DateTime<Utc>>,
339    /// Custom metadata.
340    pub metadata: HashMap<String, String>,
341}
342
343impl SessionConfig {
344    /// Create a new builder for session configuration.
345    #[must_use]
346    pub fn builder() -> SessionConfigBuilder {
347        SessionConfigBuilder::default()
348    }
349}
350
351/// Builder for `SessionConfig`.
352#[derive(Default)]
353pub struct SessionConfigBuilder {
354    title: Option<String>,
355    content_id: Option<String>,
356    workflow_type: Option<WorkflowType>,
357    description: Option<String>,
358    deadline: Option<DateTime<Utc>>,
359    metadata: HashMap<String, String>,
360}
361
362impl SessionConfigBuilder {
363    /// Set the session title.
364    #[must_use]
365    pub fn title(mut self, title: impl Into<String>) -> Self {
366        self.title = Some(title.into());
367        self
368    }
369
370    /// Set the content ID.
371    #[must_use]
372    pub fn content_id(mut self, id: impl Into<String>) -> Self {
373        self.content_id = Some(id.into());
374        self
375    }
376
377    /// Set the workflow type.
378    #[must_use]
379    pub fn workflow_type(mut self, workflow: WorkflowType) -> Self {
380        self.workflow_type = Some(workflow);
381        self
382    }
383
384    /// Set the description.
385    #[must_use]
386    pub fn description(mut self, desc: impl Into<String>) -> Self {
387        self.description = Some(desc.into());
388        self
389    }
390
391    /// Set the deadline.
392    #[must_use]
393    pub fn deadline(mut self, deadline: DateTime<Utc>) -> Self {
394        self.deadline = Some(deadline);
395        self
396    }
397
398    /// Add metadata key-value pair.
399    #[must_use]
400    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
401        self.metadata.insert(key.into(), value.into());
402        self
403    }
404
405    /// Build the configuration.
406    ///
407    /// # Errors
408    ///
409    /// Returns `ReviewError::InvalidConfig` if required fields (`title`, `content_id`) are missing
410    /// or empty.
411    pub fn build(self) -> crate::error::ReviewResult<SessionConfig> {
412        let title = self
413            .title
414            .filter(|t| !t.is_empty())
415            .ok_or_else(|| crate::error::ReviewError::InvalidConfig("title is required".into()))?;
416        let content_id = self.content_id.filter(|c| !c.is_empty()).ok_or_else(|| {
417            crate::error::ReviewError::InvalidConfig("content_id is required".into())
418        })?;
419        Ok(SessionConfig {
420            title,
421            content_id,
422            workflow_type: self.workflow_type.unwrap_or(WorkflowType::Simple),
423            description: self.description,
424            deadline: self.deadline,
425            metadata: self.metadata,
426        })
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn test_session_id_creation() {
436        let id1 = SessionId::new();
437        let id2 = SessionId::new();
438        assert_ne!(id1, id2);
439    }
440
441    #[test]
442    fn test_comment_id_creation() {
443        let id1 = CommentId::new();
444        let id2 = CommentId::new();
445        assert_ne!(id1, id2);
446    }
447
448    #[test]
449    fn test_session_config_builder() {
450        let config = SessionConfig::builder()
451            .title("Test Session")
452            .content_id("video-123")
453            .workflow_type(WorkflowType::Simple)
454            .description("Test description")
455            .metadata("key", "value")
456            .build()
457            .expect("valid config");
458
459        assert_eq!(config.title, "Test Session");
460        assert_eq!(config.content_id, "video-123");
461        assert_eq!(config.workflow_type, WorkflowType::Simple);
462        assert_eq!(config.description, Some("Test description".to_string()));
463        assert_eq!(config.metadata.get("key"), Some(&"value".to_string()));
464    }
465
466    #[test]
467    fn test_session_config_builder_missing_title_errors() {
468        let result = SessionConfig::builder().content_id("video-123").build();
469        assert!(result.is_err());
470    }
471
472    #[test]
473    fn test_session_config_builder_missing_content_id_errors() {
474        let result = SessionConfig::builder().title("My Review").build();
475        assert!(result.is_err());
476    }
477
478    #[test]
479    fn test_user_role_equality() {
480        assert_eq!(UserRole::Owner, UserRole::Owner);
481        assert_ne!(UserRole::Owner, UserRole::Reviewer);
482    }
483
484    #[test]
485    fn test_annotation_type_equality() {
486        assert_eq!(AnnotationType::Issue, AnnotationType::Issue);
487        assert_ne!(AnnotationType::Issue, AnnotationType::Suggestion);
488    }
489}