diff --git a/.env.example b/.env.example index 718f731..ec7e890 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,14 @@ S3_VIRTUAL_HOST=false # Email (Brevo) BREVO_API_KEY=your-brevo-api-key + +# ClickHouse (analytics / metrics) +# single connection URL (overrides all individual vars below) +CLICKHOUSE_URL=http://default:password@localhost:8123/mixer_metrics +# individual vars (used when CLICKHOUSE_URL is not set) +CLICKHOUSE_HOST=localhost +CLICKHOUSE_PORT=8123 +CLICKHOUSE_DATABASE=mixer_metrics +CLICKHOUSE_USERNAME=default +CLICKHOUSE_PASSWORD= +CLICKHOUSE_SCHEME=http diff --git a/lib/mixer/metrics.ex b/lib/mixer/metrics.ex index 2fc7378..a0655f5 100644 --- a/lib/mixer/metrics.ex +++ b/lib/mixer/metrics.ex @@ -33,7 +33,8 @@ defmodule Mixer.Metrics do # Event types # --------------------------------------------------------------------------- - @type event_type :: :view | :like | :unlike | :comment | :share + @type event_type :: + :view | :post | :comment | :like | :unlike | :share | :delete_post | :delete_comment @type track_opt :: {:user_id, binary() | nil} @@ -70,6 +71,33 @@ defmodule Mixer.Metrics do @spec track_share(binary(), [track_opt()]) :: :ok def track_share(tweet_id, opts \\ []), do: enqueue("share", tweet_id, opts) + @doc """ + Track a new top-level tweet being published. + + The event is recorded against the new tweet's own ID. + """ + @spec track_post(binary(), [track_opt()]) :: :ok + def track_post(tweet_id, opts \\ []), do: enqueue("post", tweet_id, opts) + + @doc """ + Track a top-level tweet being deleted. + + The event is recorded against the deleted tweet's ID. + Note: cascade-deleted comments are not individually tracked — only the + explicit user-initiated destroy action emits this event. + """ + @spec track_delete_post(binary(), [track_opt()]) :: :ok + def track_delete_post(tweet_id, opts \\ []), do: enqueue("delete_post", tweet_id, opts) + + @doc """ + Track a comment (reply) being deleted. + + The event is recorded against the *parent* tweet's ID so that + `get_summary/1` can reflect net comment activity on a tweet. + """ + @spec track_delete_comment(binary(), [track_opt()]) :: :ok + def track_delete_comment(tweet_id, opts \\ []), do: enqueue("delete_comment", tweet_id, opts) + # --------------------------------------------------------------------------- # Query helpers # --------------------------------------------------------------------------- diff --git a/lib/mixer/metrics/post_event.ex b/lib/mixer/metrics/post_event.ex index 71cb189..91d9c31 100644 --- a/lib/mixer/metrics/post_event.ex +++ b/lib/mixer/metrics/post_event.ex @@ -8,13 +8,16 @@ defmodule Mixer.Metrics.PostEvent do ## Event types - | event_type | Description | - |-------------|------------------------------------------| - | `"view"` | A tweet was displayed to a user | - | `"like"` | A user liked a tweet | - | `"unlike"` | A user removed their like from a tweet | - | `"comment"` | A user replied to a tweet | - | `"share"` | A user shared / reposted a tweet | + | event_type | `tweet_id` refers to | Description | + |--------------------|-----------------------|-------------------------------------------------| + | `"view"` | the viewed tweet | Tweet detail page was loaded | + | `"post"` | the new tweet | A new top-level tweet was published | + | `"comment"` | the parent tweet | A reply was posted; count against the parent | + | `"like"` | the liked tweet | A user liked a tweet | + | `"unlike"` | the unliked tweet | A user removed their like | + | `"share"` | the shared tweet | A user shared / reposted a tweet | + | `"delete_post"` | the deleted tweet | A top-level tweet was deleted by its author | + | `"delete_comment"` | the parent tweet | A reply was deleted; count against the parent | """ use Ecto.Schema diff --git a/lib/mixer/posts/tweet.ex b/lib/mixer/posts/tweet.ex index 5bd840e..9182416 100644 --- a/lib/mixer/posts/tweet.ex +++ b/lib/mixer/posts/tweet.ex @@ -32,7 +32,7 @@ defmodule Mixer.Posts.Tweet do end actions do - defaults [:read, :destroy] + defaults [:read] read :following_feed do filter expr( @@ -67,24 +67,47 @@ defmodule Mixer.Posts.Tweet do end end - # Track a "comment" metric event whenever a reply is posted. We record - # the event against the *parent* tweet so that `get_summary/1` and - # `get_bulk_summaries/1` can count how many comments each tweet received. + # Track post / comment creation metrics. + # Root tweets emit a "post" event recorded against their own ID. + # Replies emit a "comment" event recorded against the parent tweet ID so + # that `get_summary/1` can count how many replies a tweet has received. change fn changeset, context -> - case Ash.Changeset.get_attribute(changeset, :parent_tweet_id) do - nil -> - changeset + parent_tweet_id = Ash.Changeset.get_attribute(changeset, :parent_tweet_id) + user_id = context.actor && context.actor.id - parent_tweet_id -> - Ash.Changeset.after_action(changeset, fn _changeset, tweet -> - Mixer.Metrics.track_comment( - parent_tweet_id, - user_id: context.actor && context.actor.id - ) + Ash.Changeset.after_action(changeset, fn _changeset, tweet -> + if parent_tweet_id do + Mixer.Metrics.track_comment(parent_tweet_id, user_id: user_id) + else + Mixer.Metrics.track_post(tweet.id, user_id: user_id) + end - {:ok, tweet} - end) - end + {:ok, tweet} + end) + end + end + + # Explicit destroy so we can attach a metrics hook. The policy and cascade + # behaviour are identical to the previous default :destroy action. + destroy :destroy do + require_atomic? false + + change fn changeset, context -> + # Capture the record's identity *before* deletion — after the action + # completes the row no longer exists. + tweet_id = changeset.data.id + parent_tweet_id = changeset.data.parent_tweet_id + user_id = context.actor && context.actor.id + + Ash.Changeset.after_action(changeset, fn _changeset, result -> + if parent_tweet_id do + Mixer.Metrics.track_delete_comment(parent_tweet_id, user_id: user_id) + else + Mixer.Metrics.track_delete_post(tweet_id, user_id: user_id) + end + + {:ok, result} + end) end end