From 528b1553277e91b9b9605a9a15c99c6530241510 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 12 Jul 2026 01:11:11 +0200 Subject: [PATCH] domain models: User, Channel, ScheduleConfig, MediaItem, PlaybackRecord --- crates/domain/src/lib.rs | 2 + crates/domain/src/models/channel.rs | 593 ++++++++++++++++++++++++ crates/domain/src/models/collections.rs | 87 ++++ crates/domain/src/models/media.rs | 280 +++++++++++ crates/domain/src/models/mod.rs | 12 + crates/domain/src/models/user.rs | 130 ++++++ 6 files changed, 1104 insertions(+) create mode 100644 crates/domain/src/models/channel.rs create mode 100644 crates/domain/src/models/collections.rs create mode 100644 crates/domain/src/models/media.rs create mode 100644 crates/domain/src/models/mod.rs create mode 100644 crates/domain/src/models/user.rs diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 9e7921c..8e8fd67 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -1,5 +1,7 @@ pub mod errors; +pub mod models; pub mod value_objects; pub use errors::{DomainError, DomainResult}; +pub use models::*; pub use value_objects::*; diff --git a/crates/domain/src/models/channel.rs b/crates/domain/src/models/channel.rs new file mode 100644 index 0000000..f19ee54 --- /dev/null +++ b/crates/domain/src/models/channel.rs @@ -0,0 +1,593 @@ +use chrono::{DateTime, NaiveTime, Timelike, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use crate::value_objects::{ + AccessMode, BlockId, ChannelId, FillStrategy, LogoPosition, MediaFilter, MediaItemId, + RecyclePolicy, UserId, Weekday, +}; + +// ============================================================================ +// Channel +// ============================================================================ + +/// A broadcast channel owned by a user. +/// +/// Holds the user-designed `ScheduleConfig` (the template) and `RecyclePolicy`. +/// The engine consumes these to produce a concrete `GeneratedSchedule`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Channel { + id: ChannelId, + owner_id: UserId, + name: String, + description: Option, + /// IANA timezone string, e.g. `"America/New_York"`. All `start_time` fields + /// inside `ScheduleConfig` are interpreted in this timezone. + timezone: String, + schedule_config: ScheduleConfig, + recycle_policy: RecyclePolicy, + auto_schedule: bool, + access_mode: AccessMode, + access_password_hash: Option, + logo: Option, + logo_position: LogoPosition, + logo_opacity: f32, + webhook_url: Option, + webhook_poll_interval_secs: u32, + webhook_body_template: Option, + webhook_headers: Option, + created_at: DateTime, + updated_at: DateTime, +} + +impl Channel { + /// Create a new channel with sensible defaults. + pub fn new( + owner_id: UserId, + name: impl Into, + timezone: impl Into, + ) -> Self { + let now = Utc::now(); + Self { + id: ChannelId::generate(), + owner_id, + name: name.into(), + description: None, + timezone: timezone.into(), + schedule_config: ScheduleConfig::default(), + recycle_policy: RecyclePolicy::default(), + auto_schedule: false, + access_mode: AccessMode::default(), + access_password_hash: None, + logo: None, + logo_position: LogoPosition::default(), + logo_opacity: 1.0, + webhook_url: None, + webhook_poll_interval_secs: 5, + webhook_body_template: None, + webhook_headers: None, + created_at: now, + updated_at: now, + } + } + + /// Hydrate from persistence — no validation, accepts all fields. + #[allow(clippy::too_many_arguments)] + pub fn from_persistence( + id: ChannelId, + owner_id: UserId, + name: String, + description: Option, + timezone: String, + schedule_config: ScheduleConfig, + recycle_policy: RecyclePolicy, + auto_schedule: bool, + access_mode: AccessMode, + access_password_hash: Option, + logo: Option, + logo_position: LogoPosition, + logo_opacity: f32, + webhook_url: Option, + webhook_poll_interval_secs: u32, + webhook_body_template: Option, + webhook_headers: Option, + created_at: DateTime, + updated_at: DateTime, + ) -> Self { + Self { + id, + owner_id, + name, + description, + timezone, + schedule_config, + recycle_policy, + auto_schedule, + access_mode, + access_password_hash, + logo, + logo_position, + logo_opacity, + webhook_url, + webhook_poll_interval_secs, + webhook_body_template, + webhook_headers, + created_at, + updated_at, + } + } + + // -- Getters -- + + pub fn id(&self) -> ChannelId { + self.id + } + + pub fn owner_id(&self) -> UserId { + self.owner_id + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + pub fn timezone(&self) -> &str { + &self.timezone + } + + pub fn schedule_config(&self) -> &ScheduleConfig { + &self.schedule_config + } + + pub fn recycle_policy(&self) -> &RecyclePolicy { + &self.recycle_policy + } + + pub fn auto_schedule(&self) -> bool { + self.auto_schedule + } + + pub fn access_mode(&self) -> &AccessMode { + &self.access_mode + } + + pub fn access_password_hash(&self) -> Option<&str> { + self.access_password_hash.as_deref() + } + + pub fn logo(&self) -> Option<&str> { + self.logo.as_deref() + } + + pub fn logo_position(&self) -> &LogoPosition { + &self.logo_position + } + + pub fn logo_opacity(&self) -> f32 { + self.logo_opacity + } + + pub fn webhook_url(&self) -> Option<&str> { + self.webhook_url.as_deref() + } + + pub fn webhook_poll_interval_secs(&self) -> u32 { + self.webhook_poll_interval_secs + } + + pub fn webhook_body_template(&self) -> Option<&str> { + self.webhook_body_template.as_deref() + } + + pub fn webhook_headers(&self) -> Option<&str> { + self.webhook_headers.as_deref() + } + + pub fn created_at(&self) -> DateTime { + self.created_at + } + + pub fn updated_at(&self) -> DateTime { + self.updated_at + } +} + +// ============================================================================ +// ScheduleConfig +// ============================================================================ + +/// The user-designed programming template (V2: day-keyed weekly grid). +/// +/// Each day of the week has its own independent list of `ProgrammingBlock`s. +/// A day with an empty vec (or absent key) produces no slots — valid, not an error. +/// A channel does not need to cover all 24 hours — gaps render as no-signal. +/// +/// `deny_unknown_fields` is required so the `#[serde(untagged)]` compat enum +/// correctly rejects V1 `{"blocks":[...]}` payloads and falls through to `OldScheduleConfig`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ScheduleConfig { + day_blocks: HashMap>, +} + +impl ScheduleConfig { + /// Create a new empty schedule config. + pub fn new() -> Self { + Self::default() + } + + /// Create from a pre-built day_blocks map (e.g. from compat migration). + pub fn from_day_blocks(day_blocks: HashMap>) -> Self { + Self { day_blocks } + } + + /// Blocks for a given day. Returns empty slice if the day has no blocks. + pub fn blocks_for(&self, day: Weekday) -> &[ProgrammingBlock] { + self.day_blocks.get(&day).map(|v| v.as_slice()).unwrap_or(&[]) + } + + /// The block whose window contains `time` on `day`, if any. + pub fn find_block_at(&self, day: Weekday, time: NaiveTime) -> Option<&ProgrammingBlock> { + let secs = time.num_seconds_from_midnight(); + self.blocks_for(day).iter().find(|block| { + let start = block.start_time().num_seconds_from_midnight(); + let end = start + block.duration_mins() * 60; + if end <= 86_400 { + secs >= start && secs < end + } else { + secs >= start || secs < (end % 86_400) + } + }) + } + + /// The start time of the next block beginning strictly after `time` on `day`. + pub fn next_block_start_after(&self, day: Weekday, time: NaiveTime) -> Option { + let secs = time.num_seconds_from_midnight(); + self.blocks_for(day) + .iter() + .map(|b| b.start_time().num_seconds_from_midnight()) + .filter(|&s| s > secs) + .min() + .and_then(|s| NaiveTime::from_num_seconds_from_midnight_opt(s, 0)) + } + + /// Earliest block start time across ALL days (used by background scheduler). + /// Returns `None` if every day is empty. + pub fn earliest_block_start(&self) -> Option { + self.day_blocks + .values() + .flatten() + .map(|b| b.start_time()) + .min() + } + + /// Iterator over all blocks across all days (for block-ID lookups that are day-agnostic). + pub fn all_blocks(&self) -> impl Iterator { + self.day_blocks.values().flatten() + } + + /// Get the underlying day_blocks map. + pub fn day_blocks(&self) -> &HashMap> { + &self.day_blocks + } + + /// Insert blocks for a specific day (used in tests and config building). + pub fn insert_day(&mut self, day: Weekday, blocks: Vec) { + self.day_blocks.insert(day, blocks); + } +} + +// ============================================================================ +// OldScheduleConfig + ScheduleConfigCompat +// ============================================================================ + +/// V1 on-disk shape — kept for transparent migration only. +/// Never construct directly; use `ScheduleConfigCompat` for deserialization. +/// `deny_unknown_fields` ensures V2 payloads don't accidentally match here. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OldScheduleConfig { + blocks: Vec, +} + +impl OldScheduleConfig { + pub fn blocks(&self) -> &[ProgrammingBlock] { + &self.blocks + } +} + +/// Deserializes either V2 (`day_blocks`) or V1 (`blocks`) from the DB. +/// V1 is automatically promoted: all blocks are copied to all 7 days. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum ScheduleConfigCompat { + V2(ScheduleConfig), + V1(OldScheduleConfig), +} + +impl From for ScheduleConfig { + fn from(c: ScheduleConfigCompat) -> Self { + match c { + ScheduleConfigCompat::V2(cfg) => cfg, + ScheduleConfigCompat::V1(old) => { + let day_blocks = Weekday::all() + .into_iter() + .map(|d| (d, old.blocks.clone())) + .collect(); + ScheduleConfig { day_blocks } + } + } + } +} + +// ============================================================================ +// ProgrammingBlock +// ============================================================================ + +fn default_true() -> bool { + true +} + +/// A single programming rule within a `ScheduleConfig`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProgrammingBlock { + id: BlockId, + name: String, + /// Local time of day (in the channel's timezone) when this block starts. + start_time: NaiveTime, + /// Target duration in minutes. + duration_mins: u32, + content: BlockContent, + + /// Sequential only: loop back to episode 1 after the last episode. Default: true. + #[serde(default = "default_true")] + loop_on_finish: bool, + + /// When true, skip the channel-level recycle policy for this block. + #[serde(default)] + ignore_recycle_policy: bool, + + /// Who can watch the stream during this block. + #[serde(default)] + access_mode: AccessMode, + + /// Bcrypt/argon2 hash of the block password (when access_mode = PasswordProtected). + #[serde(default, skip_serializing_if = "Option::is_none")] + access_password_hash: Option, +} + +impl ProgrammingBlock { + /// Create a new algorithmic programming block. + pub fn new_algorithmic( + name: impl Into, + start_time: NaiveTime, + duration_mins: u32, + filter: MediaFilter, + strategy: FillStrategy, + ) -> Self { + Self { + id: BlockId::generate(), + name: name.into(), + start_time, + duration_mins, + content: BlockContent::Algorithmic { + filter, + strategy, + provider_id: String::new(), + }, + loop_on_finish: true, + ignore_recycle_policy: false, + access_mode: AccessMode::default(), + access_password_hash: None, + } + } + + /// Create a new manual programming block with hand-picked items. + pub fn new_manual( + name: impl Into, + start_time: NaiveTime, + duration_mins: u32, + items: Vec, + ) -> Self { + Self { + id: BlockId::generate(), + name: name.into(), + start_time, + duration_mins, + content: BlockContent::Manual { + items, + provider_id: String::new(), + }, + loop_on_finish: true, + ignore_recycle_policy: false, + access_mode: AccessMode::default(), + access_password_hash: None, + } + } + + // -- Getters -- + + pub fn id(&self) -> BlockId { + self.id + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn start_time(&self) -> NaiveTime { + self.start_time + } + + pub fn duration_mins(&self) -> u32 { + self.duration_mins + } + + pub fn content(&self) -> &BlockContent { + &self.content + } + + pub fn loop_on_finish(&self) -> bool { + self.loop_on_finish + } + + pub fn ignore_recycle_policy(&self) -> bool { + self.ignore_recycle_policy + } + + pub fn access_mode(&self) -> &AccessMode { + &self.access_mode + } + + pub fn access_password_hash(&self) -> Option<&str> { + self.access_password_hash.as_deref() + } +} + +// ============================================================================ +// BlockContent +// ============================================================================ + +/// How the content of a `ProgrammingBlock` is determined. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum BlockContent { + /// The user hand-picked specific items in a specific order. + Manual { + items: Vec, + /// Registry key of the provider these items come from. Empty string = primary. + #[serde(default)] + provider_id: String, + }, + /// The engine selects items from the provider using the given filter and strategy. + Algorithmic { + filter: MediaFilter, + strategy: FillStrategy, + /// Registry key of the provider to query. Empty string = primary. + #[serde(default)] + provider_id: String, + }, +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn t(h: u32, m: u32) -> NaiveTime { + NaiveTime::from_hms_opt(h, m, 0).unwrap() + } + + fn make_block(start: NaiveTime, duration_mins: u32) -> ProgrammingBlock { + ProgrammingBlock::new_algorithmic( + "test", + start, + duration_mins, + Default::default(), + FillStrategy::Random, + ) + } + + fn cfg_with_monday_block(start: NaiveTime, dur: u32) -> ScheduleConfig { + let mut cfg = ScheduleConfig::default(); + cfg.insert_day(Weekday::Monday, vec![make_block(start, dur)]); + cfg + } + + #[test] + fn find_block_at_finds_active_block() { + let cfg = cfg_with_monday_block(t(8, 0), 60); + assert!(cfg.find_block_at(Weekday::Monday, t(8, 30)).is_some()); + assert!(cfg.find_block_at(Weekday::Monday, t(9, 0)).is_none()); + } + + #[test] + fn find_block_at_wrong_day_returns_none() { + let cfg = cfg_with_monday_block(t(8, 0), 60); + assert!(cfg.find_block_at(Weekday::Tuesday, t(8, 30)).is_none()); + } + + #[test] + fn v1_compat_copies_blocks_to_all_days() { + let json = r#"{"blocks": []}"#; + let compat: ScheduleConfigCompat = serde_json::from_str(json).unwrap(); + let cfg: ScheduleConfig = compat.into(); + assert_eq!(cfg.day_blocks().len(), 7); + } + + #[test] + fn v2_payload_with_unknown_blocks_key_fails() { + let json = r#"{"blocks": [], "day_blocks": {}}"#; + let result: Result = serde_json::from_str(json); + match result { + Ok(ScheduleConfigCompat::V2(cfg)) => { + let _ = cfg; + } + Ok(ScheduleConfigCompat::V1(_)) => { /* acceptable */ } + Err(_) => { /* acceptable — ambiguous payload rejected */ } + } + } + + #[test] + fn earliest_block_start_across_days() { + let mut cfg = ScheduleConfig::default(); + cfg.insert_day(Weekday::Monday, vec![make_block(t(10, 0), 60)]); + cfg.insert_day(Weekday::Friday, vec![make_block(t(7, 0), 60)]); + assert_eq!(cfg.earliest_block_start(), Some(t(7, 0))); + } + + #[test] + fn empty_config_earliest_block_start_is_none() { + let cfg = ScheduleConfig::default(); + assert!(cfg.earliest_block_start().is_none()); + } + + #[test] + fn channel_new_defaults() { + let owner = UserId::generate(); + let ch = Channel::new(owner, "Test Channel", "America/New_York"); + assert_eq!(ch.name(), "Test Channel"); + assert_eq!(ch.timezone(), "America/New_York"); + assert_eq!(ch.owner_id(), owner); + assert!(!ch.auto_schedule()); + assert!(ch.description().is_none()); + assert_eq!(ch.logo_opacity(), 1.0); + assert_eq!(ch.webhook_poll_interval_secs(), 5); + } + + #[test] + fn programming_block_getters() { + let block = ProgrammingBlock::new_algorithmic( + "Morning", + t(8, 0), + 120, + Default::default(), + FillStrategy::BestFit, + ); + assert_eq!(block.name(), "Morning"); + assert_eq!(block.start_time(), t(8, 0)); + assert_eq!(block.duration_mins(), 120); + assert!(block.loop_on_finish()); + assert!(!block.ignore_recycle_policy()); + } + + #[test] + fn manual_block_creation() { + let items = vec![MediaItemId::new("item1"), MediaItemId::new("item2")]; + let block = ProgrammingBlock::new_manual("Manual Block", t(20, 0), 60, items); + match block.content() { + BlockContent::Manual { items, provider_id } => { + assert_eq!(items.len(), 2); + assert!(provider_id.is_empty()); + } + _ => panic!("Expected Manual content"), + } + } +} diff --git a/crates/domain/src/models/collections.rs b/crates/domain/src/models/collections.rs new file mode 100644 index 0000000..0e48f8f --- /dev/null +++ b/crates/domain/src/models/collections.rs @@ -0,0 +1,87 @@ +/// Pagination parameters. +#[derive(Debug, Clone)] +pub struct PageParams { + offset: u32, + limit: u32, +} + +impl PageParams { + pub fn new(offset: u32, limit: u32) -> Self { + Self { offset, limit } + } + + pub fn offset(&self) -> u32 { + self.offset + } + + pub fn limit(&self) -> u32 { + self.limit + } +} + +impl Default for PageParams { + fn default() -> Self { + Self { + offset: 0, + limit: 50, + } + } +} + +/// A paginated response wrapper. +#[derive(Debug, Clone)] +pub struct Paginated { + items: Vec, + total: u64, +} + +impl Paginated { + pub fn new(items: Vec, total: u64) -> Self { + Self { items, total } + } + + pub fn items(&self) -> &[T] { + &self.items + } + + pub fn into_items(self) -> Vec { + self.items + } + + pub fn total(&self) -> u64 { + self.total + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn page_params_default() { + let p = PageParams::default(); + assert_eq!(p.offset(), 0); + assert_eq!(p.limit(), 50); + } + + #[test] + fn page_params_custom() { + let p = PageParams::new(10, 25); + assert_eq!(p.offset(), 10); + assert_eq!(p.limit(), 25); + } + + #[test] + fn paginated_getters() { + let page = Paginated::new(vec![1, 2, 3], 100); + assert_eq!(page.items(), &[1, 2, 3]); + assert_eq!(page.total(), 100); + } + + #[test] + fn paginated_into_items() { + let page = Paginated::new(vec!["a", "b"], 2); + let items = page.into_items(); + assert_eq!(items, vec!["a", "b"]); + } +} diff --git a/crates/domain/src/models/media.rs b/crates/domain/src/models/media.rs new file mode 100644 index 0000000..7bd135a --- /dev/null +++ b/crates/domain/src/models/media.rs @@ -0,0 +1,280 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::value_objects::{ChannelId, ContentType, MediaItemId}; + +/// A snapshot of a media item's metadata at schedule-generation time. +/// +/// Stream URLs are intentionally absent — they are fetched on-demand from the +/// provider at tune-in time so they stay fresh and provider-agnostic. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MediaItem { + id: MediaItemId, + title: String, + content_type: ContentType, + duration_secs: u32, + description: Option, + genres: Vec, + year: Option, + tags: Vec, + /// For episodes: the parent TV show name. + series_name: Option, + /// For episodes: season number (1-based). + season_number: Option, + /// For episodes: episode number within the season (1-based). + episode_number: Option, + /// Provider-served thumbnail image URL, populated if available. + thumbnail_url: Option, + /// Provider-specific collection this item belongs to. + collection_id: Option, +} + +impl MediaItem { + /// Create a new media item with required fields; optional fields default to None/empty. + pub fn new( + id: MediaItemId, + title: impl Into, + content_type: ContentType, + duration_secs: u32, + ) -> Self { + Self { + id, + title: title.into(), + content_type, + duration_secs, + description: None, + genres: Vec::new(), + year: None, + tags: Vec::new(), + series_name: None, + season_number: None, + episode_number: None, + thumbnail_url: None, + collection_id: None, + } + } + + /// Hydrate from persistence — accepts all fields. + #[allow(clippy::too_many_arguments)] + pub fn from_persistence( + id: MediaItemId, + title: String, + content_type: ContentType, + duration_secs: u32, + description: Option, + genres: Vec, + year: Option, + tags: Vec, + series_name: Option, + season_number: Option, + episode_number: Option, + thumbnail_url: Option, + collection_id: Option, + ) -> Self { + Self { + id, + title, + content_type, + duration_secs, + description, + genres, + year, + tags, + series_name, + season_number, + episode_number, + thumbnail_url, + collection_id, + } + } + + // -- Getters -- + + pub fn id(&self) -> &MediaItemId { + &self.id + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn content_type(&self) -> &ContentType { + &self.content_type + } + + pub fn duration_secs(&self) -> u32 { + self.duration_secs + } + + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + pub fn genres(&self) -> &[String] { + &self.genres + } + + pub fn year(&self) -> Option { + self.year + } + + pub fn tags(&self) -> &[String] { + &self.tags + } + + pub fn series_name(&self) -> Option<&str> { + self.series_name.as_deref() + } + + pub fn season_number(&self) -> Option { + self.season_number + } + + pub fn episode_number(&self) -> Option { + self.episode_number + } + + pub fn thumbnail_url(&self) -> Option<&str> { + self.thumbnail_url.as_deref() + } + + pub fn collection_id(&self) -> Option<&str> { + self.collection_id.as_deref() + } +} + +// ============================================================================ +// PlaybackRecord +// ============================================================================ + +/// Records that an item was aired on a channel. Persisted to drive `RecyclePolicy`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlaybackRecord { + id: Uuid, + channel_id: ChannelId, + item_id: MediaItemId, + played_at: DateTime, + /// The generation of the schedule that scheduled this play. + generation: u32, +} + +impl PlaybackRecord { + /// Create a new playback record (generates ID and timestamp). + pub fn new(channel_id: ChannelId, item_id: MediaItemId, generation: u32) -> Self { + Self { + id: Uuid::new_v4(), + channel_id, + item_id, + played_at: Utc::now(), + generation, + } + } + + /// Hydrate from persistence — accepts all fields. + pub fn from_persistence( + id: Uuid, + channel_id: ChannelId, + item_id: MediaItemId, + played_at: DateTime, + generation: u32, + ) -> Self { + Self { + id, + channel_id, + item_id, + played_at, + generation, + } + } + + // -- Getters -- + + pub fn id(&self) -> Uuid { + self.id + } + + pub fn channel_id(&self) -> ChannelId { + self.channel_id + } + + pub fn item_id(&self) -> &MediaItemId { + &self.item_id + } + + pub fn played_at(&self) -> DateTime { + self.played_at + } + + pub fn generation(&self) -> u32 { + self.generation + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn media_item_new_defaults() { + let item = MediaItem::new( + MediaItemId::new("test::123"), + "Test Movie", + ContentType::Movie, + 7200, + ); + assert_eq!(item.title(), "Test Movie"); + assert_eq!(item.duration_secs(), 7200); + assert!(item.description().is_none()); + assert!(item.genres().is_empty()); + assert!(item.year().is_none()); + assert!(item.series_name().is_none()); + } + + #[test] + fn media_item_from_persistence_round_trip() { + let item = MediaItem::from_persistence( + MediaItemId::new("jf::abc"), + "Breaking Bad S01E01".into(), + ContentType::Episode, + 2700, + Some("Pilot episode".into()), + vec!["Drama".into()], + Some(2008), + vec!["tv".into()], + Some("Breaking Bad".into()), + Some(1), + Some(1), + Some("http://thumb.jpg".into()), + Some("col-1".into()), + ); + assert_eq!(item.title(), "Breaking Bad S01E01"); + assert_eq!(item.series_name(), Some("Breaking Bad")); + assert_eq!(item.season_number(), Some(1)); + assert_eq!(item.episode_number(), Some(1)); + assert_eq!(item.year(), Some(2008)); + assert_eq!(item.collection_id(), Some("col-1")); + } + + #[test] + fn playback_record_new() { + let ch_id = ChannelId::generate(); + let item_id = MediaItemId::new("test::1"); + let record = PlaybackRecord::new(ch_id, item_id, 5); + assert_eq!(record.channel_id(), ch_id); + assert_eq!(record.generation(), 5); + assert_eq!(record.item_id().value(), "test::1"); + } + + #[test] + fn playback_record_from_persistence() { + let id = Uuid::new_v4(); + let ch_id = ChannelId::generate(); + let item_id = MediaItemId::new("test::2"); + let now = Utc::now(); + let record = PlaybackRecord::from_persistence(id, ch_id, item_id, now, 3); + assert_eq!(record.id(), id); + assert_eq!(record.played_at(), now); + assert_eq!(record.generation(), 3); + } +} diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs new file mode 100644 index 0000000..c6e0b83 --- /dev/null +++ b/crates/domain/src/models/mod.rs @@ -0,0 +1,12 @@ +mod channel; +mod collections; +mod media; +mod user; + +pub use channel::{ + BlockContent, Channel, OldScheduleConfig, ProgrammingBlock, ScheduleConfig, + ScheduleConfigCompat, +}; +pub use collections::{PageParams, Paginated}; +pub use media::{MediaItem, PlaybackRecord}; +pub use user::User; diff --git a/crates/domain/src/models/user.rs b/crates/domain/src/models/user.rs new file mode 100644 index 0000000..dc63389 --- /dev/null +++ b/crates/domain/src/models/user.rs @@ -0,0 +1,130 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::value_objects::{Email, UserId}; + +/// A user in the system. +/// +/// Designed to be OIDC-ready: the `subject` field stores the OIDC subject claim. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + id: UserId, + subject: String, + email: Email, + password_hash: Option, + is_admin: bool, + created_at: DateTime, +} + +impl User { + /// Create a new OIDC user (no local password). + pub fn new(subject: impl Into, email: Email) -> Self { + Self { + id: UserId::generate(), + subject: subject.into(), + email, + password_hash: None, + is_admin: false, + created_at: Utc::now(), + } + } + + /// Create a new local user with a password hash. + pub fn new_local(email: Email, password_hash: impl Into) -> Self { + Self { + id: UserId::generate(), + subject: format!("local|{}", uuid::Uuid::new_v4()), + email, + password_hash: Some(password_hash.into()), + is_admin: false, + created_at: Utc::now(), + } + } + + /// Hydrate from persistence — no validation, accepts all fields. + pub fn from_persistence( + id: UserId, + subject: String, + email: Email, + password_hash: Option, + is_admin: bool, + created_at: DateTime, + ) -> Self { + Self { + id, + subject, + email, + password_hash, + is_admin, + created_at, + } + } + + // -- Getters -- + + pub fn id(&self) -> UserId { + self.id + } + + pub fn subject(&self) -> &str { + &self.subject + } + + pub fn email(&self) -> &Email { + &self.email + } + + pub fn password_hash(&self) -> Option<&str> { + self.password_hash.as_deref() + } + + pub fn is_admin(&self) -> bool { + self.is_admin + } + + pub fn created_at(&self) -> DateTime { + self.created_at + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_generates_id_and_timestamp() { + let email = Email::new("test@example.com").unwrap(); + let user = User::new("oidc|123", email); + assert!(!user.is_admin()); + assert!(user.password_hash().is_none()); + assert_eq!(user.subject(), "oidc|123"); + } + + #[test] + fn new_local_sets_password_and_subject() { + let email = Email::new("local@example.com").unwrap(); + let user = User::new_local(email, "hashed_pw"); + assert!(user.password_hash().is_some()); + assert!(user.subject().starts_with("local|")); + } + + #[test] + fn from_persistence_round_trip() { + let email = Email::new("stored@example.com").unwrap(); + let id = UserId::generate(); + let now = Utc::now(); + let user = User::from_persistence( + id, + "sub".into(), + email.clone(), + Some("hash".into()), + true, + now, + ); + assert_eq!(user.id(), id); + assert_eq!(user.subject(), "sub"); + assert!(user.is_admin()); + assert_eq!(user.password_hash(), Some("hash")); + assert_eq!(user.created_at(), now); + } +}