Skip to content
Merged
193 changes: 193 additions & 0 deletions apps/balados_sync_core/lib/balados_sync_core/aggregates/like.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
defmodule BaladosSyncCore.Aggregates.Like do
@moduledoc """
Like aggregate for the CQRS/Event Sourcing system.

Handles user likes for podcasts and episodes.

## Bounded Context

This aggregate is part of the like bounded context, separate from
subscriptions, play tracking, playlists, and collections.

## State

- `user_id` - Unique identifier for the user
- `podcast_likes` - Map of `%{feed => %{liked_at, unliked_at}}`
- `episode_likes` - Map of `%{item => %{liked_at, unliked_at, rss_source_feed}}`
"""

defstruct [
:user_id,
# %{rss_source_feed => %{liked_at, unliked_at}}
podcast_likes: %{},
# %{rss_source_item => %{liked_at, unliked_at, rss_source_feed}}
episode_likes: %{}
]

alias BaladosSyncCore.Commands.{
LikePodcast,
UnlikePodcast,
LikeEpisode,
UnlikeEpisode,
SnapshotLike
}

alias BaladosSyncCore.Events.{
PodcastLiked,
PodcastUnliked,
EpisodeLiked,
EpisodeUnliked,
LikeCheckpoint
}

alias BaladosSyncCore.LikeNormalizer

# LikePodcast - idempotent: no-op if already liked
def execute(%__MODULE__{} = state, %LikePodcast{} = cmd) do
case Map.get(state.podcast_likes, cmd.rss_source_feed) do
%{liked_at: liked_at, unliked_at: unliked_at}
when not is_nil(liked_at) and is_nil(unliked_at) ->
[]

_ ->
%PodcastLiked{
user_id: cmd.user_id,
rss_source_feed: cmd.rss_source_feed,
liked_at: cmd.liked_at || DateTime.utc_now() |> DateTime.truncate(:second),
timestamp: DateTime.utc_now() |> DateTime.truncate(:second),
event_infos: cmd.event_infos || %{}
}
end
end

# UnlikePodcast - idempotent: no-op if not currently liked
def execute(%__MODULE__{} = state, %UnlikePodcast{} = cmd) do
case Map.get(state.podcast_likes, cmd.rss_source_feed) do
%{liked_at: liked_at, unliked_at: unliked_at}
when not is_nil(liked_at) and is_nil(unliked_at) ->
%PodcastUnliked{
user_id: cmd.user_id,
rss_source_feed: cmd.rss_source_feed,
unliked_at: cmd.unliked_at || DateTime.utc_now() |> DateTime.truncate(:second),
timestamp: DateTime.utc_now() |> DateTime.truncate(:second),
event_infos: cmd.event_infos || %{}
}

_ ->
[]
end
end

# LikeEpisode - idempotent
def execute(%__MODULE__{} = state, %LikeEpisode{} = cmd) do
case Map.get(state.episode_likes, cmd.rss_source_item) do
%{liked_at: liked_at, unliked_at: unliked_at}
when not is_nil(liked_at) and is_nil(unliked_at) ->
[]

_ ->
%EpisodeLiked{
user_id: cmd.user_id,
rss_source_feed: cmd.rss_source_feed,
rss_source_item: cmd.rss_source_item,
liked_at: cmd.liked_at || DateTime.utc_now() |> DateTime.truncate(:second),
timestamp: DateTime.utc_now() |> DateTime.truncate(:second),
event_infos: cmd.event_infos || %{}
}
end
end

# UnlikeEpisode - idempotent
def execute(%__MODULE__{} = state, %UnlikeEpisode{} = cmd) do
case Map.get(state.episode_likes, cmd.rss_source_item) do
%{liked_at: liked_at, unliked_at: unliked_at}
when not is_nil(liked_at) and is_nil(unliked_at) ->
%EpisodeUnliked{
user_id: cmd.user_id,
rss_source_feed: cmd.rss_source_feed,
rss_source_item: cmd.rss_source_item,
unliked_at: cmd.unliked_at || DateTime.utc_now() |> DateTime.truncate(:second),
timestamp: DateTime.utc_now() |> DateTime.truncate(:second),
event_infos: cmd.event_infos || %{}
}

_ ->
[]
end
end

# SnapshotLike — skip if aggregate has never been initialized
def execute(%__MODULE__{user_id: nil}, %SnapshotLike{}), do: []

def execute(%__MODULE__{} = state, %SnapshotLike{}) do
%LikeCheckpoint{
user_id: state.user_id,
podcast_likes: state.podcast_likes,
episode_likes: state.episode_likes,
timestamp: DateTime.utc_now() |> DateTime.truncate(:second)
}
end

# Apply events

def apply(%__MODULE__{} = state, %PodcastLiked{} = event) do
updated = %{
liked_at: event.liked_at,
unliked_at: nil
}

%{
state
| user_id: event.user_id,
podcast_likes: Map.put(state.podcast_likes, event.rss_source_feed, updated)
}
end

def apply(%__MODULE__{} = state, %PodcastUnliked{} = event) do
case Map.get(state.podcast_likes, event.rss_source_feed) do
nil ->
state

like ->
updated = Map.put(like, :unliked_at, event.unliked_at)
%{state | podcast_likes: Map.put(state.podcast_likes, event.rss_source_feed, updated)}
end
end

def apply(%__MODULE__{} = state, %EpisodeLiked{} = event) do
updated = %{
liked_at: event.liked_at,
unliked_at: nil,
rss_source_feed: event.rss_source_feed
}

%{
state
| user_id: event.user_id,
episode_likes: Map.put(state.episode_likes, event.rss_source_item, updated)
}
end

def apply(%__MODULE__{} = state, %EpisodeUnliked{} = event) do
case Map.get(state.episode_likes, event.rss_source_item) do
nil ->
state

like ->
updated = Map.put(like, :unliked_at, event.unliked_at)
%{state | episode_likes: Map.put(state.episode_likes, event.rss_source_item, updated)}
end
end

def apply(%__MODULE__{} = state, %LikeCheckpoint{} = event) do
# Normalize keys: after JSON deserialization, nested map keys may be strings
%{
state
| user_id: event.user_id,
podcast_likes: LikeNormalizer.normalize(event.podcast_likes || %{}),
episode_likes: LikeNormalizer.normalize(event.episode_likes || %{})
}
end

def apply(%__MODULE__{} = state, _event), do: state
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
defmodule BaladosSyncCore.Commands.LikeEpisode do
@moduledoc """
Command to like an episode.
"""

@type t :: %__MODULE__{
user_id: String.t(),
rss_source_feed: String.t(),
rss_source_item: String.t(),
liked_at: DateTime.t() | nil,
event_infos: map()
}

defstruct [
:user_id,
:rss_source_feed,
:rss_source_item,
:liked_at,
:event_infos
]
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
defmodule BaladosSyncCore.Commands.LikePodcast do
@moduledoc """
Command to like a podcast feed.
"""

@type t :: %__MODULE__{
user_id: String.t(),
rss_source_feed: String.t(),
liked_at: DateTime.t() | nil,
event_infos: map()
}

defstruct [
:user_id,
:rss_source_feed,
:liked_at,
:event_infos
]
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
defmodule BaladosSyncCore.Commands.SnapshotLike do
@moduledoc "Command to snapshot the Like aggregate state."
defstruct [:user_id]
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
defmodule BaladosSyncCore.Commands.UnlikeEpisode do
@moduledoc """
Command to unlike an episode.
"""

@type t :: %__MODULE__{
user_id: String.t(),
rss_source_feed: String.t(),
rss_source_item: String.t(),
unliked_at: DateTime.t() | nil,
event_infos: map()
}

defstruct [
:user_id,
:rss_source_feed,
:rss_source_item,
:unliked_at,
:event_infos
]
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
defmodule BaladosSyncCore.Commands.UnlikePodcast do
@moduledoc """
Command to unlike a podcast feed.
"""

@type t :: %__MODULE__{
user_id: String.t(),
rss_source_feed: String.t(),
unliked_at: DateTime.t() | nil,
event_infos: map()
}

defstruct [
:user_id,
:rss_source_feed,
:unliked_at,
:event_infos
]
end
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ defmodule BaladosSyncCore.Dispatcher.Router do
alias BaladosSyncCore.Aggregates.PlayTracking
alias BaladosSyncCore.Aggregates.Playlist
alias BaladosSyncCore.Aggregates.Collection
alias BaladosSyncCore.Aggregates.Like
alias BaladosSyncCore.Middleware.ValidateSubscription

alias BaladosSyncCore.Commands.{
Expand Down Expand Up @@ -32,7 +33,12 @@ defmodule BaladosSyncCore.Dispatcher.Router do
UpdateCollection,
DeleteCollection,
ReorderCollectionFeed,
ChangeCollectionVisibility
ChangeCollectionVisibility,
LikePodcast,
UnlikePodcast,
LikeEpisode,
UnlikeEpisode,
SnapshotLike
}

# Registered globally (runs for all commands) because Commanded doesn't support
Expand Down Expand Up @@ -104,4 +110,18 @@ defmodule BaladosSyncCore.Dispatcher.Router do
],
to: Collection
)

# Like aggregate (podcast and episode likes)
identify(Like, by: :user_id, prefix: "like-")

dispatch(
[
LikePodcast,
UnlikePodcast,
LikeEpisode,
UnlikeEpisode,
SnapshotLike
],
to: Like
)
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
defmodule BaladosSyncCore.Events.EpisodeLiked do
@derive Jason.Encoder
defstruct [
:user_id,
:rss_source_feed,
:rss_source_item,
:liked_at,
:timestamp,
:event_infos
]
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
defmodule BaladosSyncCore.Events.EpisodeUnliked do
@derive Jason.Encoder
defstruct [
:user_id,
:rss_source_feed,
:rss_source_item,
:unliked_at,
:timestamp,
:event_infos
]
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
defmodule BaladosSyncCore.Events.LikeCheckpoint do
@derive Jason.Encoder
defstruct [
:user_id,
:podcast_likes,
:episode_likes,
:timestamp
]
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
defmodule BaladosSyncCore.Events.PodcastLiked do
@derive Jason.Encoder
defstruct [
:user_id,
:rss_source_feed,
:liked_at,
:timestamp,
:event_infos
]
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
defmodule BaladosSyncCore.Events.PodcastUnliked do
@derive Jason.Encoder
defstruct [
:user_id,
:rss_source_feed,
:unliked_at,
:timestamp,
:event_infos
]
end
Loading