Merge remote-tracking branch 'origin/develop' into reactions
[akkoma] / lib / pleroma / web / activity_pub / utils.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Web.ActivityPub.Utils do
6 alias Ecto.Changeset
7 alias Ecto.UUID
8 alias Pleroma.Activity
9 alias Pleroma.Notification
10 alias Pleroma.Object
11 alias Pleroma.Repo
12 alias Pleroma.User
13 alias Pleroma.Web
14 alias Pleroma.Web.ActivityPub.Visibility
15 alias Pleroma.Web.Endpoint
16 alias Pleroma.Web.Router.Helpers
17
18 import Ecto.Query
19
20 require Logger
21 require Pleroma.Constants
22
23 @supported_object_types ["Article", "Note", "Video", "Page", "Question", "Answer", "Audio"]
24 @supported_report_states ~w(open closed resolved)
25 @valid_visibilities ~w(public unlisted private direct)
26
27 # Some implementations send the actor URI as the actor field, others send the entire actor object,
28 # so figure out what the actor's URI is based on what we have.
29 def get_ap_id(%{"id" => id} = _), do: id
30 def get_ap_id(id), do: id
31
32 def normalize_params(params) do
33 Map.put(params, "actor", get_ap_id(params["actor"]))
34 end
35
36 @spec determine_explicit_mentions(map()) :: map()
37 def determine_explicit_mentions(%{"tag" => tag} = _) when is_list(tag) do
38 Enum.flat_map(tag, fn
39 %{"type" => "Mention", "href" => href} -> [href]
40 _ -> []
41 end)
42 end
43
44 def determine_explicit_mentions(%{"tag" => tag} = object) when is_map(tag) do
45 object
46 |> Map.put("tag", [tag])
47 |> determine_explicit_mentions()
48 end
49
50 def determine_explicit_mentions(_), do: []
51
52 @spec recipient_in_collection(any(), any()) :: boolean()
53 defp recipient_in_collection(ap_id, coll) when is_binary(coll), do: ap_id == coll
54 defp recipient_in_collection(ap_id, coll) when is_list(coll), do: ap_id in coll
55 defp recipient_in_collection(_, _), do: false
56
57 @spec recipient_in_message(User.t(), User.t(), map()) :: boolean()
58 def recipient_in_message(%User{ap_id: ap_id} = recipient, %User{} = actor, params) do
59 addresses = [params["to"], params["cc"], params["bto"], params["bcc"]]
60
61 cond do
62 Enum.any?(addresses, &recipient_in_collection(ap_id, &1)) -> true
63 # if the message is unaddressed at all, then assume it is directly addressed
64 # to the recipient
65 Enum.all?(addresses, &is_nil(&1)) -> true
66 # if the message is sent from somebody the user is following, then assume it
67 # is addressed to the recipient
68 User.following?(recipient, actor) -> true
69 true -> false
70 end
71 end
72
73 defp extract_list(target) when is_binary(target), do: [target]
74 defp extract_list(lst) when is_list(lst), do: lst
75 defp extract_list(_), do: []
76
77 def maybe_splice_recipient(ap_id, params) do
78 need_splice? =
79 !recipient_in_collection(ap_id, params["to"]) &&
80 !recipient_in_collection(ap_id, params["cc"])
81
82 if need_splice? do
83 cc_list = extract_list(params["cc"])
84 Map.put(params, "cc", [ap_id | cc_list])
85 else
86 params
87 end
88 end
89
90 def make_json_ld_header do
91 %{
92 "@context" => [
93 "https://www.w3.org/ns/activitystreams",
94 "#{Web.base_url()}/schemas/litepub-0.1.jsonld",
95 %{
96 "@language" => "und"
97 }
98 ]
99 }
100 end
101
102 def make_date do
103 DateTime.utc_now() |> DateTime.to_iso8601()
104 end
105
106 def generate_activity_id do
107 generate_id("activities")
108 end
109
110 def generate_context_id do
111 generate_id("contexts")
112 end
113
114 def generate_object_id do
115 Helpers.o_status_url(Endpoint, :object, UUID.generate())
116 end
117
118 def generate_id(type) do
119 "#{Web.base_url()}/#{type}/#{UUID.generate()}"
120 end
121
122 def get_notified_from_object(%{"type" => type} = object) when type in @supported_object_types do
123 fake_create_activity = %{
124 "to" => object["to"],
125 "cc" => object["cc"],
126 "type" => "Create",
127 "object" => object
128 }
129
130 get_notified_from_object(fake_create_activity)
131 end
132
133 def get_notified_from_object(object) do
134 Notification.get_notified_from_activity(%Activity{data: object}, false)
135 end
136
137 def create_context(context) do
138 context = context || generate_id("contexts")
139
140 # Ecto has problems accessing the constraint inside the jsonb,
141 # so we explicitly check for the existed object before insert
142 object = Object.get_cached_by_ap_id(context)
143
144 with true <- is_nil(object),
145 changeset <- Object.context_mapping(context),
146 {:ok, inserted_object} <- Repo.insert(changeset) do
147 inserted_object
148 else
149 _ ->
150 object
151 end
152 end
153
154 @doc """
155 Enqueues an activity for federation if it's local
156 """
157 @spec maybe_federate(any()) :: :ok
158 def maybe_federate(%Activity{local: true} = activity) do
159 if Pleroma.Config.get!([:instance, :federating]) do
160 Pleroma.Web.Federator.publish(activity)
161 end
162
163 :ok
164 end
165
166 def maybe_federate(_), do: :ok
167
168 @doc """
169 Adds an id and a published data if they aren't there,
170 also adds it to an included object
171 """
172 @spec lazy_put_activity_defaults(map(), boolean) :: map()
173 def lazy_put_activity_defaults(map, fake? \\ false)
174
175 def lazy_put_activity_defaults(map, true) do
176 map
177 |> Map.put_new("id", "pleroma:fakeid")
178 |> Map.put_new_lazy("published", &make_date/0)
179 |> Map.put_new("context", "pleroma:fakecontext")
180 |> Map.put_new("context_id", -1)
181 |> lazy_put_object_defaults(true)
182 end
183
184 def lazy_put_activity_defaults(map, _fake?) do
185 %{data: %{"id" => context}, id: context_id} = create_context(map["context"])
186
187 map
188 |> Map.put_new_lazy("id", &generate_activity_id/0)
189 |> Map.put_new_lazy("published", &make_date/0)
190 |> Map.put_new("context", context)
191 |> Map.put_new("context_id", context_id)
192 |> lazy_put_object_defaults(false)
193 end
194
195 # Adds an id and published date if they aren't there.
196 #
197 @spec lazy_put_object_defaults(map(), boolean()) :: map()
198 defp lazy_put_object_defaults(%{"object" => map} = activity, true)
199 when is_map(map) do
200 object =
201 map
202 |> Map.put_new("id", "pleroma:fake_object_id")
203 |> Map.put_new_lazy("published", &make_date/0)
204 |> Map.put_new("context", activity["context"])
205 |> Map.put_new("context_id", activity["context_id"])
206 |> Map.put_new("fake", true)
207
208 %{activity | "object" => object}
209 end
210
211 defp lazy_put_object_defaults(%{"object" => map} = activity, _)
212 when is_map(map) do
213 object =
214 map
215 |> Map.put_new_lazy("id", &generate_object_id/0)
216 |> Map.put_new_lazy("published", &make_date/0)
217 |> Map.put_new("context", activity["context"])
218 |> Map.put_new("context_id", activity["context_id"])
219
220 %{activity | "object" => object}
221 end
222
223 defp lazy_put_object_defaults(activity, _), do: activity
224
225 @doc """
226 Inserts a full object if it is contained in an activity.
227 """
228 def insert_full_object(%{"object" => %{"type" => type} = object_data} = map)
229 when is_map(object_data) and type in @supported_object_types do
230 with {:ok, object} <- Object.create(object_data) do
231 map = Map.put(map, "object", object.data["id"])
232
233 {:ok, map, object}
234 end
235 end
236
237 def insert_full_object(map), do: {:ok, map, nil}
238
239 #### Like-related helpers
240
241 @doc """
242 Returns an existing like if a user already liked an object
243 """
244 @spec get_existing_like(String.t(), map()) :: Activity.t() | nil
245 def get_existing_like(actor, %{data: %{"id" => id}}) do
246 actor
247 |> Activity.Queries.by_actor()
248 |> Activity.Queries.by_object_id(id)
249 |> Activity.Queries.by_type("Like")
250 |> limit(1)
251 |> Repo.one()
252 end
253
254 @doc """
255 Returns like activities targeting an object
256 """
257 def get_object_likes(%{data: %{"id" => id}}) do
258 id
259 |> Activity.Queries.by_object_id()
260 |> Activity.Queries.by_type("Like")
261 |> Repo.all()
262 end
263
264 def make_emoji_reaction_data(user, object, emoji, activity_id) do
265 make_like_data(user, object, activity_id)
266 |> Map.put("type", "EmojiReaction")
267 |> Map.put("content", emoji)
268 end
269
270 @spec make_like_data(User.t(), map(), String.t()) :: map()
271 def make_like_data(
272 %User{ap_id: ap_id} = actor,
273 %{data: %{"actor" => object_actor_id, "id" => id}} = object,
274 activity_id
275 ) do
276 object_actor = User.get_cached_by_ap_id(object_actor_id)
277
278 to =
279 if Visibility.is_public?(object) do
280 [actor.follower_address, object.data["actor"]]
281 else
282 [object.data["actor"]]
283 end
284
285 cc =
286 (object.data["to"] ++ (object.data["cc"] || []))
287 |> List.delete(actor.ap_id)
288 |> List.delete(object_actor.follower_address)
289
290 %{
291 "type" => "Like",
292 "actor" => ap_id,
293 "object" => id,
294 "to" => to,
295 "cc" => cc,
296 "context" => object.data["context"]
297 }
298 |> maybe_put("id", activity_id)
299 end
300
301 @spec update_element_in_object(String.t(), list(any), Object.t()) ::
302 {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
303 def update_element_in_object(property, element, object) do
304 length =
305 if is_map(element) do
306 element
307 |> Map.values()
308 |> List.flatten()
309 |> length()
310 else
311 element
312 |> length()
313 end
314
315 data =
316 Map.merge(
317 object.data,
318 %{"#{property}_count" => length, "#{property}s" => element}
319 )
320
321 object
322 |> Changeset.change(data: data)
323 |> Object.update_and_set_cache()
324 end
325
326 @spec add_emoji_reaction_to_object(Activity.t(), Object.t()) ::
327 {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
328
329 def add_emoji_reaction_to_object(
330 %Activity{data: %{"content" => emoji, "actor" => actor}},
331 object
332 ) do
333 reactions = object.data["reactions"] || %{}
334 emoji_actors = reactions[emoji] || []
335 new_emoji_actors = [actor | emoji_actors] |> Enum.uniq()
336 new_reactions = Map.put(reactions, emoji, new_emoji_actors)
337 update_element_in_object("reaction", new_reactions, object)
338 end
339
340 @spec add_like_to_object(Activity.t(), Object.t()) ::
341 {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
342 def add_like_to_object(%Activity{data: %{"actor" => actor}}, object) do
343 [actor | fetch_likes(object)]
344 |> Enum.uniq()
345 |> update_likes_in_object(object)
346 end
347
348 @spec remove_like_from_object(Activity.t(), Object.t()) ::
349 {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
350 def remove_like_from_object(%Activity{data: %{"actor" => actor}}, object) do
351 object
352 |> fetch_likes()
353 |> List.delete(actor)
354 |> update_likes_in_object(object)
355 end
356
357 defp update_likes_in_object(likes, object) do
358 update_element_in_object("like", likes, object)
359 end
360
361 defp fetch_likes(object) do
362 if is_list(object.data["likes"]) do
363 object.data["likes"]
364 else
365 []
366 end
367 end
368
369 #### Follow-related helpers
370
371 @doc """
372 Updates a follow activity's state (for locked accounts).
373 """
374 @spec update_follow_state_for_all(Activity.t(), String.t()) :: {:ok, Activity} | {:error, any()}
375 def update_follow_state_for_all(
376 %Activity{data: %{"actor" => actor, "object" => object}} = activity,
377 state
378 ) do
379 "Follow"
380 |> Activity.Queries.by_type()
381 |> Activity.Queries.by_actor(actor)
382 |> Activity.Queries.by_object_id(object)
383 |> where(fragment("data->>'state' = 'pending'"))
384 |> update(set: [data: fragment("jsonb_set(data, '{state}', ?)", ^state)])
385 |> Repo.update_all([])
386
387 User.set_follow_state_cache(actor, object, state)
388
389 activity = Activity.get_by_id(activity.id)
390
391 {:ok, activity}
392 end
393
394 def update_follow_state(
395 %Activity{data: %{"actor" => actor, "object" => object}} = activity,
396 state
397 ) do
398 new_data = Map.put(activity.data, "state", state)
399 changeset = Changeset.change(activity, data: new_data)
400
401 with {:ok, activity} <- Repo.update(changeset) do
402 User.set_follow_state_cache(actor, object, state)
403 {:ok, activity}
404 end
405 end
406
407 @doc """
408 Makes a follow activity data for the given follower and followed
409 """
410 def make_follow_data(
411 %User{ap_id: follower_id},
412 %User{ap_id: followed_id} = _followed,
413 activity_id
414 ) do
415 %{
416 "type" => "Follow",
417 "actor" => follower_id,
418 "to" => [followed_id],
419 "cc" => [Pleroma.Constants.as_public()],
420 "object" => followed_id,
421 "state" => "pending"
422 }
423 |> maybe_put("id", activity_id)
424 end
425
426 def fetch_latest_follow(%User{ap_id: follower_id}, %User{ap_id: followed_id}) do
427 "Follow"
428 |> Activity.Queries.by_type()
429 |> where(actor: ^follower_id)
430 # this is to use the index
431 |> Activity.Queries.by_object_id(followed_id)
432 |> order_by([activity], fragment("? desc nulls last", activity.id))
433 |> limit(1)
434 |> Repo.one()
435 end
436
437 #### Announce-related helpers
438
439 @doc """
440 Retruns an existing announce activity if the notice has already been announced
441 """
442 @spec get_existing_announce(String.t(), map()) :: Activity.t() | nil
443 def get_existing_announce(actor, %{data: %{"id" => ap_id}}) do
444 "Announce"
445 |> Activity.Queries.by_type()
446 |> where(actor: ^actor)
447 # this is to use the index
448 |> Activity.Queries.by_object_id(ap_id)
449 |> Repo.one()
450 end
451
452 @doc """
453 Make announce activity data for the given actor and object
454 """
455 # for relayed messages, we only want to send to subscribers
456 def make_announce_data(
457 %User{ap_id: ap_id} = user,
458 %Object{data: %{"id" => id}} = object,
459 activity_id,
460 false
461 ) do
462 %{
463 "type" => "Announce",
464 "actor" => ap_id,
465 "object" => id,
466 "to" => [user.follower_address],
467 "cc" => [],
468 "context" => object.data["context"]
469 }
470 |> maybe_put("id", activity_id)
471 end
472
473 def make_announce_data(
474 %User{ap_id: ap_id} = user,
475 %Object{data: %{"id" => id}} = object,
476 activity_id,
477 true
478 ) do
479 %{
480 "type" => "Announce",
481 "actor" => ap_id,
482 "object" => id,
483 "to" => [user.follower_address, object.data["actor"]],
484 "cc" => [Pleroma.Constants.as_public()],
485 "context" => object.data["context"]
486 }
487 |> maybe_put("id", activity_id)
488 end
489
490 @doc """
491 Make unannounce activity data for the given actor and object
492 """
493 def make_unannounce_data(
494 %User{ap_id: ap_id} = user,
495 %Activity{data: %{"context" => context}} = activity,
496 activity_id
497 ) do
498 %{
499 "type" => "Undo",
500 "actor" => ap_id,
501 "object" => activity.data,
502 "to" => [user.follower_address, activity.data["actor"]],
503 "cc" => [Pleroma.Constants.as_public()],
504 "context" => context
505 }
506 |> maybe_put("id", activity_id)
507 end
508
509 def make_unlike_data(
510 %User{ap_id: ap_id} = user,
511 %Activity{data: %{"context" => context}} = activity,
512 activity_id
513 ) do
514 %{
515 "type" => "Undo",
516 "actor" => ap_id,
517 "object" => activity.data,
518 "to" => [user.follower_address, activity.data["actor"]],
519 "cc" => [Pleroma.Constants.as_public()],
520 "context" => context
521 }
522 |> maybe_put("id", activity_id)
523 end
524
525 @spec add_announce_to_object(Activity.t(), Object.t()) ::
526 {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
527 def add_announce_to_object(
528 %Activity{data: %{"actor" => actor}},
529 object
530 ) do
531 announcements = take_announcements(object)
532
533 with announcements <- Enum.uniq([actor | announcements]) do
534 update_element_in_object("announcement", announcements, object)
535 end
536 end
537
538 def add_announce_to_object(_, object), do: {:ok, object}
539
540 @spec remove_announce_from_object(Activity.t(), Object.t()) ::
541 {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
542 def remove_announce_from_object(%Activity{data: %{"actor" => actor}}, object) do
543 with announcements <- List.delete(take_announcements(object), actor) do
544 update_element_in_object("announcement", announcements, object)
545 end
546 end
547
548 defp take_announcements(%{data: %{"announcements" => announcements}} = _)
549 when is_list(announcements),
550 do: announcements
551
552 defp take_announcements(_), do: []
553
554 #### Unfollow-related helpers
555
556 def make_unfollow_data(follower, followed, follow_activity, activity_id) do
557 %{
558 "type" => "Undo",
559 "actor" => follower.ap_id,
560 "to" => [followed.ap_id],
561 "object" => follow_activity.data
562 }
563 |> maybe_put("id", activity_id)
564 end
565
566 #### Block-related helpers
567 @spec fetch_latest_block(User.t(), User.t()) :: Activity.t() | nil
568 def fetch_latest_block(%User{ap_id: blocker_id}, %User{ap_id: blocked_id}) do
569 "Block"
570 |> Activity.Queries.by_type()
571 |> where(actor: ^blocker_id)
572 # this is to use the index
573 |> Activity.Queries.by_object_id(blocked_id)
574 |> order_by([activity], fragment("? desc nulls last", activity.id))
575 |> limit(1)
576 |> Repo.one()
577 end
578
579 def make_block_data(blocker, blocked, activity_id) do
580 %{
581 "type" => "Block",
582 "actor" => blocker.ap_id,
583 "to" => [blocked.ap_id],
584 "object" => blocked.ap_id
585 }
586 |> maybe_put("id", activity_id)
587 end
588
589 def make_unblock_data(blocker, blocked, block_activity, activity_id) do
590 %{
591 "type" => "Undo",
592 "actor" => blocker.ap_id,
593 "to" => [blocked.ap_id],
594 "object" => block_activity.data
595 }
596 |> maybe_put("id", activity_id)
597 end
598
599 #### Create-related helpers
600
601 def make_create_data(params, additional) do
602 published = params.published || make_date()
603
604 %{
605 "type" => "Create",
606 "to" => params.to |> Enum.uniq(),
607 "actor" => params.actor.ap_id,
608 "object" => params.object,
609 "published" => published,
610 "context" => params.context
611 }
612 |> Map.merge(additional)
613 end
614
615 #### Listen-related helpers
616 def make_listen_data(params, additional) do
617 published = params.published || make_date()
618
619 %{
620 "type" => "Listen",
621 "to" => params.to |> Enum.uniq(),
622 "actor" => params.actor.ap_id,
623 "object" => params.object,
624 "published" => published,
625 "context" => params.context
626 }
627 |> Map.merge(additional)
628 end
629
630 #### Flag-related helpers
631 @spec make_flag_data(map(), map()) :: map()
632 def make_flag_data(%{actor: actor, context: context, content: content} = params, additional) do
633 %{
634 "type" => "Flag",
635 "actor" => actor.ap_id,
636 "content" => content,
637 "object" => build_flag_object(params),
638 "context" => context,
639 "state" => "open"
640 }
641 |> Map.merge(additional)
642 end
643
644 def make_flag_data(_, _), do: %{}
645
646 defp build_flag_object(%{account: account, statuses: statuses} = _) do
647 [account.ap_id] ++
648 Enum.map(statuses || [], fn
649 %Activity{} = act -> act.data["id"]
650 act when is_map(act) -> act["id"]
651 act when is_binary(act) -> act
652 end)
653 end
654
655 defp build_flag_object(_), do: []
656
657 @doc """
658 Fetches the OrderedCollection/OrderedCollectionPage from `from`, limiting the amount of pages fetched after
659 the first one to `pages_left` pages.
660 If the amount of pages is higher than the collection has, it returns whatever was there.
661 """
662 def fetch_ordered_collection(from, pages_left, acc \\ []) do
663 with {:ok, response} <- Tesla.get(from),
664 {:ok, collection} <- Jason.decode(response.body) do
665 case collection["type"] do
666 "OrderedCollection" ->
667 # If we've encountered the OrderedCollection and not the page,
668 # just call the same function on the page address
669 fetch_ordered_collection(collection["first"], pages_left)
670
671 "OrderedCollectionPage" ->
672 if pages_left > 0 do
673 # There are still more pages
674 if Map.has_key?(collection, "next") do
675 # There are still more pages, go deeper saving what we have into the accumulator
676 fetch_ordered_collection(
677 collection["next"],
678 pages_left - 1,
679 acc ++ collection["orderedItems"]
680 )
681 else
682 # No more pages left, just return whatever we already have
683 acc ++ collection["orderedItems"]
684 end
685 else
686 # Got the amount of pages needed, add them all to the accumulator
687 acc ++ collection["orderedItems"]
688 end
689
690 _ ->
691 {:error, "Not an OrderedCollection or OrderedCollectionPage"}
692 end
693 end
694 end
695
696 #### Report-related helpers
697
698 def update_report_state(%Activity{} = activity, state) when state in @supported_report_states do
699 new_data = Map.put(activity.data, "state", state)
700
701 activity
702 |> Changeset.change(data: new_data)
703 |> Repo.update()
704 end
705
706 def update_report_state(_, _), do: {:error, "Unsupported state"}
707
708 def update_activity_visibility(activity, visibility) when visibility in @valid_visibilities do
709 [to, cc, recipients] =
710 activity
711 |> get_updated_targets(visibility)
712 |> Enum.map(&Enum.uniq/1)
713
714 object_data =
715 activity.object.data
716 |> Map.put("to", to)
717 |> Map.put("cc", cc)
718
719 {:ok, object} =
720 activity.object
721 |> Object.change(%{data: object_data})
722 |> Object.update_and_set_cache()
723
724 activity_data =
725 activity.data
726 |> Map.put("to", to)
727 |> Map.put("cc", cc)
728
729 activity
730 |> Map.put(:object, object)
731 |> Activity.change(%{data: activity_data, recipients: recipients})
732 |> Repo.update()
733 end
734
735 def update_activity_visibility(_, _), do: {:error, "Unsupported visibility"}
736
737 defp get_updated_targets(
738 %Activity{data: %{"to" => to} = data, recipients: recipients},
739 visibility
740 ) do
741 cc = Map.get(data, "cc", [])
742 follower_address = User.get_cached_by_ap_id(data["actor"]).follower_address
743 public = Pleroma.Constants.as_public()
744
745 case visibility do
746 "public" ->
747 to = [public | List.delete(to, follower_address)]
748 cc = [follower_address | List.delete(cc, public)]
749 recipients = [public | recipients]
750 [to, cc, recipients]
751
752 "private" ->
753 to = [follower_address | List.delete(to, public)]
754 cc = List.delete(cc, public)
755 recipients = List.delete(recipients, public)
756 [to, cc, recipients]
757
758 "unlisted" ->
759 to = [follower_address | List.delete(to, public)]
760 cc = [public | List.delete(cc, follower_address)]
761 recipients = recipients ++ [follower_address, public]
762 [to, cc, recipients]
763
764 _ ->
765 [to, cc, recipients]
766 end
767 end
768
769 def get_existing_votes(actor, %{data: %{"id" => id}}) do
770 actor
771 |> Activity.Queries.by_actor()
772 |> Activity.Queries.by_type("Create")
773 |> Activity.with_preloaded_object()
774 |> where([a, object: o], fragment("(?)->>'inReplyTo' = ?", o.data, ^to_string(id)))
775 |> where([a, object: o], fragment("(?)->>'type' = 'Answer'", o.data))
776 |> Repo.all()
777 end
778
779 defp maybe_put(map, _key, nil), do: map
780 defp maybe_put(map, key, value), do: Map.put(map, key, value)
781 end