priv/repo/migrations/20260823173228_create_issue_notifications.exs

main at 58e6347eeb72 · 3 KB

defmodule OpenAgents.Repo.Migrations.CreateIssueNotifications do
  use Ecto.Migration

  def change do
    # Who follows an issue, and why. `subscribed` is a three-state answer
    # collapsed into a row plus a boolean: no row means "never took part",
    # `true` means "follows", and `false` means "took part and then muted".
    # Muting has to be a row rather than a deletion, because participating
    # again would otherwise resubscribe someone who already said no.
    create table(:issue_subscriptions, primary_key: false) do
      add :id, :binary_id, primary_key: true

      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all),
        null: false

      add :issue_id, references(:issues, on_delete: :delete_all), null: false
      add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
      add :reason, :string, null: false
      add :subscribed, :boolean, null: false, default: true
      timestamps(type: :utc_datetime)
    end

    create unique_index(:issue_subscriptions, [:issue_id, :user_id])
    create index(:issue_subscriptions, [:user_id])
    create index(:issue_subscriptions, [:repository_id])

    create constraint(:issue_subscriptions, :issue_subscriptions_reason_check,
             check: "reason in ('author', 'commented', 'mentioned', 'manual')"
           )

    # A notification is a pointer, never a copy. It names the repository, the
    # issue, the comment, and the actor's login, and it stores no title and no
    # body. Reading joins back through `Repositories.readable_by/2`, so a
    # recipient who loses membership stops seeing the row instead of keeping a
    # rendered snapshot of a private issue in their inbox.
    create table(:notifications, primary_key: false) do
      add :id, :binary_id, primary_key: true
      add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false

      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all),
        null: false

      add :issue_id, references(:issues, on_delete: :delete_all), null: false
      add :comment_id, references(:comments, on_delete: :delete_all)
      add :kind, :string, null: false
      add :actor_login, :string

      # The idempotency key. One event produces one string per recipient, so a
      # retried fan-out collides with the row it already wrote instead of
      # notifying twice. It is a column rather than a composite index over
      # nullable `comment_id`, because a partial index would need one variant
      # per event shape and would grow with every new shape.
      add :dedupe_key, :string, null: false

      add :read_at, :utc_datetime
      timestamps(type: :utc_datetime)
    end

    create unique_index(:notifications, [:user_id, :dedupe_key])

    # The inbox reads one recipient's rows newest first, and the unread count
    # reads the same rows filtered by `read_at`.
    create index(:notifications, [:user_id, :inserted_at])
    create index(:notifications, [:user_id, :read_at])
    create index(:notifications, [:issue_id])
    create index(:notifications, [:repository_id])

    create constraint(:notifications, :notifications_kind_check,
             check: "kind in ('mention', 'issue_comment')"
           )

    # Preference controls. A missing row means every category is on, so the
    # defaults live in one place rather than being written into every account
    # at signup and drifting from the schema afterwards.
    create table(:notification_preferences, primary_key: false) do
      add :id, :binary_id, primary_key: true
      add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
      add :mentions_enabled, :boolean, null: false, default: true
      add :issue_comments_enabled, :boolean, null: false, default: true
      timestamps(type: :utc_datetime)
    end

    create unique_index(:notification_preferences, [:user_id])
  end
end