From a46ced4acaaca9c32666d0e279a8fd43acbcccae Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 12 Jul 2026 03:41:24 +0200 Subject: [PATCH] add restructure design spec + implementation plan --- .../plans/2026-07-12-backend-restructure.md | 1386 +++++++++++++++++ .../2026-07-12-backend-restructure-design.md | 393 +++++ 2 files changed, 1779 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-12-backend-restructure.md create mode 100644 docs/superpowers/specs/2026-07-12-backend-restructure-design.md diff --git a/docs/superpowers/plans/2026-07-12-backend-restructure.md b/docs/superpowers/plans/2026-07-12-backend-restructure.md new file mode 100644 index 0000000..f94a911 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-backend-restructure.md @@ -0,0 +1,1386 @@ +# K-TV Backend Restructure Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restructure k-tv-backend from 3-crate layout (domain/infra/api) to proper DDD hexagonal architecture with `crates/` directory, matching movies-diary reference project. + +**Architecture:** Bottom-up migration — build new crates under `/mnt/drive/dev/k-tv/crates/`, layer by layer: domain → application → api-types → infra-wiring → adapters → presentation → mcp. Old `k-tv-backend/` stays untouched as reference. Each task must compile before moving to the next. + +**Tech Stack:** Rust (edition 2024), Axum 0.8, SQLx, Tokio, Serde, chrono-tz, utoipa (OpenAPI), async-trait, thiserror, uuid + +## Global Constraints + +- Rust edition 2024 for all crates +- No `k-core` dependency — inline what's needed +- Domain crate: zero I/O deps (no sqlx, no reqwest, no tokio runtime) +- All entity fields private with `new()`, `from_persistence()`, getters +- ID types are newtypes via `uuid_id!` macro (not type aliases) +- Ports split CQRS: separate Command (write) and Query (read) traits +- Application use cases: free `async fn execute(deps, cmd)` functions +- Test doubles (InMemory + Noops) in domain behind `test-helpers` feature +- Tests in sibling `tests/` directories via `#[cfg(test)] #[path = "tests/foo.rs"] mod tests;` +- API types use `utoipa::ToSchema` for OpenAPI generation +- Reference code lives at `/mnt/drive/dev/k-tv/k-tv-backend/` — read it, don't modify it +- Reference architecture lives at `/mnt/drive/dev/movies-diary/` — follow its patterns +- When creating a new crate, add it to `[workspace] members` in root `Cargo.toml` +- Old `k-tv-backend/` has its own `Cargo.toml` workspace — do NOT conflict with the new root workspace + +--- + +### Task 1: Workspace scaffold + domain foundation (errors, uuid_id macro, ID types) + +**Files:** +- Create: `Cargo.toml` (workspace root at `/mnt/drive/dev/k-tv/Cargo.toml`) +- Create: `crates/domain/Cargo.toml` +- Create: `crates/domain/src/lib.rs` +- Create: `crates/domain/src/errors/mod.rs` +- Create: `crates/domain/src/value_objects/mod.rs` +- Create: `crates/domain/src/value_objects/ids.rs` + +**Interfaces:** +- Produces: `DomainError`, `DomainResult`, `uuid_id!` macro, `UserId`, `ChannelId`, `SlotId`, `BlockId`, `ScheduleId`, `MediaItemId` + +- [ ] **Step 1: Create workspace root Cargo.toml** + +Create `/mnt/drive/dev/k-tv/Cargo.toml`. Note: start with only `crates/domain` as a member. Add other members as their crates are created in later tasks. The glob `crates/adapters/*` only works once adapter dirs exist. + +```toml +[workspace] +members = ["crates/domain"] +exclude = ["k-tv-backend", "k-tv-frontend"] +resolver = "2" + +[workspace.dependencies] +async-trait = "0.1" +chrono = { version = "0.4", features = ["serde"] } +chrono-tz = { version = "0.10", features = ["serde"] } +email_address = "0.2" +rand = "0.8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +url = { version = "2.5", features = ["serde"] } +uuid = { version = "1", features = ["v4", "serde"] } +tokio = { version = "1", features = ["full"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "macros", "chrono", "uuid"] } +axum = { version = "0.8" } +axum-extra = { version = "0.10" } +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +reqwest = { version = "0.12", features = ["json"] } +utoipa = { version = "5", features = ["chrono", "uuid"] } +jsonwebtoken = "9" + +# Internal crates +domain = { path = "crates/domain" } +application = { path = "crates/application" } +api-types = { path = "crates/api-types" } +infra-wiring = { path = "crates/infra-wiring" } +adapter-common = { path = "crates/adapters/adapter-common" } +adapter-sqlite = { path = "crates/adapters/sqlite" } +adapter-postgres = { path = "crates/adapters/postgres" } +adapter-auth = { path = "crates/adapters/auth" } +adapter-jellyfin = { path = "crates/adapters/jellyfin" } +adapter-local-files = { path = "crates/adapters/local-files" } +adapter-event-publisher = { path = "crates/adapters/event-publisher" } +``` + +- [ ] **Step 2: Create domain crate Cargo.toml** + +Create `crates/domain/Cargo.toml`: + +```toml +[package] +name = "domain" +version = "0.1.0" +edition = "2024" + +[features] +test-helpers = [] + +[dependencies] +async-trait = { workspace = true } +chrono = { workspace = true } +chrono-tz = { workspace = true } +email_address = { workspace = true } +rand = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +url = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } +``` + +- [ ] **Step 3: Create errors module** + +Create `crates/domain/src/errors/mod.rs` — copy from `k-tv-backend/domain/src/errors.rs` but change `Uuid` params to use the new ID newtypes where applicable. For now, keep `Uuid` since IDs aren't defined yet: + +```rust +use thiserror::Error; +use uuid::Uuid; + +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum DomainError { + #[error("User not found: {0}")] + UserNotFound(Uuid), + + #[error("User already exists: {0}")] + UserAlreadyExists(String), + + #[error("Channel not found: {0}")] + ChannelNotFound(Uuid), + + #[error("No active schedule for channel: {0}")] + NoActiveSchedule(Uuid), + + #[error("Validation error: {0}")] + ValidationError(String), + + #[error("Invalid timezone: {0}")] + TimezoneError(String), + + #[error("Unauthenticated: {0}")] + Unauthenticated(String), + + #[error("Forbidden: {0}")] + Forbidden(String), + + #[error("Repository error: {0}")] + RepositoryError(String), + + #[error("Infrastructure error: {0}")] + InfrastructureError(String), +} + +impl DomainError { + pub fn validation(message: impl Into) -> Self { + Self::ValidationError(message.into()) + } + + pub fn unauthenticated(message: impl Into) -> Self { + Self::Unauthenticated(message.into()) + } + + pub fn forbidden(message: impl Into) -> Self { + Self::Forbidden(message.into()) + } + + pub fn is_not_found(&self) -> bool { + matches!(self, DomainError::UserNotFound(_) | DomainError::ChannelNotFound(_)) + } + + pub fn is_conflict(&self) -> bool { + matches!(self, DomainError::UserAlreadyExists(_)) + } +} + +pub type DomainResult = Result; +``` + +- [ ] **Step 4: Create uuid_id macro and ID types** + +Create `crates/domain/src/value_objects/ids.rs`: + +```rust +use uuid::Uuid; + +macro_rules! uuid_id { + ($name:ident) => { + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] + pub struct $name(Uuid); + + impl $name { + pub fn generate() -> Self { + Self(Uuid::new_v4()) + } + + pub fn from_uuid(uuid: Uuid) -> Self { + Self(uuid) + } + + pub fn value(&self) -> Uuid { + self.0 + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } + } + + impl std::str::FromStr for $name { + type Err = uuid::Error; + fn from_str(s: &str) -> Result { + Ok(Self(s.parse()?)) + } + } + + impl From for $name { + fn from(uuid: Uuid) -> Self { + Self(uuid) + } + } + }; +} + +pub(crate) use uuid_id; + +uuid_id!(UserId); +uuid_id!(ChannelId); +uuid_id!(SlotId); +uuid_id!(BlockId); +uuid_id!(ScheduleId); + +/// Opaque media item identifier — format is provider-specific. +/// The domain never inspects the string; it just passes it back to the provider. +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct MediaItemId(String); + +impl MediaItemId { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn into_inner(self) -> String { + self.0 + } + + pub fn value(&self) -> &str { + &self.0 + } +} + +impl AsRef for MediaItemId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for MediaItemId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for MediaItemId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From<&str> for MediaItemId { + fn from(s: &str) -> Self { + Self(s.to_string()) + } +} +``` + +- [ ] **Step 5: Create value_objects mod.rs** + +Create `crates/domain/src/value_objects/mod.rs`: + +```rust +pub mod ids; + +pub use ids::*; +``` + +- [ ] **Step 6: Create lib.rs** + +Create `crates/domain/src/lib.rs`: + +```rust +pub mod errors; +pub mod value_objects; + +pub use errors::{DomainError, DomainResult}; +pub use value_objects::*; +``` + +- [ ] **Step 7: Verify it compiles** + +Run: `cargo check -p domain` +Expected: compiles with no errors + +- [ ] **Step 8: Commit** + +```bash +git add Cargo.toml crates/domain/ +git commit -m "scaffold workspace + domain foundation (errors, ids, uuid_id macro)" +``` + +--- + +### Task 2: Domain value objects (auth, scheduling, channel, oidc, search) + +**Files:** +- Create: `crates/domain/src/value_objects/auth.rs` +- Create: `crates/domain/src/value_objects/scheduling.rs` +- Create: `crates/domain/src/value_objects/channel.rs` +- Create: `crates/domain/src/value_objects/oidc.rs` +- Create: `crates/domain/src/value_objects/search.rs` +- Modify: `crates/domain/src/value_objects/mod.rs` +- Modify: `crates/domain/src/errors/mod.rs` + +**Interfaces:** +- Consumes: `DomainError`, `DomainResult`, ID types from Task 1 +- Produces: `Email`, `Password`, `ValidationError`, `ContentType`, `MediaFilter`, `FillStrategy`, `RecyclePolicy`, `Weekday`, `AccessMode`, `LogoPosition`, OIDC newtypes, `LibrarySearchFilter` + +- [ ] **Step 1: Copy and adapt auth.rs** + +Copy from `k-tv-backend/domain/src/value_objects/auth.rs` into `crates/domain/src/value_objects/auth.rs`. Keep identical — `ValidationError`, `Email`, `Password` with all impls and tests. + +- [ ] **Step 2: Add `From` to errors** + +Add to `crates/domain/src/errors/mod.rs`: + +```rust +impl From for DomainError { + fn from(error: crate::value_objects::auth::ValidationError) -> Self { + DomainError::ValidationError(error.to_string()) + } +} +``` + +- [ ] **Step 3: Copy and adapt scheduling.rs** + +Copy from `k-tv-backend/domain/src/value_objects/scheduling.rs`. Move `MediaItemId`, `AccessMode`, and `LogoPosition` OUT (they have their own files now). Keep: `ContentType`, `MediaFilter`, `FillStrategy`, `RecyclePolicy`, `Weekday` with all impls and tests. + +- [ ] **Step 4: Create channel.rs** + +Create `crates/domain/src/value_objects/channel.rs` with `AccessMode` and `LogoPosition` (moved from scheduling.rs in old code): + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AccessMode { + #[default] + Public, + PasswordProtected, + AccountRequired, + OwnerOnly, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum LogoPosition { + TopLeft, + #[default] + TopRight, + BottomLeft, + BottomRight, +} +``` + +- [ ] **Step 5: Copy and adapt oidc.rs** + +Copy from `k-tv-backend/domain/src/value_objects/oidc.rs` into `crates/domain/src/value_objects/oidc.rs`. Keep identical — all OIDC newtypes plus `JwtSecret`. + +- [ ] **Step 6: Create search.rs** + +Create `crates/domain/src/value_objects/search.rs` with `LibrarySearchFilter` (moved from `library.rs` in old code since it's a value object): + +```rust +use crate::value_objects::ContentType; + +#[derive(Debug, Clone)] +pub struct LibrarySearchFilter { + provider_id: Option, + content_type: Option, + series_names: Vec, + collection_id: Option, + genres: Vec, + decade: Option, + min_duration_secs: Option, + max_duration_secs: Option, + search_term: Option, + season_number: Option, + offset: u32, + limit: u32, +} + +impl LibrarySearchFilter { + pub fn new() -> Self { + Self::default() + } + + // Builder methods + pub fn with_provider_id(mut self, id: impl Into) -> Self { self.provider_id = Some(id.into()); self } + pub fn with_content_type(mut self, ct: ContentType) -> Self { self.content_type = Some(ct); self } + pub fn with_series_names(mut self, names: Vec) -> Self { self.series_names = names; self } + pub fn with_collection_id(mut self, id: impl Into) -> Self { self.collection_id = Some(id.into()); self } + pub fn with_genres(mut self, genres: Vec) -> Self { self.genres = genres; self } + pub fn with_decade(mut self, decade: u16) -> Self { self.decade = Some(decade); self } + pub fn with_min_duration_secs(mut self, secs: u32) -> Self { self.min_duration_secs = Some(secs); self } + pub fn with_max_duration_secs(mut self, secs: u32) -> Self { self.max_duration_secs = Some(secs); self } + pub fn with_search_term(mut self, term: impl Into) -> Self { self.search_term = Some(term.into()); self } + pub fn with_season_number(mut self, n: u32) -> Self { self.season_number = Some(n); self } + pub fn with_offset(mut self, offset: u32) -> Self { self.offset = offset; self } + pub fn with_limit(mut self, limit: u32) -> Self { self.limit = limit; self } + + // Getters + pub fn provider_id(&self) -> Option<&str> { self.provider_id.as_deref() } + pub fn content_type(&self) -> Option<&ContentType> { self.content_type.as_ref() } + pub fn series_names(&self) -> &[String] { &self.series_names } + pub fn collection_id(&self) -> Option<&str> { self.collection_id.as_deref() } + pub fn genres(&self) -> &[String] { &self.genres } + pub fn decade(&self) -> Option { self.decade } + pub fn min_duration_secs(&self) -> Option { self.min_duration_secs } + pub fn max_duration_secs(&self) -> Option { self.max_duration_secs } + pub fn search_term(&self) -> Option<&str> { self.search_term.as_deref() } + pub fn season_number(&self) -> Option { self.season_number } + pub fn offset(&self) -> u32 { self.offset } + pub fn limit(&self) -> u32 { self.limit } +} + +impl Default for LibrarySearchFilter { + fn default() -> Self { + Self { + provider_id: None, + content_type: None, + series_names: vec![], + collection_id: None, + genres: vec![], + decade: None, + min_duration_secs: None, + max_duration_secs: None, + search_term: None, + season_number: None, + offset: 0, + limit: 50, + } + } +} +``` + +- [ ] **Step 7: Update value_objects/mod.rs** + +```rust +pub mod auth; +pub mod channel; +pub mod ids; +pub mod oidc; +pub mod scheduling; +pub mod search; + +pub use auth::*; +pub use channel::*; +pub use ids::*; +pub use oidc::*; +pub use scheduling::*; +pub use search::*; +``` + +- [ ] **Step 8: Update lib.rs re-exports** + +Update `crates/domain/src/lib.rs` to re-export the new modules. + +- [ ] **Step 9: Verify and commit** + +Run: `cargo check -p domain` +Expected: compiles + +```bash +git add crates/domain/ +git commit -m "domain value objects: auth, scheduling, channel, oidc, search" +``` + +--- + +### Task 3: Domain models — User, Channel, ScheduleConfig, ProgrammingBlock, BlockContent, MediaItem + +**Files:** +- Create: `crates/domain/src/models/mod.rs` +- Create: `crates/domain/src/models/user.rs` +- Create: `crates/domain/src/models/channel.rs` +- Create: `crates/domain/src/models/media.rs` +- Create: `crates/domain/src/models/collections.rs` +- Modify: `crates/domain/src/lib.rs` + +**Interfaces:** +- Consumes: All value objects from Tasks 1-2 +- Produces: `User`, `Channel`, `ScheduleConfig`, `ScheduleConfigCompat`, `OldScheduleConfig`, `ProgrammingBlock`, `BlockContent`, `MediaItem`, `PlaybackRecord`, `PageParams`, `Paginated` + +Key transformation: ALL fields become private. Each entity gets `new()` for creation, `from_persistence()` for DB hydration, and getter methods. Read the old `k-tv-backend/domain/src/entities.rs` for the complete field lists. Use new ID newtypes (`UserId`, `ChannelId`, etc.) instead of bare `Uuid`. + +For `ScheduleConfig`, `ProgrammingBlock`, `BlockContent`: these are Serde-serialized as JSON in the DB. They need `Serialize`/`Deserialize` but fields should still be private with getters. Use `#[serde(into = "...", from = "...")]` or keep serde derives and add getters alongside. Since these are complex nested structures frequently serialized, keep `Serialize`/`Deserialize` derives and add getters. + +For `MediaItem`: this is embedded in `ScheduledSlot` as JSON. Same approach — derives + getters. + +Refer to `k-tv-backend/domain/src/entities.rs` for the complete type definitions. Migrate all types and tests. + +- [ ] **Step 1: Create user.rs with encapsulated User** +- [ ] **Step 2: Create channel.rs with Channel, ScheduleConfig, ProgrammingBlock, BlockContent, ScheduleConfigCompat** +- [ ] **Step 3: Create media.rs with MediaItem, PlaybackRecord** +- [ ] **Step 4: Create collections.rs with PageParams, Paginated** +- [ ] **Step 5: Create mod.rs with re-exports** +- [ ] **Step 6: Update lib.rs** +- [ ] **Step 7: Verify and commit** + +Run: `cargo check -p domain` + +```bash +git add crates/domain/ +git commit -m "domain models: User, Channel, ScheduleConfig, MediaItem, PlaybackRecord" +``` + +--- + +### Task 4: Domain models — Schedule, Library, ConfigSnapshot, Activity, ProviderConfig + +**Files:** +- Create: `crates/domain/src/models/schedule.rs` +- Create: `crates/domain/src/models/library.rs` +- Create: `crates/domain/src/models/config_snapshot.rs` +- Create: `crates/domain/src/models/activity.rs` +- Create: `crates/domain/src/models/provider_config.rs` +- Modify: `crates/domain/src/models/mod.rs` + +**Interfaces:** +- Consumes: All value objects, `MediaItem`, `ScheduleConfig` from Tasks 1-3 +- Produces: `GeneratedSchedule`, `ScheduledSlot`, `CurrentBroadcast`, `LibraryItem`, `LibraryCollection`, `LibrarySyncResult`, `LibrarySyncLogEntry`, `ShowSummary`, `SeasonSummary`, `ChannelConfigSnapshot`, `ActivityEvent`, `ProviderConfigRow` + +Read old code from: +- `k-tv-backend/domain/src/entities.rs` (GeneratedSchedule, ScheduledSlot, CurrentBroadcast, ChannelConfigSnapshot) +- `k-tv-backend/domain/src/library.rs` (LibraryItem, LibraryCollection, etc.) +- `k-tv-backend/domain/src/repositories.rs` (ActivityEvent, ProviderConfigRow) + +All fields private with `new()`, `from_persistence()`, getters. + +- [ ] **Step 1: Create schedule.rs** — GeneratedSchedule, ScheduledSlot, CurrentBroadcast +- [ ] **Step 2: Create library.rs** — LibraryItem, LibraryCollection, LibrarySyncResult, LibrarySyncLogEntry, ShowSummary, SeasonSummary +- [ ] **Step 3: Create config_snapshot.rs** — ChannelConfigSnapshot +- [ ] **Step 4: Create activity.rs** — ActivityEvent +- [ ] **Step 5: Create provider_config.rs** — ProviderConfigRow +- [ ] **Step 6: Update mod.rs re-exports** +- [ ] **Step 7: Verify and commit** + +Run: `cargo check -p domain` + +```bash +git add crates/domain/ +git commit -m "domain models: schedule, library, config_snapshot, activity, provider_config" +``` + +--- + +### Task 5: Domain ports — all CQRS-split trait definitions + +**Files:** +- Create: `crates/domain/src/ports/mod.rs` +- Create: `crates/domain/src/ports/auth.rs` +- Create: `crates/domain/src/ports/user.rs` +- Create: `crates/domain/src/ports/channel.rs` +- Create: `crates/domain/src/ports/schedule.rs` +- Create: `crates/domain/src/ports/library.rs` +- Create: `crates/domain/src/ports/media.rs` +- Create: `crates/domain/src/ports/events.rs` +- Create: `crates/domain/src/ports/settings.rs` +- Create: `crates/domain/src/ports/activity.rs` +- Create: `crates/domain/src/ports/provider_config.rs` +- Create: `crates/domain/src/ports/transcode.rs` +- Modify: `crates/domain/src/lib.rs` + +**Interfaces:** +- Consumes: All models and value objects from Tasks 1-4 +- Produces: `AuthService`, `UserCommand`, `UserQuery`, `ChannelCommand`, `ChannelQuery`, `ScheduleCommand`, `ScheduleQuery`, `LibraryCommand`, `LibraryQuery`, `LibrarySyncAdapter`, `IMediaProvider`, `IProviderRegistry`, `ProviderCapabilities`, `StreamingProtocol`, `StreamQuality`, `Collection`, `SeriesSummary`, `EventPublisher`, `EventConsumer`, `EventHandler`, `AppSettingsRepository`, `ActivityLogRepository`, `ProviderConfigRepository`, `TranscodeSettingsRepository` + +Read old code from: +- `k-tv-backend/domain/src/repositories.rs` — split each old trait into Command + Query +- `k-tv-backend/domain/src/ports.rs` — `IMediaProvider`, `IProviderRegistry`, `ProviderCapabilities`, etc. +- `k-tv-backend/domain/src/library.rs` — `ILibraryRepository` → split into `LibraryCommand` + `LibraryQuery`, `LibrarySyncAdapter` + +CQRS split example for `UserRepository`: +```rust +// ports/user.rs +#[async_trait] +pub trait UserCommand: Send + Sync { + async fn save(&self, user: &User) -> DomainResult<()>; + async fn delete(&self, id: UserId) -> DomainResult<()>; +} + +#[async_trait] +pub trait UserQuery: Send + Sync { + async fn find_by_id(&self, id: UserId) -> DomainResult>; + async fn find_by_subject(&self, subject: &str) -> DomainResult>; + async fn find_by_email(&self, email: &str) -> DomainResult>; + async fn count_users(&self) -> DomainResult; +} +``` + +New `AuthService` port (password hashing + verification, abstracted from infra): +```rust +// ports/auth.rs +#[async_trait] +pub trait AuthService: Send + Sync { + fn hash_password(&self, password: &str) -> DomainResult; + fn verify_password(&self, password: &str, hash: &str) -> DomainResult; +} +``` + +New event ports: +```rust +// ports/events.rs +#[async_trait] +pub trait EventPublisher: Send + Sync { + async fn publish(&self, event: DomainEvent) -> DomainResult<()>; +} + +#[async_trait] +pub trait EventConsumer: Send + Sync { + async fn subscribe(&self) -> DomainResult>; +} + +#[async_trait] +pub trait EventHandler: Send + Sync { + async fn handle(&self, event: &DomainEvent) -> DomainResult<()>; +} +``` + +`media.rs` — copy `IMediaProvider`, `IProviderRegistry`, `ProviderCapabilities`, `StreamingProtocol`, `StreamQuality`, `Collection`, `SeriesSummary` from `k-tv-backend/domain/src/ports.rs`. + +- [ ] **Step 1: Create auth.rs port** +- [ ] **Step 2: Create user.rs with UserCommand + UserQuery** +- [ ] **Step 3: Create channel.rs with ChannelCommand + ChannelQuery** +- [ ] **Step 4: Create schedule.rs with ScheduleCommand + ScheduleQuery** +- [ ] **Step 5: Create library.rs with LibraryCommand + LibraryQuery + LibrarySyncAdapter** +- [ ] **Step 6: Create media.rs with IMediaProvider, IProviderRegistry, etc.** +- [ ] **Step 7: Create events.rs with EventPublisher, EventConsumer, EventHandler** +- [ ] **Step 8: Create settings.rs, activity.rs, provider_config.rs, transcode.rs** +- [ ] **Step 9: Create mod.rs with re-exports** +- [ ] **Step 10: Update lib.rs** +- [ ] **Step 11: Verify and commit** + +Run: `cargo check -p domain` + +```bash +git add crates/domain/ +git commit -m "domain ports: CQRS-split traits for all bounded contexts" +``` + +--- + +### Task 6: Domain events + +**Files:** +- Create: `crates/domain/src/events/mod.rs` +- Modify: `crates/domain/src/lib.rs` + +**Interfaces:** +- Consumes: ID types, models from prior tasks +- Produces: `DomainEvent` enum + +Read from `k-tv-backend/domain/src/events.rs`. Adapt to use new ID newtypes. The events should reference IDs, not full entities (keeps Clone cheap): + +```rust +#[derive(Clone, Debug)] +pub enum DomainEvent { + BroadcastTransition { channel_id: ChannelId, slot_id: SlotId }, + NoSignal { channel_id: ChannelId }, + ScheduleGenerated { channel_id: ChannelId, schedule_id: ScheduleId }, + ChannelCreated { channel_id: ChannelId }, + ChannelUpdated { channel_id: ChannelId }, + ChannelDeleted { channel_id: ChannelId }, +} +``` + +- [ ] **Step 1: Create events/mod.rs** +- [ ] **Step 2: Update lib.rs** +- [ ] **Step 3: Verify and commit** + +```bash +git add crates/domain/ +git commit -m "domain events: DomainEvent enum" +``` + +--- + +### Task 7: Domain services — ScheduleEngineService, fill strategies, IPTV + +**Files:** +- Create: `crates/domain/src/services/mod.rs` +- Create: `crates/domain/src/services/schedule/mod.rs` +- Create: `crates/domain/src/services/schedule/fill.rs` +- Create: `crates/domain/src/services/schedule/recycle.rs` +- Create: `crates/domain/src/services/iptv.rs` +- Modify: `crates/domain/src/lib.rs` + +**Interfaces:** +- Consumes: All models, ports, value objects +- Produces: `ScheduleEngineService`, `fill_block()`, `apply_recycle_policy()`, `generate_m3u()`, `generate_xmltv()` + +Read from: +- `k-tv-backend/domain/src/services/schedule/mod.rs` (392 lines) +- `k-tv-backend/domain/src/services/schedule/fill.rs` (151 lines) +- `k-tv-backend/domain/src/services/schedule/recycle.rs` (55 lines) +- `k-tv-backend/domain/src/iptv.rs` (93 lines) + +Note: The old `UserService` and `ChannelService` are NOT migrated here — their logic moves to application use cases. Only `ScheduleEngineService` stays in domain because it contains pure scheduling algorithms. + +Adapt all code to use new newtypes (getters instead of field access, `UserId` instead of `Uuid`, etc.). + +- [ ] **Step 1: Create schedule/fill.rs** — adapt fill_block, fill_best_fit, fill_sequential +- [ ] **Step 2: Create schedule/recycle.rs** — adapt apply_recycle_policy +- [ ] **Step 3: Create schedule/mod.rs** — adapt ScheduleEngineService +- [ ] **Step 4: Create iptv.rs** — adapt generate_m3u, generate_xmltv +- [ ] **Step 5: Create services/mod.rs** +- [ ] **Step 6: Update lib.rs** +- [ ] **Step 7: Verify and commit** + +Run: `cargo check -p domain` + +```bash +git add crates/domain/ +git commit -m "domain services: schedule engine, fill strategies, IPTV" +``` + +--- + +### Task 8: Domain testing — InMemory repos + Noops + +**Files:** +- Create: `crates/domain/src/testing/mod.rs` +- Create: `crates/domain/src/testing/in_memory.rs` +- Create: `crates/domain/src/testing/noops.rs` +- Modify: `crates/domain/src/lib.rs` + +**Interfaces:** +- Consumes: All port traits from Task 5 +- Produces: `InMemoryUserRepository`, `InMemoryChannelRepository`, `InMemoryScheduleRepository`, `InMemoryLibraryRepository`, `NoopEventPublisher`, `NoopMediaProvider`, `NoopActivityLog`, etc. + +InMemory repos: `Arc>>` implementing both Command and Query traits. One struct implements both traits (since InMemory doesn't need separate read/write stores). + +Noops: return `Ok(())` for writes, `Ok(None)` / `Ok(vec![])` for reads. + +Reference: `movies-diary/crates/domain/src/testing/` for pattern. + +Gated behind `#[cfg(feature = "test-helpers")]` in lib.rs: +```rust +#[cfg(feature = "test-helpers")] +pub mod testing; +``` + +- [ ] **Step 1: Create in_memory.rs** — InMemory implementations for all ports +- [ ] **Step 2: Create noops.rs** — Noop implementations for all ports +- [ ] **Step 3: Create testing/mod.rs** — re-exports +- [ ] **Step 4: Update lib.rs** — add cfg-gated `pub mod testing` +- [ ] **Step 5: Verify and commit** + +Run: `cargo check -p domain --features test-helpers` + +```bash +git add crates/domain/ +git commit -m "domain testing: InMemory repos + Noops behind test-helpers feature" +``` + +--- + +### Task 9: Application crate — auth bounded context + +**Files:** +- Create: `crates/application/Cargo.toml` +- Create: `crates/application/src/lib.rs` +- Create: `crates/application/src/auth/mod.rs` +- Create: `crates/application/src/auth/deps.rs` +- Create: `crates/application/src/auth/commands.rs` +- Create: `crates/application/src/auth/queries.rs` +- Create: `crates/application/src/auth/register.rs` +- Create: `crates/application/src/auth/login.rs` +- Create: `crates/application/src/auth/tests/register.rs` +- Create: `crates/application/src/auth/tests/login.rs` + +**Interfaces:** +- Consumes: `domain::*` (ports, models, value objects, testing) +- Produces: `AuthDeps`, `RegisterCommand`, `LoginCommand`, `auth::register::execute()`, `auth::login::execute()` + +```toml +# crates/application/Cargo.toml +[package] +name = "application" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true, features = ["test-helpers"] } +async-trait = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } +``` + +Wait — `test-helpers` should only be enabled in dev-dependencies. Fix: + +```toml +[dependencies] +domain = { workspace = true } +async-trait = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +domain = { workspace = true, features = ["test-helpers"] } +tokio = { workspace = true } +``` + +Pattern for each use case file: +```rust +// auth/register.rs +use domain::*; +use super::deps::AuthDeps; +use super::commands::RegisterCommand; + +pub async fn execute(deps: &AuthDeps, cmd: RegisterCommand) -> DomainResult { + // validate, create entity, persist, publish event +} + +#[cfg(test)] +#[path = "tests/register.rs"] +mod tests; +``` + +Read old logic from: +- `k-tv-backend/domain/src/services/user.rs` (UserService methods become use cases) +- `k-tv-backend/api/src/routes/auth/local.rs` (registration/login handler logic) + +- [ ] **Step 1: Create application Cargo.toml** +- [ ] **Step 2: Create deps.rs** — AuthDeps struct +- [ ] **Step 3: Create commands.rs and queries.rs** +- [ ] **Step 4: Write register test** — `tests/register.rs` +- [ ] **Step 5: Implement register.rs** +- [ ] **Step 6: Write login test** +- [ ] **Step 7: Implement login.rs** +- [ ] **Step 8: Create mod.rs and lib.rs** +- [ ] **Step 9: Verify and commit** + +Run: `cargo test -p application` + +```bash +git add crates/application/ +git commit -m "application: auth bounded context (register, login)" +``` + +--- + +### Task 10: Application crate — channels bounded context + +**Files:** +- Create: `crates/application/src/channels/mod.rs` +- Create: `crates/application/src/channels/deps.rs` +- Create: `crates/application/src/channels/commands.rs` +- Create: `crates/application/src/channels/queries.rs` +- Create: `crates/application/src/channels/create.rs` +- Create: `crates/application/src/channels/update.rs` +- Create: `crates/application/src/channels/delete.rs` +- Create: `crates/application/src/channels/get.rs` +- Create: `crates/application/src/channels/list.rs` +- Create: `crates/application/src/channels/list_by_owner.rs` +- Create: `crates/application/src/channels/tests/` + +**Interfaces:** +- Consumes: `domain::*` +- Produces: `ChannelCommandDeps`, `ChannelQueryDeps`, `CreateChannelCommand`, `UpdateChannelCommand`, use case `execute()` functions + +Read old logic from `k-tv-backend/domain/src/services/channel.rs` (ChannelService methods → use cases). + +- [ ] **Step 1: Create deps.rs** +- [ ] **Step 2: Create commands.rs and queries.rs** +- [ ] **Step 3: Write and implement create.rs with tests** +- [ ] **Step 4: Write and implement update.rs with tests** +- [ ] **Step 5: Write and implement delete.rs with tests** +- [ ] **Step 6: Write and implement get.rs, list.rs, list_by_owner.rs with tests** +- [ ] **Step 7: Create mod.rs, update lib.rs** +- [ ] **Step 8: Verify and commit** + +Run: `cargo test -p application` + +```bash +git add crates/application/ +git commit -m "application: channels bounded context" +``` + +--- + +### Task 11: Application crate — schedule bounded context + +**Files:** +- Create: `crates/application/src/schedule/mod.rs`, `deps.rs`, `commands.rs`, `queries.rs` +- Create: `crates/application/src/schedule/generate.rs`, `get_active.rs`, `get_current_broadcast.rs`, `get_epg.rs`, `get_stream_url.rs`, `list_history.rs` +- Create: `crates/application/src/schedule/tests/` + +**Interfaces:** +- Consumes: `domain::*`, `ScheduleEngineService` +- Produces: `ScheduleDeps`, `GenerateScheduleCommand`, schedule query functions + +The schedule use cases orchestrate calling `ScheduleEngineService` (which stays in domain). Read old logic from `k-tv-backend/domain/src/services/schedule/mod.rs`. + +- [ ] **Step 1-6: Create deps, commands, queries, implement all use cases with tests** +- [ ] **Step 7: Verify and commit** + +```bash +git add crates/application/ +git commit -m "application: schedule bounded context" +``` + +--- + +### Task 12: Application crate — library bounded context + +**Files:** +- Create: `crates/application/src/library/mod.rs`, `deps.rs`, `commands.rs`, `queries.rs` +- Create: `crates/application/src/library/sync.rs`, `search.rs`, `list_collections.rs`, `list_shows.rs`, `list_seasons.rs` +- Create: `crates/application/src/library/tests/` + +**Interfaces:** +- Consumes: `domain::*` +- Produces: `LibraryCommandDeps`, `LibraryQueryDeps`, `TriggerSyncCommand`, library query functions + +Read old logic from `k-tv-backend/api/src/routes/library.rs` (handler logic → use cases). + +- [ ] **Step 1-6: Create deps, commands, queries, implement all use cases with tests** +- [ ] **Step 7: Verify and commit** + +```bash +git add crates/application/ +git commit -m "application: library bounded context" +``` + +--- + +### Task 13: Application crate — remaining bounded contexts + +**Files:** +- Create: `crates/application/src/config_snapshots/` — save, restore, list, patch_label +- Create: `crates/application/src/admin/` — update_settings, get_settings, activity_log +- Create: `crates/application/src/providers/` — upsert, delete, list +- Create: `crates/application/src/iptv/` — m3u, xmltv + +**Interfaces:** +- Consumes: `domain::*` +- Produces: All remaining use case functions + +Follow the same pattern as Tasks 9-12 for each bounded context. + +- [ ] **Step 1: config_snapshots — deps, commands, queries, use cases, tests** +- [ ] **Step 2: admin — deps, commands, queries, use cases, tests** +- [ ] **Step 3: providers — deps, commands, queries, use cases, tests** +- [ ] **Step 4: iptv — deps, queries, use cases, tests** +- [ ] **Step 5: Verify and commit** + +Run: `cargo test -p application` + +```bash +git add crates/application/ +git commit -m "application: config_snapshots, admin, providers, iptv" +``` + +--- + +### Task 14: API types crate + +**Files:** +- Create: `crates/api-types/Cargo.toml` +- Create: `crates/api-types/src/lib.rs` +- Create: `crates/api-types/src/auth.rs`, `channels.rs`, `schedule.rs`, `library.rs`, `admin.rs`, `config.rs`, `providers.rs`, `transcode.rs`, `common.rs` + +**Interfaces:** +- Consumes: `domain` (for types referenced in responses) +- Produces: All `*Request`, `*Response`, `*Dto` types with `utoipa::ToSchema` + +```toml +# crates/api-types/Cargo.toml +[package] +name = "api-types" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +serde = { workspace = true } +utoipa = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +``` + +Read old DTOs from `k-tv-backend/api/src/dto.rs`. Each DTO gets `#[derive(Serialize, Deserialize, utoipa::ToSchema)]`. + +- [ ] **Step 1: Create Cargo.toml** +- [ ] **Step 2: Create common.rs** — PaginatedResponse, ErrorResponse +- [ ] **Step 3: Create auth.rs** — LoginRequest, RegisterRequest, TokenResponse, UserResponse +- [ ] **Step 4: Create channels.rs** — CreateChannelRequest, UpdateChannelRequest, ChannelResponse +- [ ] **Step 5: Create schedule.rs, library.rs, admin.rs, config.rs, providers.rs, transcode.rs** +- [ ] **Step 6: Create lib.rs** +- [ ] **Step 7: Verify and commit** + +Run: `cargo check -p api-types` + +```bash +git add crates/api-types/ +git commit -m "api-types: HTTP DTOs with utoipa OpenAPI derives" +``` + +--- + +### Task 15: Infra-wiring crate + +**Files:** +- Create: `crates/infra-wiring/Cargo.toml` +- Create: `crates/infra-wiring/src/lib.rs` + +**Interfaces:** +- Produces: `DbPool` enum, `Config` struct + +```toml +# crates/infra-wiring/Cargo.toml +[package] +name = "infra-wiring" +version = "0.1.0" +edition = "2024" + +[features] +default = ["sqlite"] +sqlite = ["sqlx/sqlite"] +postgres = ["sqlx/postgres"] + +[dependencies] +sqlx = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } +``` + +Read old config from `k-tv-backend/api/src/config.rs`. Migrate `Config` struct and `DbPool` enum. + +- [ ] **Step 1: Create Cargo.toml** +- [ ] **Step 2: Create lib.rs** — DbPool enum, Config struct, Config::from_env() +- [ ] **Step 3: Verify and commit** + +Run: `cargo check -p infra-wiring` + +```bash +git add crates/infra-wiring/ +git commit -m "infra-wiring: DbPool enum + Config" +``` + +--- + +### Task 16: Adapter — adapter-common + +**Files:** +- Create: `crates/adapters/adapter-common/Cargo.toml` +- Create: `crates/adapters/adapter-common/src/lib.rs` + +**Interfaces:** +- Consumes: `domain` +- Produces: `map_sqlx_error()`, shared row-to-domain conversion helpers + +Read old mapping code from `k-tv-backend/infra/src/channel_repository/mapping.rs`, `user_repository/mapping.rs`, `schedule_repository/mapping.rs`. + +- [ ] **Step 1: Create Cargo.toml** +- [ ] **Step 2: Create lib.rs** — map_sqlx_error, row mapping helpers +- [ ] **Step 3: Verify and commit** + +```bash +git add crates/adapters/adapter-common/ +git commit -m "adapter-common: shared DB mapping helpers" +``` + +--- + +### Task 17: Adapter — SQLite + +**Files:** +- Create: `crates/adapters/sqlite/Cargo.toml` +- Create: `crates/adapters/sqlite/src/lib.rs` +- Create: `crates/adapters/sqlite/src/wire.rs` +- Create: `crates/adapters/sqlite/src/user.rs`, `channel.rs`, `schedule.rs`, `library.rs`, `activity.rs`, `settings.rs`, `provider_config.rs`, `transcode.rs` +- Copy: `k-tv-backend/migrations_sqlite/` → top-level `migrations_sqlite/` + +**Interfaces:** +- Consumes: `domain`, `adapter-common`, `infra-wiring` +- Produces: `SqliteWireOutput`, all `Sqlite*Repository` types + +Read old implementations from `k-tv-backend/infra/src/*_repository/sqlite.rs` files. Each repo struct wraps `SqlitePool` and implements both Command + Query traits. + +`wire()` function creates pool, runs migrations, returns `SqliteWireOutput` with `Arc` for each. + +- [ ] **Step 1: Create Cargo.toml** +- [ ] **Step 2: Copy migrations** +- [ ] **Step 3: Implement user.rs** — SqliteUserRepository (UserCommand + UserQuery) +- [ ] **Step 4: Implement channel.rs** — SqliteChannelRepository +- [ ] **Step 5: Implement schedule.rs** — SqliteScheduleRepository +- [ ] **Step 6: Implement library.rs** — SqliteLibraryRepository +- [ ] **Step 7: Implement activity.rs, settings.rs, provider_config.rs, transcode.rs** +- [ ] **Step 8: Create wire.rs** — SqliteWireOutput, wire() function +- [ ] **Step 9: Create lib.rs** +- [ ] **Step 10: Verify and commit** + +Run: `cargo check -p adapter-sqlite` + +```bash +git add crates/adapters/sqlite/ migrations_sqlite/ +git commit -m "adapter-sqlite: all repository implementations + wire function" +``` + +--- + +### Task 18: Adapter — PostgreSQL + +**Files:** +- Create: `crates/adapters/postgres/Cargo.toml` +- Create: `crates/adapters/postgres/src/` — same structure as sqlite + +**Interfaces:** +- Consumes: `domain`, `adapter-common`, `infra-wiring` +- Produces: `PostgresWireOutput`, all `Postgres*Repository` types + +Read old code from `k-tv-backend/infra/src/*_repository/postgres.rs`. Note: not all repos have Postgres implementations in the old code — implement what exists, stub the rest. + +- [ ] **Step 1-8: Mirror SQLite adapter for PostgreSQL** +- [ ] **Step 9: Verify and commit** + +```bash +git add crates/adapters/postgres/ +git commit -m "adapter-postgres: repository implementations" +``` + +--- + +### Task 19: Adapter — auth (JWT + OIDC) + +**Files:** +- Create: `crates/adapters/auth/Cargo.toml` +- Create: `crates/adapters/auth/src/lib.rs` +- Create: `crates/adapters/auth/src/jwt.rs` +- Create: `crates/adapters/auth/src/oidc.rs` +- Create: `crates/adapters/auth/src/password.rs` + +**Interfaces:** +- Consumes: `domain` (AuthService port) +- Produces: `JwtAuthService`, `OidcService`, `PasswordAuthService` + +Read from `k-tv-backend/infra/src/auth/`. Feature-gate OIDC behind `auth-oidc`. + +- [ ] **Step 1-5: Create adapter with JWT, OIDC, password hashing** +- [ ] **Step 6: Verify and commit** + +```bash +git add crates/adapters/auth/ +git commit -m "adapter-auth: JWT, OIDC, password hashing" +``` + +--- + +### Task 20: Adapter — Jellyfin + +**Files:** +- Create: `crates/adapters/jellyfin/Cargo.toml` +- Create: `crates/adapters/jellyfin/src/lib.rs`, `config.rs`, `mapping.rs`, `models.rs`, `provider.rs` + +**Interfaces:** +- Consumes: `domain` (IMediaProvider port) +- Produces: `JellyfinMediaProvider` + +Read from `k-tv-backend/infra/src/jellyfin/`. + +- [ ] **Step 1-5: Create Jellyfin adapter** +- [ ] **Step 6: Verify and commit** + +```bash +git add crates/adapters/jellyfin/ +git commit -m "adapter-jellyfin: media provider" +``` + +--- + +### Task 21: Adapter — local-files + +**Files:** +- Create: `crates/adapters/local-files/Cargo.toml` +- Create: `crates/adapters/local-files/src/` — lib.rs, config.rs, index.rs, provider.rs, scanner.rs, transcoder.rs + +**Interfaces:** +- Consumes: `domain`, `infra-wiring` +- Produces: `LocalFilesProvider`, `LocalIndex`, `TranscodeManager`, `LocalFilesBundle` + +Read from `k-tv-backend/infra/src/local_files/`. + +- [ ] **Step 1-6: Create local-files adapter** +- [ ] **Step 7: Verify and commit** + +```bash +git add crates/adapters/local-files/ +git commit -m "adapter-local-files: media provider, index, transcoder" +``` + +--- + +### Task 22: Adapter — event-publisher + +**Files:** +- Create: `crates/adapters/event-publisher/Cargo.toml` +- Create: `crates/adapters/event-publisher/src/lib.rs` + +**Interfaces:** +- Consumes: `domain` (EventPublisher, EventConsumer ports) +- Produces: `ChannelEventBus` (tokio broadcast-based) + +```rust +pub struct ChannelEventBus { + tx: broadcast::Sender, +} + +impl ChannelEventBus { + pub fn new(capacity: usize) -> Self { ... } + pub fn subscriber(&self) -> broadcast::Receiver { ... } +} + +#[async_trait] +impl EventPublisher for ChannelEventBus { + async fn publish(&self, event: DomainEvent) -> DomainResult<()> { + let _ = self.tx.send(event); + Ok(()) + } +} +``` + +- [ ] **Step 1-3: Create event-publisher adapter** +- [ ] **Step 4: Verify and commit** + +```bash +git add crates/adapters/event-publisher/ +git commit -m "adapter-event-publisher: broadcast channel bus" +``` + +--- + +### Task 23: Presentation crate + +**Files:** +- Create: `crates/presentation/Cargo.toml` +- Create: `crates/presentation/src/main.rs` +- Create: `crates/presentation/src/state.rs` +- Create: `crates/presentation/src/context.rs` +- Create: `crates/presentation/src/factory.rs` +- Create: `crates/presentation/src/routes.rs` +- Create: `crates/presentation/src/errors.rs` +- Create: `crates/presentation/src/extractors.rs` +- Create: `crates/presentation/src/openapi/` — mod.rs + per-module files +- Create: `crates/presentation/src/handlers/` — auth.rs, channels.rs, schedule.rs, library.rs, admin.rs, config.rs, files.rs, iptv.rs +- Create: `crates/presentation/src/mappers/` — mod.rs + per-module files +- Create: `crates/presentation/src/background/` — mod.rs, library_sync.rs, auto_scheduler.rs, broadcast_poller.rs, webhook_consumer.rs + +**Interfaces:** +- Consumes: Everything — domain, application, api-types, infra-wiring, all adapters +- Produces: Running HTTP server + +Read old code from `k-tv-backend/api/src/`. This is the largest task. + +Handlers follow this pattern: +```rust +async fn create_channel( + State(ctx): State, + CurrentUser(user): CurrentUser, + Json(req): Json, +) -> Result, ApiError> { + let cmd = CreateChannelCommand { owner_id: user.id().value(), name: req.name, ... }; + let channel = channels::create::execute(&ctx.channel_deps, cmd).await?; + Ok(Json(ChannelResponse::from(channel))) +} +``` + +Feature flags on Cargo.toml: +```toml +[features] +default = ["sqlite", "auth-jwt", "jellyfin"] +sqlite = ["adapter-sqlite", "infra-wiring/sqlite"] +postgres = ["adapter-postgres", "infra-wiring/postgres"] +auth-jwt = ["adapter-auth/jwt"] +auth-oidc = ["adapter-auth/oidc"] +jellyfin = ["adapter-jellyfin"] +local-files = ["adapter-local-files"] +``` + +- [ ] **Step 1: Create Cargo.toml with feature flags** +- [ ] **Step 2: Create state.rs, context.rs** +- [ ] **Step 3: Create errors.rs** — ApiError enum, IntoResponse +- [ ] **Step 4: Create extractors.rs** — CurrentUser, AdminUser, OptionalCurrentUser +- [ ] **Step 5: Create mappers/** — domain → api-types From impls +- [ ] **Step 6: Create handlers/auth.rs** +- [ ] **Step 7: Create handlers/channels.rs** +- [ ] **Step 8: Create handlers/schedule.rs** +- [ ] **Step 9: Create handlers/library.rs** +- [ ] **Step 10: Create handlers/admin.rs, config.rs, iptv.rs, files.rs** +- [ ] **Step 11: Create routes.rs** — wire all handlers +- [ ] **Step 12: Create openapi/** — utoipa merge +- [ ] **Step 13: Create background/** — library_sync, auto_scheduler, broadcast_poller, webhook_consumer +- [ ] **Step 14: Create factory.rs** — build all adapters +- [ ] **Step 15: Create main.rs** — startup wiring +- [ ] **Step 16: Verify and commit** + +Run: `cargo build -p presentation` + +```bash +git add crates/presentation/ +git commit -m "presentation: HTTP server with handlers, routes, OpenAPI, background tasks" +``` + +--- + +### Task 24: MCP crate + +**Files:** +- Create: `crates/mcp/Cargo.toml` +- Create: `crates/mcp/src/main.rs` +- Create: `crates/mcp/src/server.rs` +- Create: `crates/mcp/src/error.rs` +- Create: `crates/mcp/src/tools/mod.rs`, `channels.rs`, `library.rs`, `schedule.rs` + +**Interfaces:** +- Consumes: `domain`, `application`, `infra-wiring`, adapter crates +- Produces: MCP stdio binary + +Read from `k-tv-backend/mcp/src/`. Adapt tool implementations to call application use cases instead of domain services directly. + +- [ ] **Step 1-5: Create MCP crate** +- [ ] **Step 6: Verify and commit** + +Run: `cargo build -p mcp` + +```bash +git add crates/mcp/ +git commit -m "mcp: MCP server calling application use cases" +``` + +--- + +### Task 25: Cleanup — deprecate old code, update Docker, CI + +**Files:** +- Modify: `k-tv-backend/README.md` — add DEPRECATED notice +- Modify: `compose.yml` — point to new presentation binary +- Modify: `compose.traefik.yml` — update if needed +- Modify: `.dockerignore` if exists +- Create or modify: `Dockerfile` for new structure + +- [ ] **Step 1: Add DEPRECATED to k-tv-backend README** +- [ ] **Step 2: Update Docker build to use crates/presentation** +- [ ] **Step 3: Update compose files** +- [ ] **Step 4: Final full build and test** + +Run: `cargo build --release -p presentation` +Run: `cargo test --workspace` + +```bash +git add . +git commit -m "deprecate k-tv-backend, update Docker for new crate structure" +``` diff --git a/docs/superpowers/specs/2026-07-12-backend-restructure-design.md b/docs/superpowers/specs/2026-07-12-backend-restructure-design.md new file mode 100644 index 0000000..5115476 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-backend-restructure-design.md @@ -0,0 +1,393 @@ +# K-TV Backend Restructure — Design Spec + +## Goal + +Restructure the k-tv backend from the current 3-crate layout (`domain/infra/api` inside `k-tv-backend/`) to a proper DDD hexagonal architecture matching the movies-diary reference project. The old code stays untouched; new code lives in `crates/` at the repo root. + +## Decisions Made + +| Decision | Choice | +|----------|--------| +| k-core dependency | Drop entirely, inline what's needed | +| MCP crate | Keep as separate crate under `crates/mcp` | +| Event system | Full event infrastructure (ports + adapter crates) | +| Adapter granularity | One crate per adapter | +| Entity encapsulation | Full — private fields, `new()`, `from_persistence()`, getters | +| ID types | Newtype IDs via `uuid_id!` macro | +| infra-wiring | Used only by presentation/mcp + adapters, NOT by application | +| Test doubles | InMemory + Noops (no Fakes or Panics) | +| Migration approach | Incremental bottom-up — old `k-tv-backend/` stays, new code in `crates/` | + +## Workspace Layout + +``` +/mnt/drive/dev/k-tv/ + Cargo.toml # NEW workspace root + crates/ + domain/ + application/ + api-types/ + infra-wiring/ + presentation/ + mcp/ + adapters/ + adapter-common/ + sqlite/ + postgres/ + auth/ + jellyfin/ + local-files/ + event-publisher/ + k-tv-backend/ # OLD code (DEPRECATED, untouched) + k-tv-frontend/ # Frontend (untouched) + migrations_sqlite/ # Copied from k-tv-backend/migrations_sqlite/ +``` + +## Dependency Graph + +``` +domain (zero I/O) + ↑ +application (depends on domain only) + ↑ +api-types (depends on domain only) + ↑ +adapters/* (depend on domain, some on adapter-common, some on infra-wiring for DbPool) + ↑ +presentation / mcp (depend on everything, wire it all together) + ↑ +infra-wiring (DbPool enum, startup Config — used by presentation + adapters) +``` + +Application does NOT depend on infra-wiring. Use cases receive everything through ports and parameters. + +## Domain Crate (`crates/domain/`) + +Zero I/O dependencies. Contains all business logic, types, ports, and test doubles. + +### Module Structure + +``` +src/ + lib.rs + + errors/ + mod.rs # DomainError enum (#[non_exhaustive]), DomainResult + + events/ + mod.rs # DomainEvent enum, EventEnvelope, AckHandle trait + + models/ + mod.rs + user.rs # User + channel.rs # Channel, ScheduleConfig, ProgrammingBlock, BlockContent + schedule.rs # GeneratedSchedule, ScheduledSlot, CurrentBroadcast + media.rs # MediaItem, PlaybackRecord + library.rs # LibraryItem, LibraryCollection, ShowSummary, SeasonSummary, LibrarySyncResult, LibrarySyncLogEntry + config_snapshot.rs # ChannelConfigSnapshot + activity.rs # ActivityEvent + provider_config.rs # ProviderConfigRow + collections.rs # PageParams, Paginated + + value_objects/ + mod.rs # re-exports + uuid_id! macro + ids.rs # UserId, ChannelId, SlotId, BlockId, ScheduleId, MediaItemId (newtypes) + auth.rs # Email, Password, ValidationError + scheduling.rs # ContentType, MediaFilter, FillStrategy, RecyclePolicy, Weekday + channel.rs # AccessMode, LogoPosition + oidc.rs # IssuerUrl, ClientId, ClientSecret, RedirectUrl, etc. + search.rs # LibrarySearchFilter + + ports/ + mod.rs + auth.rs # AuthService (password hashing, JWT validation) + user.rs # UserCommand, UserQuery (CQRS split) + channel.rs # ChannelCommand, ChannelQuery + schedule.rs # ScheduleCommand, ScheduleQuery + library.rs # LibraryCommand, LibraryQuery, LibrarySyncAdapter + media.rs # IMediaProvider, IProviderRegistry, ProviderCapabilities, StreamingProtocol + events.rs # EventPublisher, EventConsumer, EventHandler + settings.rs # AppSettingsRepository + activity.rs # ActivityLogRepository + provider_config.rs # ProviderConfigRepository + transcode.rs # TranscodeSettingsRepository + + services/ + mod.rs + schedule/ + mod.rs # ScheduleEngineService (generate_schedule, get_current_broadcast, get_epg) + fill.rs # fill_block(), fill_best_fit(), fill_sequential() + recycle.rs # apply_recycle_policy() + iptv.rs # generate_m3u(), generate_xmltv() pure functions + + testing/ + mod.rs # gated behind "test-helpers" feature + in_memory.rs # InMemory* repositories (Mutex) + noops.rs # NoopEventPublisher, NoopMediaProvider, etc. +``` + +### Entity Pattern + +All entities have private fields with: +- `new(...)` — validated constructor for creation +- `from_persistence(...)` — unchecked constructor for DB hydration +- Getter methods for each field +- No `pub` fields + +Example: +```rust +pub struct User { id: UserId, email: Email, ... } +impl User { + pub fn new(email: Email, password_hash: Option, is_admin: bool) -> Self { ... } + pub fn from_persistence(id: UserId, ...) -> Self { ... } + pub fn id(&self) -> UserId { self.id } + pub fn email(&self) -> &Email { &self.email } +} +``` + +### ID Types + +`uuid_id!` macro generates typed wrappers: +```rust +uuid_id!(UserId, ChannelId, SlotId, BlockId, ScheduleId); +``` +Each generates a newtype around `Uuid` with `new()`, `Display`, `FromStr`, `Serialize`, `Deserialize`, `Hash`, `sqlx::Type`. + +`MediaItemId` remains a manual newtype around `String` (not UUID-based). + +### Port Design (CQRS) + +Ports split into Command (write) and Query (read): +```rust +#[async_trait] +pub trait ChannelCommand: Send + Sync { + async fn save(&self, channel: &Channel) -> DomainResult<()>; + async fn delete(&self, id: ChannelId) -> DomainResult<()>; + async fn save_config_snapshot(&self, snapshot: &ChannelConfigSnapshot) -> DomainResult<()>; + // ... +} + +#[async_trait] +pub trait ChannelQuery: Send + Sync { + async fn find_by_id(&self, id: ChannelId) -> DomainResult>; + async fn find_by_owner(&self, owner_id: UserId) -> DomainResult>; + async fn find_all(&self) -> DomainResult>; + // ... +} +``` + +### Test Doubles + +Behind `test-helpers` feature flag: +- **InMemory**: `InMemoryUserRepository`, `InMemoryChannelRepository`, etc. — `Mutex` implementations of both Command and Query traits +- **Noops**: `NoopEventPublisher`, `NoopMediaProvider`, etc. — return `Ok(())` / empty results + +## Application Crate (`crates/application/`) + +Depends only on `domain`. One bounded context per subdirectory. + +### Pattern Per Bounded Context + +``` +auth/ + mod.rs # re-exports + deps.rs # AuthDeps { user_command, user_query, auth_service, event_publisher } + commands.rs # RegisterCommand, LoginCommand (plain structs, primitive fields) + queries.rs # GetCurrentUserQuery + register.rs # pub async fn execute(deps: &AuthDeps, cmd: RegisterCommand) -> DomainResult + login.rs + logout.rs + refresh.rs + tests/ # per-use-case test files +``` + +### Bounded Contexts + +- `auth/` — register, login, logout, refresh +- `channels/` — create, update, delete, get, list, list_by_owner +- `schedule/` — generate, get_active, get_current_broadcast, get_epg, get_stream_url, list_history +- `library/` — sync, search, list_collections, list_shows, list_seasons +- `config_snapshots/` — save, restore, list, patch_label +- `admin/` — update_settings, get_settings, activity_log +- `providers/` — upsert, delete, list +- `iptv/` — m3u, xmltv + +### Use Case Signature + +```rust +pub async fn execute(deps: &ChannelCommandDeps, cmd: CreateChannelCommand) -> DomainResult { + let channel = Channel::new( + UserId::from(cmd.owner_id), + cmd.name, + cmd.timezone, + )?; + deps.channel_command.save(&channel).await?; + deps.event_publisher.publish(DomainEvent::ChannelCreated { id: channel.id() }).await?; + Ok(channel) +} +``` + +### Test Pattern + +```rust +#[cfg(test)] +#[path = "tests/create.rs"] +mod tests; + +// In tests/create.rs: +use domain::testing::{InMemoryChannelRepository, NoopEventPublisher}; + +#[tokio::test] +async fn creates_channel() { + let channels = Arc::new(InMemoryChannelRepository::new()); + let events = Arc::new(NoopEventPublisher::new()); + let deps = ChannelCommandDeps { + channel_command: channels.clone(), + channel_query: channels.clone(), + event_publisher: events, + }; + let result = create::execute(&deps, CreateChannelCommand { ... }).await.unwrap(); + assert_eq!(result.name(), "Test Channel"); +} +``` + +## API Types Crate (`crates/api-types/`) + +Depends only on `domain` and `utoipa`. Pure data types for HTTP request/response. + +``` +src/ + lib.rs + auth.rs # LoginRequest, RegisterRequest, TokenResponse, UserResponse + channels.rs # CreateChannelRequest, UpdateChannelRequest, ChannelResponse + schedule.rs # ScheduleResponse, SlotResponse, CurrentBroadcastResponse + library.rs # LibraryItemResponse, CollectionResponse, ShowResponse + admin.rs # SettingsResponse, ActivityEventResponse + config.rs # ConfigResponse, ProviderInfo + providers.rs # ProviderConfigRequest, ProviderConfigResponse + transcode.rs # TranscodeSettingsResponse, TranscodeStatsResponse + common.rs # PaginatedResponse, ErrorResponse +``` + +All structs: `#[derive(Serialize, Deserialize, utoipa::ToSchema)]`. + +## Adapter Crates (`crates/adapters/`) + +Each adapter is a standalone crate implementing domain ports. + +| Crate | Depends on | Implements | Entry point | +|-------|-----------|------------|-------------| +| `adapter-common` | `domain` | Shared helpers: `map_sqlx_error()`, row converters | N/A | +| `sqlite` | `domain`, `adapter-common`, `infra-wiring` | All `*Command`/`*Query` ports | `wire(pool) -> SqliteOutput` | +| `postgres` | `domain`, `adapter-common`, `infra-wiring` | Same ports for Postgres | `wire(pool) -> PostgresOutput` | +| `auth` | `domain` | `AuthService` (JWT, OIDC) | `JwtAuthService::new()`, `OidcService::new()` | +| `jellyfin` | `domain` | `IMediaProvider` | `JellyfinMediaProvider::new()` | +| `local-files` | `domain`, `infra-wiring` | `IMediaProvider`, `LocalIndex`, `TranscodeManager` | `LocalFilesBundle::new()` | +| `event-publisher` | `domain` | `EventPublisher`, `EventConsumer` | `ChannelEventBus::new()` | + +### SQLite Wire Output + +```rust +pub struct SqliteWireOutput { + pub user_command: Arc, + pub user_query: Arc, + pub channel_command: Arc, + pub channel_query: Arc, + pub schedule_command: Arc, + pub schedule_query: Arc, + pub library_command: Arc, + pub library_query: Arc, + // ... all ports +} +``` + +## Infra-Wiring Crate (`crates/infra-wiring/`) + +Minimal crate, breaks dependency cycles: + +```rust +pub enum DbPool { + Sqlite(SqlitePool), + Postgres(PgPool), +} + +pub struct Config { /* 30+ env var fields, startup config only */ } +impl Config { + pub fn from_env() -> Result { ... } +} +``` + +Used by: `presentation`, `mcp`, database adapter crates. NOT by `application`. + +## Presentation Crate (`crates/presentation/`) + +Main HTTP binary. Axum-based. + +``` +src/ + main.rs # Startup wiring + state.rs # AppState + context.rs # AppContext { repositories, services } + factory.rs # Builds adapters, constructs AppContext + routes.rs # Router construction + errors.rs # ApiError → HTTP response + extractors.rs # CurrentUser, AdminUser, OptionalCurrentUser + openapi/ + mod.rs # Merged OpenAPI doc via utoipa + auth.rs, channels.rs, schedule.rs, library.rs, admin.rs, config.rs, iptv.rs + handlers/ + auth.rs, channels.rs, schedule.rs, library.rs, admin.rs, config.rs, files.rs, iptv.rs + mappers/ + mod.rs + auth.rs, channels.rs, schedule.rs, library.rs, admin.rs + background/ + mod.rs + library_sync.rs + auto_scheduler.rs + broadcast_poller.rs + webhook_consumer.rs +``` + +Handlers: receive HTTP → map to application command/query → call `execute()` → map result to api-types response. + +## MCP Crate (`crates/mcp/`) + +Separate binary, stdio transport. Calls application use cases (not domain services directly). + +``` +src/ + main.rs # DB setup, wiring, stdio transport + server.rs # KTvMcpServer, tool dispatch + error.rs # MCP error types + tools/ + channels.rs, library.rs, schedule.rs +``` + +## Migration Order (Bottom-Up, Bounded-Context-at-a-Time) + +1. **Domain foundation** — errors, value objects (ids, auth, scheduling, channel), `uuid_id!` macro +2. **Domain models** — User, Channel, ScheduleConfig, ProgrammingBlock, BlockContent, MediaItem +3. **Domain models (continued)** — GeneratedSchedule, ScheduledSlot, CurrentBroadcast, LibraryItem, remaining models +4. **Domain ports** — all port traits (CQRS split) +5. **Domain services** — ScheduleEngineService, fill strategies, recycle policy, IPTV +6. **Domain events** — DomainEvent, EventEnvelope, EventPublisher/Consumer/Handler ports +7. **Domain testing** — InMemory repos, Noops +8. **Application: auth** — deps, commands, queries, use cases, tests +9. **Application: channels** — deps, commands, queries, use cases, tests +10. **Application: schedule** — deps, commands, queries, use cases, tests +11. **Application: library** — deps, commands, queries, use cases, tests +12. **Application: remaining** — config_snapshots, admin, providers, iptv +13. **API types** — all request/response DTOs with utoipa derives +14. **Infra-wiring** — DbPool, Config +15. **Adapter: adapter-common** — shared helpers +16. **Adapter: sqlite** — all repo implementations, wire function +17. **Adapter: postgres** — all repo implementations +18. **Adapter: auth** — JWT + OIDC +19. **Adapter: jellyfin** — media provider +20. **Adapter: local-files** — media provider, index, transcoder +21. **Adapter: event-publisher** — broadcast channel bus +22. **Presentation** — handlers, mappers, routes, extractors, openapi, background tasks, main +23. **MCP** — tools, server, main +24. **Cleanup** — deprecate `k-tv-backend/`, update compose files, CI + +Each step must compile and (where applicable) pass tests before moving to the next.