add support for bluesky syndication
This commit is contained in:
parent
36a1cdfab4
commit
8e3b231465
19 changed files with 342 additions and 17 deletions
.formatter.exs
lib
mix.exsmix.lockpriv/repo/migrations
|
@ -1,5 +1,5 @@
|
|||
[
|
||||
import_deps: [:oban, :ecto, :ecto_sql, :phoenix],
|
||||
import_deps: [:oban, :ecto, :ecto_sql, :phoenix, :tesla],
|
||||
subdirectories: ["priv/*/migrations"],
|
||||
plugins: [Phoenix.LiveView.HTMLFormatter],
|
||||
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
|
||||
|
|
|
@ -88,9 +88,9 @@ defmodule Core.Posts do
|
|||
with {:ok, post} <-
|
||||
post
|
||||
|> Post.publish_changeset(published_at)
|
||||
|> Core.Repo.update() do
|
||||
Core.Syndication.syndicate_to_mastodon(post)
|
||||
|
||||
|> Core.Repo.update(),
|
||||
{:ok, post} <- Core.Syndication.syndicate_to_mastodon(post),
|
||||
{:ok, post} <- Core.Syndication.syndicate_to_bluesky(post) do
|
||||
{:ok, post}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -14,7 +14,8 @@ defmodule Core.Posts.Post do
|
|||
:body,
|
||||
:deleted_at,
|
||||
:published_at,
|
||||
:syndicate_to_mastodon
|
||||
:syndicate_to_mastodon,
|
||||
:syndicate_to_bluesky
|
||||
])
|
||||
|> validate_required([:kind], message: "must have a kind")
|
||||
|> validate_required([:body], message: "must have a body")
|
||||
|
@ -90,7 +91,9 @@ defmodule Core.Posts.Post do
|
|||
as: :posts,
|
||||
left_join: mp in assoc(p, :mastodon_post),
|
||||
as: :mastodon_posts,
|
||||
preload: [mastodon_post: mp]
|
||||
left_join: bp in assoc(p, :bluesky_post),
|
||||
as: :bluesky_posts,
|
||||
preload: [mastodon_post: mp, bluesky_post: bp]
|
||||
end
|
||||
|
||||
def current(query \\ base()) do
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
defmodule Core.Syndication do
|
||||
alias __MODULE__
|
||||
|
||||
## mastodon
|
||||
|
||||
@mastodon_instance Application.compile_env!(:sloanely_but_surely, [
|
||||
Core.Syndication,
|
||||
:mastodon_instance
|
||||
|
@ -33,13 +35,13 @@ defmodule Core.Syndication do
|
|||
})
|
||||
|> Core.Repo.insert()
|
||||
|
||||
post
|
||||
{:ok, post}
|
||||
else
|
||||
post
|
||||
{:ok, post}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_mastodon_access_token do
|
||||
defp get_mastodon_access_token! do
|
||||
mastodon_account = Core.Repo.one!(Schema.MastodonAccount)
|
||||
mastodon_account.access_token
|
||||
end
|
||||
|
@ -47,7 +49,93 @@ defmodule Core.Syndication do
|
|||
defp build_mastodon_client_conn do
|
||||
%MastodonClient.Conn{
|
||||
instance: @mastodon_instance,
|
||||
access_token: get_mastodon_access_token()
|
||||
access_token: get_mastodon_access_token!()
|
||||
}
|
||||
end
|
||||
|
||||
## bluesky
|
||||
|
||||
def get_bluesky_account(%Schema.User{} = user) do
|
||||
Core.Repo.get_by(Schema.BlueskyAccount, user_id: user.id)
|
||||
end
|
||||
|
||||
def save_bluesky_account(user, identifier, password) do
|
||||
with {:ok, session_resp} <-
|
||||
Syndication.BlueskyClient.create_session(identifier, password),
|
||||
{:ok, attrs} <- parse_bluesky_token_resp(session_resp) do
|
||||
user
|
||||
|> Ecto.build_assoc(:bluesky_account)
|
||||
|> Syndication.BlueskyAccount.create_changeset(attrs)
|
||||
|> Core.Repo.insert(
|
||||
conflict_target: [:user_id],
|
||||
on_conflict: {:replace_all_except, [:id, :inserted_at]}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def refresh_bluesky_account(%Schema.BlueskyAccount{} = bluesky_account) do
|
||||
with {:ok, refresh_resp} <-
|
||||
Syndication.BlueskyClient.refresh_session(bluesky_account),
|
||||
{:ok, attrs} <- parse_bluesky_token_resp(refresh_resp) do
|
||||
bluesky_account
|
||||
|> Syndication.BlueskyAccount.refresh_changeset(attrs)
|
||||
|> Core.Repo.update()
|
||||
end
|
||||
end
|
||||
|
||||
def syndicate_to_bluesky(%Schema.Post{} = post) do
|
||||
post = Core.Repo.preload(post, [:bluesky_post])
|
||||
|
||||
if post.syndicate_to_bluesky && is_nil(post.bluesky_post) do
|
||||
bluesky_account = get_bluesky_account!()
|
||||
|
||||
with {:ok, resp} <- Syndication.BlueskyClient.post_status(bluesky_account, post.body) do
|
||||
post
|
||||
|> Ecto.build_assoc(:bluesky_post)
|
||||
|> Syndication.BlueskyPost.changeset(resp.body)
|
||||
|> Core.Repo.insert()
|
||||
|
||||
{:ok, post}
|
||||
end
|
||||
else
|
||||
{:ok, post}
|
||||
end
|
||||
end
|
||||
|
||||
def bsky_app_url(%Schema.BlueskyPost{uri: uri}) do
|
||||
[did, id] = Regex.run(~r|at://(did:.*)/app.bsky.feed.post/(.*)|, uri, capture: :all_but_first)
|
||||
|
||||
"https://bsky.app/profile/#{did}/post/#{id}"
|
||||
end
|
||||
|
||||
defp parse_bluesky_token_resp(resp) do
|
||||
with {:ok, %{iat: access_jwt_iat, exp: access_jwt_exp}} <-
|
||||
parse_bluesky_jwt(resp.body["accessJwt"]),
|
||||
{:ok, %{iat: refresh_jwt_iat, exp: refresh_jwt_exp}} <-
|
||||
parse_bluesky_jwt(resp.body["refreshJwt"]) do
|
||||
{:ok,
|
||||
%{
|
||||
handle: resp.body["handle"],
|
||||
did: resp.body["did"],
|
||||
access_jwt: resp.body["accessJwt"],
|
||||
access_jwt_iat: access_jwt_iat,
|
||||
access_jwt_exp: access_jwt_exp,
|
||||
refresh_jwt: resp.body["refreshJwt"],
|
||||
refresh_jwt_iat: refresh_jwt_iat,
|
||||
refresh_jwt_exp: refresh_jwt_exp
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_bluesky_jwt(jwt) do
|
||||
with {:ok, claims} <- Joken.peek_claims(jwt),
|
||||
{:ok, iat} <- DateTime.from_unix(claims["iat"]),
|
||||
{:ok, exp} <- DateTime.from_unix(claims["exp"]) do
|
||||
{:ok, %{iat: iat, exp: exp}}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_bluesky_account! do
|
||||
Core.Repo.one!(Schema.BlueskyAccount)
|
||||
end
|
||||
end
|
||||
|
|
47
lib/core/syndication/bluesky_account.ex
Normal file
47
lib/core/syndication/bluesky_account.ex
Normal file
|
@ -0,0 +1,47 @@
|
|||
defmodule Core.Syndication.BlueskyAccount do
|
||||
import Ecto.Changeset
|
||||
|
||||
def create_changeset(%Schema.BlueskyAccount{} = bluesky_account, attrs) do
|
||||
bluesky_account
|
||||
|> cast(attrs, [
|
||||
:handle,
|
||||
:did,
|
||||
:access_jwt,
|
||||
:access_jwt_iat,
|
||||
:access_jwt_exp,
|
||||
:refresh_jwt,
|
||||
:refresh_jwt_iat,
|
||||
:refresh_jwt_exp
|
||||
])
|
||||
|> validate_required([
|
||||
:handle,
|
||||
:did,
|
||||
:access_jwt,
|
||||
:access_jwt_iat,
|
||||
:access_jwt_exp,
|
||||
:refresh_jwt,
|
||||
:refresh_jwt_iat,
|
||||
:refresh_jwt_exp
|
||||
])
|
||||
end
|
||||
|
||||
def refresh_changeset(%Schema.BlueskyAccount{} = bluesky_account, attrs) do
|
||||
bluesky_account
|
||||
|> cast(attrs, [
|
||||
:access_jwt,
|
||||
:access_jwt_iat,
|
||||
:access_jwt_exp,
|
||||
:refresh_jwt,
|
||||
:refresh_jwt_iat,
|
||||
:refresh_jwt_exp
|
||||
])
|
||||
|> validate_required([
|
||||
:access_jwt,
|
||||
:access_jwt_iat,
|
||||
:access_jwt_exp,
|
||||
:refresh_jwt,
|
||||
:refresh_jwt_iat,
|
||||
:refresh_jwt_exp
|
||||
])
|
||||
end
|
||||
end
|
50
lib/core/syndication/bluesky_client.ex
Normal file
50
lib/core/syndication/bluesky_client.ex
Normal file
|
@ -0,0 +1,50 @@
|
|||
defmodule Core.Syndication.BlueskyClient do
|
||||
@middleware [
|
||||
Tesla.Middleware.Logger,
|
||||
{Tesla.Middleware.BaseUrl, "https://bsky.social/xrpc"},
|
||||
Tesla.Middleware.JSON
|
||||
]
|
||||
|
||||
def new(opts \\ []) do
|
||||
middleware = @middleware
|
||||
|
||||
middleware =
|
||||
case Keyword.fetch(opts, :token) do
|
||||
{:ok, token} ->
|
||||
middleware ++ [{Tesla.Middleware.BearerAuth, token: token}]
|
||||
|
||||
:error ->
|
||||
middleware
|
||||
end
|
||||
|
||||
Tesla.client(middleware)
|
||||
end
|
||||
|
||||
def create_session(identifier, password) do
|
||||
client = new()
|
||||
|
||||
Tesla.post(client, "/com.atproto.server.createSession", %{
|
||||
identifier: identifier,
|
||||
password: password
|
||||
})
|
||||
end
|
||||
|
||||
def refresh_session(%Schema.BlueskyAccount{refresh_jwt: token}) do
|
||||
client = new(token: token)
|
||||
|
||||
Tesla.post(client, "/com.atproto.server.refreshSession", nil)
|
||||
end
|
||||
|
||||
def post_status(%Schema.BlueskyAccount{} = bluesky_account, text) do
|
||||
client = new(token: bluesky_account.access_jwt)
|
||||
|
||||
Tesla.post(client, "/com.atproto.repo.createRecord", %{
|
||||
repo: bluesky_account.did,
|
||||
collection: "app.bsky.feed.post",
|
||||
record: %{
|
||||
text: text,
|
||||
createdAt: DateTime.utc_now() |> DateTime.to_iso8601()
|
||||
}
|
||||
})
|
||||
end
|
||||
end
|
9
lib/core/syndication/bluesky_post.ex
Normal file
9
lib/core/syndication/bluesky_post.ex
Normal file
|
@ -0,0 +1,9 @@
|
|||
defmodule Core.Syndication.BlueskyPost do
|
||||
import Ecto.Changeset
|
||||
|
||||
def changeset(%Schema.BlueskyPost{} = bluesky_post, attrs) do
|
||||
bluesky_post
|
||||
|> cast(attrs, [:cid, :uri, :commit])
|
||||
|> validate_required([:cid, :uri, :commit])
|
||||
end
|
||||
end
|
|
@ -1,6 +1,8 @@
|
|||
defmodule Schema do
|
||||
@moduledoc false
|
||||
use Boundary, deps: [], exports: [Post, User, UserToken, MastodonAccount]
|
||||
use Boundary,
|
||||
deps: [],
|
||||
exports: [Post, User, UserToken, MastodonAccount, BlueskyAccount, BlueskyPost]
|
||||
|
||||
defmacro __using__(_) do
|
||||
quote do
|
||||
|
|
20
lib/schema/bluesky_account.ex
Normal file
20
lib/schema/bluesky_account.ex
Normal file
|
@ -0,0 +1,20 @@
|
|||
defmodule Schema.BlueskyAccount do
|
||||
use Schema
|
||||
|
||||
schema "bluesky_accounts" do
|
||||
field :handle, :string
|
||||
field :did, :string
|
||||
|
||||
field :access_jwt, :string, redact: true
|
||||
field :access_jwt_iat, :utc_datetime
|
||||
field :access_jwt_exp, :utc_datetime
|
||||
|
||||
field :refresh_jwt, :string, redact: true
|
||||
field :refresh_jwt_iat, :utc_datetime
|
||||
field :refresh_jwt_exp, :utc_datetime
|
||||
|
||||
belongs_to :user, Schema.User
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
13
lib/schema/bluesky_post.ex
Normal file
13
lib/schema/bluesky_post.ex
Normal file
|
@ -0,0 +1,13 @@
|
|||
defmodule Schema.BlueskyPost do
|
||||
use Schema
|
||||
|
||||
schema "bluesky_posts" do
|
||||
field :cid, :string
|
||||
field :uri, :string
|
||||
field :commit, :map
|
||||
|
||||
belongs_to :post, Schema.Post
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
|
@ -29,8 +29,10 @@ defmodule Schema.Post do
|
|||
field :deleted_at, :utc_datetime_usec
|
||||
|
||||
field :syndicate_to_mastodon, :boolean
|
||||
field :syndicate_to_bluesky, :boolean
|
||||
|
||||
has_one :mastodon_post, Schema.MastodonPost
|
||||
has_one :bluesky_post, Schema.BlueskyPost
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
|
|
@ -9,6 +9,7 @@ defmodule Schema.User do
|
|||
field :current_password, :string, virtual: true, redact: true
|
||||
|
||||
has_one :mastodon_account, Schema.MastodonAccount
|
||||
has_one :bluesky_account, Schema.BlueskyAccount
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
|
|
@ -55,11 +55,22 @@
|
|||
<% end %>
|
||||
</div>
|
||||
<div class="flex flex-row gap-x-2">
|
||||
<%= if @status.mastodon_post do %>
|
||||
<.link href={@status.mastodon_post.url} target="_blank" class="u-syndication">
|
||||
tech.lgbt
|
||||
</.link>
|
||||
<% end %>
|
||||
<.link
|
||||
:if={@status.mastodon_post}
|
||||
href={@status.mastodon_post.url}
|
||||
target="_blank"
|
||||
class="u-syndication"
|
||||
>
|
||||
tech.lgbt
|
||||
</.link>
|
||||
<.link
|
||||
:if={@status.bluesky_post}
|
||||
href={Core.Syndication.bsky_app_url(@status.bluesky_post)}
|
||||
target="_blank"
|
||||
class="u-syndication"
|
||||
>
|
||||
bsky.app
|
||||
</.link>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
|
|
|
@ -31,6 +31,12 @@
|
|||
class="self-end"
|
||||
field={@form[:syndicate_to_mastodon]}
|
||||
/>
|
||||
<.input
|
||||
type="checkbox"
|
||||
label="syndicate to bluesky"
|
||||
class="self-end"
|
||||
field={@form[:syndicate_to_bluesky]}
|
||||
/>
|
||||
<%= if @post.deleted_at do %>
|
||||
<.button phx-click="undelete" class="self-end">undelete</.button>
|
||||
<% else %>
|
||||
|
|
|
@ -3,11 +3,26 @@ defmodule Web.AdminSyndicationLive do
|
|||
|
||||
def mount(_params, _session, socket) do
|
||||
mastodon_account = Core.Syndication.get_mastodon_account(socket.assigns.current_user)
|
||||
bluesky_account = Core.Syndication.get_bluesky_account(socket.assigns.current_user)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:mastodon_account, mastodon_account)
|
||||
|> assign(:bluesky_account, bluesky_account)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"save_bluesky_account",
|
||||
%{"bluesky_account" => %{"identifier" => identifier, "password" => password}},
|
||||
socket
|
||||
) do
|
||||
{:ok, bluesky_account} =
|
||||
Core.Syndication.save_bluesky_account(socket.assigns.current_user, identifier, password)
|
||||
|
||||
socket = assign(socket, bluesky_account: bluesky_account)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -31,7 +31,18 @@
|
|||
<% end %>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Bluesky: </strong>Coming soon!
|
||||
<strong>Bluesky: </strong>
|
||||
<%= if @bluesky_account do %>
|
||||
<.link href={"https://bsky.app/profile/#{@bluesky_account.handle}"} target="_blank">
|
||||
@{@bluesky_account.handle}
|
||||
</.link>
|
||||
<% else %>
|
||||
<.form :let={f} for={%{}} as={:bluesky_account} phx-submit="save_bluesky_account">
|
||||
<.input field={f[:identifier]} label="email" />
|
||||
<.input field={f[:password]} type="password" />
|
||||
<.button type="submit">Connect account</.button>
|
||||
</.form>
|
||||
<% end %>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
|
1
mix.exs
1
mix.exs
|
@ -70,6 +70,7 @@ defmodule SlaonelyButSurely.MixProject do
|
|||
{:ueberauth_mastodon, "~> 0.3.0"},
|
||||
{:tesla, "~> 1.14"},
|
||||
{:mint, "~> 1.7"},
|
||||
{:joken, "~> 2.6"},
|
||||
|
||||
# Added dev and/or test dependencies
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
|
|
2
mix.lock
2
mix.lock
|
@ -31,6 +31,8 @@
|
|||
"igniter": {:hex, :igniter, "0.5.47", "7a1041d5e38303e526fa6b6de37c9e78013f5cb573833ed51183d18e3a152f10", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:inflex, "~> 2.0", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "53a900909e20f217a25d15a34fef629c562b4822c1fb39cfa5d6999bc72992ed"},
|
||||
"inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"},
|
||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
|
||||
"jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"},
|
||||
"mastodon_client": {:hex, :mastodon_client, "0.1.0", "7f1a9e54367d0e126c76d0bb1097de346fb9c6da6d682a6b57bc936c92d643eb", [:mix], [{:hackney, "~> 1.18", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}, {:tesla, "~> 1.4", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "3daa37fc7a95430eb0b77cdb168af58b8db84efea6e0f3244d15d45f39a87160"},
|
||||
"mdex": {:hex, :mdex, "0.5.0", "252c83cebc6a089801dfc1e142b4d98c9c358378ec7096a94796bce8bd13b0fc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: false]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "73e3ddee03130267e3be6aaf47a7f423c6f86add4bb5c62b352465cd9fb87d95"},
|
||||
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
|
||||
|
|
44
priv/repo/migrations/20250502113428_add_bluesky_tables.exs
Normal file
44
priv/repo/migrations/20250502113428_add_bluesky_tables.exs
Normal file
|
@ -0,0 +1,44 @@
|
|||
defmodule Core.Repo.Migrations.AddBlueskyTables do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:bluesky_accounts, primary_key: false) do
|
||||
add :id, :uuid, primary_key: true
|
||||
|
||||
add :user_id, references(:users, type: :uuid, on_delete: :delete_all), null: false
|
||||
|
||||
add :handle, :text, null: false
|
||||
add :did, :text, null: false
|
||||
|
||||
add :access_jwt, :text, null: false
|
||||
add :access_jwt_iat, :utc_datetime, null: false
|
||||
add :access_jwt_exp, :utc_datetime, null: false
|
||||
|
||||
add :refresh_jwt, :text, null: false
|
||||
add :refresh_jwt_iat, :utc_datetime, null: false
|
||||
add :refresh_jwt_exp, :utc_datetime, null: false
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create unique_index(:bluesky_accounts, [:user_id])
|
||||
|
||||
create table(:bluesky_posts, primary_key: false) do
|
||||
add :id, :uuid, primary_key: true
|
||||
|
||||
add :post_id, references(:posts, type: :uuid, on_delete: :delete_all), null: false
|
||||
|
||||
add :cid, :text, null: false
|
||||
add :uri, :text, null: false
|
||||
add :commit, :jsonb, null: false
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create unique_index(:bluesky_posts, [:post_id])
|
||||
|
||||
alter table(:posts) do
|
||||
add :syndicate_to_bluesky, :boolean, default: false, null: false
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue