etwin_services 0.10.2

Top-level Eternal-Twin services
Documentation
use etwin_core::auth::AuthContext;
use etwin_core::clock::Clock;
use etwin_core::core::{Instant, Listing};
use etwin_core::forum::{
  AddModeratorOptions, CreatePostOptions, CreateThreadOptions, DeleteModeratorOptions, DeletePostError,
  DeletePostOptions, ForumActor, ForumPost, ForumPostIdRef, ForumPostListing, ForumPostRevision,
  ForumPostRevisionContent, ForumPostRevisionListing, ForumRole, ForumRoleGrant, ForumSection, ForumSectionListing,
  ForumSectionMeta, ForumSectionSelf, ForumStore, ForumThread, ForumThreadMeta, ForumThreadMetaWithSection,
  GetForumSectionMetaOptions, GetForumSectionOptions, GetSectionMetaError, GetThreadOptions,
  LatestForumPostRevisionListing, MarktwinText, RawAddModeratorOptions, RawCreatePostOptions,
  RawCreatePostRevisionOptions, RawCreateThreadsOptions, RawDeleteModeratorOptions, RawForumActor, RawForumPost,
  RawForumSectionMeta, RawForumThreadMeta, RawGetForumPostOptions, RawGetForumThreadMetaOptions, RawGetPostsOptions,
  RawGetRoleGrantsOptions, RawGetSectionsOptions, RawGetThreadsOptions, ShortForumPost, UpdatePostOptions,
  UpsertSystemSectionError, UpsertSystemSectionOptions, UserForumActor,
};
pub use etwin_core::forum::{CreatePostError, UpdatePostError};
use etwin_core::types::AnyError;
use etwin_core::user::{GetShortUserOptions, ShortUser, UserId, UserIdRef, UserStore};
use marktwin::emitter::emit_html;
use marktwin::grammar::Grammar;
use serde::{Deserialize, Serialize};
use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet};
use std::convert::TryFrom;
use std::future::Future;
use std::sync::Arc;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum AddModeratorError {
  #[error("section not found")]
  SectionNotFound,
  #[error("target grantee user not found")]
  TargetUserNotFound,
  #[error("current actor does not have the permission to add moderators to this section")]
  Forbidden,
  #[error(transparent)]
  Other(AnyError),
}

#[derive(Error, Debug)]
pub enum DeleteModeratorError {
  #[error("section not found")]
  SectionNotFound,
  #[error("target grantee user not found")]
  TargetUserNotFound,
  #[error("current actor does not have the permission to remove moderator")]
  Forbidden,
  #[error(transparent)]
  Other(AnyError),
}

#[derive(Error, Debug)]
pub enum CreateThreadError {
  #[error("section not found")]
  SectionNotFound,
  #[error("current actor does not have the permission to create a thread in this section")]
  Forbidden,
  #[error("failed to parse provided body")]
  FailedToParseBody,
  #[error("failed to render provided body")]
  FailedToRenderBody,
  #[error(transparent)]
  Other(AnyError),
}

#[derive(Error, Debug)]
pub enum GetSectionError {
  #[error("section not found")]
  SectionNotFound,
  #[error("internal error: {0}")]
  Internal(&'static str, AnyError),
}

#[derive(Error, Debug)]
pub enum GetSectionsError {
  #[error(transparent)]
  Other(AnyError),
}

#[derive(Error, Debug)]
pub enum GetThreadError {
  #[error("thread not found")]
  ThreadNotFound,
  #[error(transparent)]
  Other(AnyError),
}

#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct GetForumPostOptions {
  pub post: ForumPostIdRef,
  pub revision_offset: u32,
  pub revision_limit: u32,
  pub time: Option<Instant>,
}

#[derive(Error, Debug)]
pub enum GetPostError {
  #[error("post not found")]
  PostNotFound,
  #[error(transparent)]
  Other(AnyError),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct ForumConfig {
  pub threads_per_page: u32,
  pub posts_per_page: u32,
}

pub struct ForumService<TyClock, TyForumStore, TyUserStore>
where
  TyClock: Clock,
  TyForumStore: ForumStore,
  TyUserStore: UserStore,
{
  #[allow(unused)]
  clock: TyClock,
  forum_store: TyForumStore,
  user_store: TyUserStore,
}

pub type DynForumService = ForumService<Arc<dyn Clock>, Arc<dyn ForumStore>, Arc<dyn UserStore>>;

impl<TyClock, TyForumStore, TyUserStore> ForumService<TyClock, TyForumStore, TyUserStore>
where
  TyClock: Clock,
  TyForumStore: ForumStore,
  TyUserStore: UserStore,
{
  #[allow(clippy::too_many_arguments)]
  pub fn new(clock: TyClock, forum_store: TyForumStore, user_store: TyUserStore) -> Self {
    Self {
      clock,
      forum_store,
      user_store,
    }
  }

  pub fn config(&self) -> ForumConfig {
    ForumConfig {
      threads_per_page: 20,
      posts_per_page: 10,
    }
  }

  pub async fn add_moderator(
    &self,
    acx: &AuthContext,
    options: &AddModeratorOptions,
  ) -> Result<ForumSection, AddModeratorError> {
    let granter = match acx {
      AuthContext::User(acx) if acx.is_administrator => &acx.user,
      _ => return Err(AddModeratorError::Forbidden),
    };

    let grantee = self
      .user_store
      .get_short_user(&options.user.clone().into())
      .await
      .map_err(AddModeratorError::Other)?;
    let grantee: ShortUser = grantee.ok_or(AddModeratorError::TargetUserNotFound)?;

    self
      .forum_store
      .add_moderator(&RawAddModeratorOptions {
        section: options.section.clone(),
        target: grantee.as_ref(),
        granter: granter.as_ref(),
      })
      .await
      .map_err(AddModeratorError::Other)?;
    let section = self
      .get_section(
        acx,
        &GetForumSectionOptions {
          section: options.section.clone(),
          thread_offset: 0,
          thread_limit: 10,
        },
      )
      .await
      .map_err(|e| AddModeratorError::Other(Box::new(e)))?;
    Ok(section)
  }

  pub async fn delete_moderator(
    &self,
    acx: &AuthContext,
    options: &DeleteModeratorOptions,
  ) -> Result<ForumSection, DeleteModeratorError> {
    let (actor, is_admin) = match acx {
      AuthContext::User(acx) => (&acx.user, acx.is_administrator),
      _ => return Err(DeleteModeratorError::Forbidden),
    };

    let target = self
      .user_store
      .get_short_user(&options.user.clone().into())
      .await
      .map_err(DeleteModeratorError::Other)?;
    let target: ShortUser = match target {
      Some(target) => {
        if is_admin || (actor.id == target.id) {
          target
        } else {
          return Err(DeleteModeratorError::Forbidden);
        }
      }
      None => {
        return Err(if is_admin {
          DeleteModeratorError::TargetUserNotFound
        } else {
          DeleteModeratorError::Forbidden
        });
      }
    };

    self
      .forum_store
      .delete_moderator(&RawDeleteModeratorOptions {
        section: options.section.clone(),
        target: target.as_ref(),
        revoker: actor.as_ref(),
      })
      .await
      .map_err(DeleteModeratorError::Other)?;
    let section = self
      .get_section(
        acx,
        &GetForumSectionOptions {
          section: options.section.clone(),
          thread_offset: 0,
          thread_limit: 10,
        },
      )
      .await
      .map_err(|e| DeleteModeratorError::Other(Box::new(e)))?;
    Ok(section)
  }

  // Manual async fn impl to enforce the `Send` trait explicitly
  #[allow(clippy::manual_async_fn)]
  pub fn create_thread<'a>(
    &'a self,
    acx: &'a AuthContext,
    options: &'a CreateThreadOptions,
  ) -> impl Future<Output = Result<ForumThread, CreateThreadError>> + Send + 'a {
    async move {
      let actor: ForumActor = match acx {
        AuthContext::User(user) => ForumActor::UserForumActor(UserForumActor {
          role: None,
          user: user.user.clone(),
        }),
        AuthContext::Guest(_) => return Err(CreateThreadError::Forbidden),
        _ => {
          return Err(CreateThreadError::Other(
            String::from("not implemented: non-user AuthContext").into(),
          ))
        }
      };
      let grammar = Grammar {
        admin: false,
        depth: Some(4),
        emphasis: true,
        icons: HashSet::new(),
        links: {
          let mut links = HashSet::new();
          links.insert(String::from("http"));
          links.insert(String::from("https"));
          links
        },
        r#mod: false,
        quote: false,
        spoiler: false,
        strong: true,
        strikethrough: true,
      };
      let body = {
        let body = marktwin::parser::parse(&grammar, options.body.as_str());
        let body =
          marktwin::ast::concrete::Root::try_from(body.syntax()).map_err(|()| CreateThreadError::FailedToParseBody)?;
        let mut bytes: Vec<u8> = Vec::new();
        emit_html(&mut bytes, &body).map_err(|_| CreateThreadError::FailedToRenderBody)?;
        String::from_utf8(bytes).map_err(|_| CreateThreadError::FailedToRenderBody)?
      };
      let thread = self
        .forum_store
        .create_thread(&RawCreateThreadsOptions {
          actor: actor.to_raw(),
          section: options.section.clone(),
          title: options.title.clone(),
          body_mkt: options.body.clone(),
          body_html: body,
        })
        .await
        .map_err(|e| CreateThreadError::Other(format!("internal create_thread errpr: {}", e).into()))?;

      let section = self
        .forum_store
        .get_section_meta(&GetForumSectionMetaOptions {
          section: thread.section.into(),
        })
        .await
        .map_err(|e| match e {
          GetSectionMetaError::NotFound => CreateThreadError::SectionNotFound,
          GetSectionMetaError::Other(e) => CreateThreadError::Other(e),
        })?;

      // TODO: Assert the author matches the expected actor
      let post_revision = ForumPostRevision {
        id: thread.post_revision.id,
        time: thread.post_revision.time,
        author: actor.clone(),
        content: thread.post_revision.content,
        moderation: thread.post_revision.moderation,
        comment: thread.post_revision.comment,
      };
      // TODO: Assert the author
      let post = ShortForumPost {
        id: thread.post_id,
        ctime: thread.ctime,
        author: actor.clone(),
        revisions: LatestForumPostRevisionListing {
          count: 1,
          last: post_revision,
        },
      };

      Ok(ForumThread {
        id: thread.id,
        key: thread.key,
        title: thread.title,
        ctime: thread.ctime,
        section: ForumSectionMeta {
          id: section.id,
          key: section.key,
          display_name: section.display_name,
          ctime: section.ctime,
          locale: section.locale,
          threads: section.threads,
          this: match acx {
            AuthContext::User(acx) => {
              let mut roles = Vec::new();
              if acx.is_administrator {
                roles.push(ForumRole::Administrator);
              }
              if section
                .role_grants
                .iter()
                .any(|grant| grant.role == ForumRole::Moderator && grant.user.id == acx.user.id)
              {
                roles.push(ForumRole::Moderator);
              }
              ForumSectionSelf { roles }
            }
            _ => ForumSectionSelf { roles: vec![] },
          },
        },
        posts: Listing {
          offset: 0,
          limit: 10,
          count: 1,
          items: vec![post],
        },
        is_pinned: false,
        is_locked: false,
      })
    }
  }

  // Manual async fn impl to enforce the `Send` trait explicitly
  #[allow(clippy::manual_async_fn)]
  pub fn create_post<'a>(
    &'a self,
    acx: &'a AuthContext,
    options: &'a CreatePostOptions,
  ) -> impl Future<Output = Result<ForumPost, CreatePostError>> + Send + 'a {
    async move {
      let actor: ForumActor = match acx {
        AuthContext::User(user) => ForumActor::UserForumActor(UserForumActor {
          role: None,
          user: user.user.clone(),
        }),
        AuthContext::Guest(_) => return Err(CreatePostError::Forbidden),
        _ => todo!(),
      };
      let grammar = Grammar {
        admin: false,
        depth: Some(4),
        emphasis: true,
        icons: HashSet::new(),
        links: {
          let mut links = HashSet::new();
          links.insert(String::from("http"));
          links.insert(String::from("https"));
          links
        },
        r#mod: false,
        quote: false,
        spoiler: false,
        strong: true,
        strikethrough: true,
      };
      let body = {
        let body = marktwin::parser::parse(&grammar, options.body.as_str());
        let body =
          marktwin::ast::concrete::Root::try_from(body.syntax()).map_err(|()| CreatePostError::FailedToParseBody)?;
        let mut bytes: Vec<u8> = Vec::new();
        emit_html(&mut bytes, &body).map_err(|_| CreatePostError::FailedToRenderBody)?;
        String::from_utf8(bytes).map_err(|_| CreatePostError::FailedToRenderBody)?
      };
      let post = self
        .forum_store
        .create_post(&RawCreatePostOptions {
          actor: actor.to_raw(),
          thread: options.thread.clone(),
          body: ForumPostRevisionContent {
            marktwin: options.body.clone(),
            html: body,
          },
        })
        .await
        .map_err(CreatePostError::Other)?;

      let thread: RawForumThreadMeta = self
        .forum_store
        .get_thread_meta(&RawGetForumThreadMetaOptions {
          thread: post.thread.into(),
        })
        .await
        .map_err(|e| CreatePostError::Other(Box::new(e)))?;

      let section: RawForumSectionMeta = self
        .forum_store
        .get_section_meta(&GetForumSectionMetaOptions {
          section: post.section.into(),
        })
        .await
        .map_err(|e| CreatePostError::Other(Box::new(e)))?;

      let section = ForumSectionMeta {
        id: section.id,
        key: section.key,
        display_name: section.display_name,
        ctime: section.ctime,
        locale: section.locale,
        threads: section.threads,
        this: match acx {
          AuthContext::User(acx) => {
            let mut roles = Vec::new();
            if acx.is_administrator {
              roles.push(ForumRole::Administrator);
            }
            if section
              .role_grants
              .iter()
              .any(|grant| grant.role == ForumRole::Moderator && grant.user.id == acx.user.id)
            {
              roles.push(ForumRole::Moderator);
            }
            ForumSectionSelf { roles }
          }
          _ => ForumSectionSelf { roles: vec![] },
        },
      };

      // TODO: Assert the author matches the expected actor
      let post_revision = ForumPostRevision {
        id: post.revision.id,
        time: post.revision.time,
        // TODO: Assert the author matches `post.revision.author`
        author: actor.clone(),
        content: post.revision.content,
        moderation: post.revision.moderation,
        comment: post.revision.comment,
      };

      Ok(ForumPost {
        id: post.id,
        ctime: post.revision.time,
        // TODO: Assert the author matches `post.revision.author`
        author: actor.clone(),
        revisions: ForumPostRevisionListing {
          offset: 0,
          limit: 100,
          count: 1,
          items: vec![post_revision],
        },
        thread: ForumThreadMetaWithSection {
          id: thread.id,
          key: thread.key,
          title: thread.title,
          ctime: thread.ctime,
          is_pinned: thread.is_pinned,
          is_locked: thread.is_locked,
          posts: thread.posts,
          section,
        },
      })
    }
  }

  pub async fn update_post(
    &self,
    acx: &AuthContext,
    options: &UpdatePostOptions,
  ) -> Result<ForumPost, UpdatePostError> {
    let user: UserIdRef;
    let is_admin: bool;
    let actor: ForumActor = match acx {
      AuthContext::User(u) => {
        is_admin = u.is_administrator;
        user = u.user.id.into();
        ForumActor::UserForumActor(UserForumActor {
          role: None,
          user: u.user.clone(),
        })
      }
      AuthContext::Guest(_) => return Err(UpdatePostError::Forbidden),
      _ => todo!(),
    };
    let post = self
      .forum_store
      .get_post(&RawGetForumPostOptions {
        post: options.post.into(),
        offset: 0,
        limit: 100,
      })
      .await
      .map_err(UpdatePostError::Other)?;
    let latest_revision = post.revisions.items.last().unwrap();
    let section = post.thread.section;
    let grants = self
      .forum_store
      .get_role_grants(&RawGetRoleGrantsOptions {
        section,
        user: Some(user),
      })
      .await
      .map_err(UpdatePostError::Other)?;
    let is_moderator = is_admin || grants.iter().any(|rg| rg.role == ForumRole::Moderator);
    let grammar = Grammar {
      admin: false,
      depth: Some(4),
      emphasis: true,
      icons: HashSet::new(),
      links: {
        let mut links = HashSet::new();
        links.insert(String::from("http"));
        links.insert(String::from("https"));
        links
      },
      r#mod: false,
      quote: false,
      spoiler: false,
      strong: true,
      strikethrough: true,
    };
    let old_body = latest_revision.content.as_ref().map(|c| &c.marktwin);
    let new_body: Option<MarktwinText> = match options.content.as_ref() {
      Some(new_body) if new_body.as_ref() != old_body => {
        // Actual change request, check permissions
        match new_body.as_ref() {
          None => {
            if !is_moderator {
              // Only moderators can delete body
              return Err(UpdatePostError::Forbidden);
            }
          }
          Some(_) => {
            // Disable content updates for the moment
            return Err(UpdatePostError::Forbidden);
          }
        }
        new_body.clone()
      }
      _ => {
        // No change, or same as old content
        old_body.cloned()
      }
    };
    let old_mod_body = latest_revision.moderation.as_ref().map(|c| &c.marktwin);
    let new_mod_body: Option<MarktwinText> = match options.moderation.as_ref() {
      Some(new_mod_body) if new_mod_body.as_ref() != old_mod_body => {
        // Actual change request, check permissions
        if !is_moderator {
          // Only moderators can delete body
          return Err(UpdatePostError::Forbidden);
        }
        new_mod_body.clone()
      }
      _ => {
        // No change, or same as old content
        old_mod_body.cloned()
      }
    };
    let new_revision_res = self
      .forum_store
      .create_post_revision(&RawCreatePostRevisionOptions {
        actor: actor.to_raw(),
        post: post.id.into(),
        body: match new_body {
          None => None,
          Some(marktwin) => {
            let body = marktwin::parser::parse(&grammar, marktwin.as_str());
            let body = marktwin::ast::concrete::Root::try_from(body.syntax())
              .map_err(|()| UpdatePostError::FailedToParseBody)?;
            let html = {
              let mut bytes: Vec<u8> = Vec::new();
              emit_html(&mut bytes, &body).map_err(|_| UpdatePostError::FailedToRenderBody)?;
              String::from_utf8(bytes).map_err(|_| UpdatePostError::FailedToRenderBody)?
            };
            Some(ForumPostRevisionContent { marktwin, html })
          }
        },
        mod_body: match new_mod_body {
          None => None,
          Some(marktwin) => {
            let body = marktwin::parser::parse(&grammar, marktwin.as_str());
            let body = marktwin::ast::concrete::Root::try_from(body.syntax())
              .map_err(|()| UpdatePostError::FailedToParseBody)?;
            let html = {
              let mut bytes: Vec<u8> = Vec::new();
              emit_html(&mut bytes, &body).map_err(|_| UpdatePostError::FailedToRenderBody)?;
              String::from_utf8(bytes).map_err(|_| UpdatePostError::FailedToRenderBody)?
            };
            Some(ForumPostRevisionContent { marktwin, html })
          }
        },
        comment: options.comment.clone(),
      })
      .await
      .map_err(UpdatePostError::Other)?;

    let section: RawForumSectionMeta = self
      .forum_store
      .get_section_meta(&GetForumSectionMetaOptions {
        section: new_revision_res.section.into(),
      })
      .await
      .map_err(|e| UpdatePostError::Other(Box::new(e)))?;

    let section = ForumSectionMeta {
      id: section.id,
      key: section.key,
      display_name: section.display_name,
      ctime: section.ctime,
      locale: section.locale,
      threads: section.threads,
      this: match acx {
        AuthContext::User(acx) => {
          let mut roles = Vec::new();
          if acx.is_administrator {
            roles.push(ForumRole::Administrator);
          }
          if section
            .role_grants
            .iter()
            .any(|grant| grant.role == ForumRole::Moderator && grant.user.id == acx.user.id)
          {
            roles.push(ForumRole::Moderator);
          }
          ForumSectionSelf { roles }
        }
        _ => ForumSectionSelf { roles: vec![] },
      },
    };

    let time = new_revision_res.revision.time;
    let mut revisions = post.revisions;
    revisions.items.push(new_revision_res.revision);
    revisions.count += 1;
    assert!(u32::try_from(revisions.items.len()).unwrap() <= revisions.limit);

    let mut actor_cache: HashMap<UserId, ForumActor> = HashMap::new();

    let revisions = ForumPostRevisionListing {
      offset: revisions.offset,
      limit: revisions.limit,
      count: revisions.count,
      items: {
        let mut items = Vec::new();
        for item in revisions.items.into_iter() {
          items.push(ForumPostRevision {
            id: item.id,
            time: item.time,
            author: {
              match item.author {
                RawForumActor::UserForumActor(a) => {
                  let e = actor_cache.entry(a.user.id);
                  let actor = match e {
                    Entry::Vacant(e) => {
                      let actor = ForumActor::UserForumActor(UserForumActor {
                        role: None,
                        user: self
                          .user_store
                          .get_short_user(&GetShortUserOptions {
                            r#ref: a.user.id.into(),
                            time: Some(time),
                          })
                          .await
                          .map_err(UpdatePostError::Other)?
                          .unwrap(),
                      });
                      e.insert(actor)
                    }
                    Entry::Occupied(e) => e.into_mut(),
                  };
                  actor.clone()
                }
                _ => todo!(),
              }
            },
            content: item.content,
            moderation: item.moderation,
            comment: item.comment,
          })
        }
        items
      },
    };

    Ok(ForumPost {
      id: post.id,
      ctime: post.ctime,
      author: {
        assert_eq!(revisions.offset, 0);
        revisions.items[0].author.clone()
      },
      revisions,
      thread: ForumThreadMetaWithSection {
        id: post.thread.id,
        key: post.thread.key,
        title: post.thread.title,
        ctime: post.thread.ctime,
        is_pinned: post.thread.is_pinned,
        is_locked: post.thread.is_locked,
        posts: post.thread.posts,
        section,
      },
    })
  }

  pub async fn delete_post(
    &self,
    acx: &AuthContext,
    options: &DeletePostOptions,
  ) -> Result<ForumPost, DeletePostError> {
    let update_res = self
      .update_post(
        acx,
        &UpdatePostOptions {
          post: options.post,
          revision: options.revision,
          content: Some(None),
          moderation: Some(None),
          comment: options.comment.clone(),
        },
      )
      .await;
    match update_res {
      Ok(post) => Ok(post),
      // TODO: Proper error mapping
      Err(e) => Err(DeletePostError::Other(e.into())),
    }
  }

  pub async fn get_sections(&self, acx: &AuthContext) -> Result<ForumSectionListing, GetSectionsError> {
    let sections: Listing<RawForumSectionMeta> = self
      .forum_store
      .get_sections(&RawGetSectionsOptions { offset: 0, limit: 20 })
      .await
      .map_err(GetSectionsError::Other)?;
    let mut items: Vec<ForumSectionMeta> = Vec::new();
    for section in sections.items.into_iter() {
      let forum_self = match acx {
        AuthContext::User(acx) => {
          let mut roles = Vec::new();
          if acx.is_administrator {
            roles.push(ForumRole::Administrator);
          }
          if section
            .role_grants
            .iter()
            .any(|grant| grant.role == ForumRole::Moderator && grant.user.id == acx.user.id)
          {
            roles.push(ForumRole::Moderator);
          }
          ForumSectionSelf { roles }
        }
        _ => ForumSectionSelf { roles: vec![] },
      };
      let section = ForumSectionMeta {
        id: section.id,
        key: section.key,
        display_name: section.display_name,
        ctime: section.ctime,
        locale: section.locale,
        threads: section.threads,
        this: forum_self,
      };
      items.push(section);
    }
    Ok(Listing {
      offset: sections.offset,
      limit: sections.limit,
      count: sections.count,
      items,
    })
  }

  pub async fn upsert_system_section(
    &self,
    options: &UpsertSystemSectionOptions,
  ) -> Result<ForumSection, UpsertSystemSectionError> {
    self.forum_store.upsert_system_section(options).await
  }

  pub async fn get_section(
    &self,
    acx: &AuthContext,
    options: &GetForumSectionOptions,
  ) -> Result<ForumSection, GetSectionError> {
    let section_meta: RawForumSectionMeta = self
      .forum_store
      .get_section_meta(&GetForumSectionMetaOptions {
        section: options.section.clone(),
      })
      .await
      .map_err(|e| match e {
        GetSectionMetaError::NotFound => GetSectionError::SectionNotFound,
        GetSectionMetaError::Other(e) => GetSectionError::Internal("get_section_meta", e),
      })?;
    let threads = self
      .forum_store
      .get_threads(&RawGetThreadsOptions {
        section: section_meta.as_ref().into(),
        offset: options.thread_offset,
        limit: options.thread_limit,
      })
      .await
      .map_err(|e| GetSectionError::Internal("get_threads", e))?;

    let threads = Listing {
      offset: threads.offset,
      limit: threads.limit,
      count: threads.count,
      items: threads
        .items
        .into_iter()
        .map(|thread| ForumThreadMeta {
          id: thread.id,
          key: thread.key,
          title: thread.title,
          ctime: thread.ctime,
          is_pinned: thread.is_pinned,
          is_locked: thread.is_locked,
          posts: thread.posts,
        })
        .collect(),
    };

    let forum_self = match acx {
      AuthContext::User(acx) => {
        let mut roles = Vec::new();
        if acx.is_administrator {
          roles.push(ForumRole::Administrator);
        }
        if section_meta
          .role_grants
          .iter()
          .any(|grant| grant.role == ForumRole::Moderator && grant.user.id == acx.user.id)
        {
          roles.push(ForumRole::Moderator);
        }
        ForumSectionSelf { roles }
      }
      _ => ForumSectionSelf { roles: vec![] },
    };

    let mut role_grants: Vec<ForumRoleGrant> = Vec::new();
    for grant in section_meta.role_grants.into_iter() {
      let grantee = self
        .user_store
        .get_short_user(&grant.user.into())
        .await
        .map_err(|e| GetSectionError::Internal("get_short_user (grantee)", e))?
        .expect("failed to resolve grantee");
      let granter = self
        .user_store
        .get_short_user(&grant.granted_by.into())
        .await
        .map_err(|e| GetSectionError::Internal("get_short_user (granter)", e))?
        .expect("failed to resolve grantee");
      role_grants.push(ForumRoleGrant {
        role: grant.role,
        user: grantee,
        start_time: grant.start_time,
        granted_by: granter,
      });
    }

    Ok(ForumSection {
      id: section_meta.id,
      key: section_meta.key,
      display_name: section_meta.display_name,
      ctime: section_meta.ctime,
      locale: section_meta.locale,
      threads,
      role_grants,
      this: forum_self,
    })
  }

  pub async fn get_thread(&self, acx: &AuthContext, options: &GetThreadOptions) -> Result<ForumThread, GetThreadError> {
    let time = self.clock.now();

    let thread_meta: RawForumThreadMeta = self
      .forum_store
      .get_thread_meta(&RawGetForumThreadMetaOptions {
        thread: options.thread.clone(),
      })
      .await
      .map_err(|e| GetThreadError::Other(Box::new(e)))?;
    let posts = self
      .forum_store
      .get_posts(&RawGetPostsOptions {
        thread: thread_meta.as_ref().into(),
        offset: options.post_offset,
        limit: options.post_limit,
      })
      .await
      .map_err(GetThreadError::Other)?;
    let section_meta: RawForumSectionMeta = self
      .forum_store
      .get_section_meta(&GetForumSectionMetaOptions {
        section: thread_meta.section.into(),
      })
      .await
      .map_err(|e| GetThreadError::Other(Box::new(e)))?;

    let forum_self = match acx {
      AuthContext::User(acx) => {
        let mut roles = Vec::new();
        if acx.is_administrator {
          roles.push(ForumRole::Administrator);
        }
        if section_meta
          .role_grants
          .iter()
          .any(|grant| grant.role == ForumRole::Moderator && grant.user.id == acx.user.id)
        {
          roles.push(ForumRole::Moderator);
        }
        ForumSectionSelf { roles }
      }
      _ => ForumSectionSelf { roles: vec![] },
    };

    let mut role_grants: Vec<ForumRoleGrant> = Vec::new();
    for grant in section_meta.role_grants.into_iter() {
      let grantee = self
        .user_store
        .get_short_user(&grant.user.into())
        .await
        .map_err(GetThreadError::Other)?
        .expect("failed to resolve grantee");
      let granter = self
        .user_store
        .get_short_user(&grant.granted_by.into())
        .await
        .map_err(GetThreadError::Other)?
        .expect("failed to resolve grantee");
      role_grants.push(ForumRoleGrant {
        role: grant.role,
        user: grantee,
        start_time: grant.start_time,
        granted_by: granter,
      });
    }

    let posts = ForumPostListing {
      offset: posts.offset,
      limit: posts.limit,
      count: posts.count,
      items: {
        let mut items: Vec<ShortForumPost> = Vec::new();
        for item in posts.items {
          let last_revision = item.revisions.last;
          let first_author = self
            .user_store
            .get_short_user(
              &(match item.author {
                RawForumActor::UserForumActor(a) => GetShortUserOptions {
                  r#ref: a.user.id.into(),
                  time: Some(time),
                },
                _ => todo!(),
              }),
            )
            .await
            .map_err(GetThreadError::Other)?
            .unwrap();
          let last_author = self
            .user_store
            .get_short_user(
              &(match last_revision.author {
                RawForumActor::UserForumActor(a) => GetShortUserOptions {
                  r#ref: a.user.id.into(),
                  time: Some(time),
                },
                _ => todo!(),
              }),
            )
            .await
            .map_err(GetThreadError::Other)?
            .unwrap();
          items.push(ShortForumPost {
            id: item.id,
            ctime: item.ctime,
            author: ForumActor::UserForumActor(UserForumActor {
              role: None,
              user: first_author,
            }),
            revisions: LatestForumPostRevisionListing {
              count: item.revisions.count,
              last: ForumPostRevision {
                id: last_revision.id,
                time: last_revision.time,
                author: ForumActor::UserForumActor(UserForumActor {
                  role: None,
                  user: last_author,
                }),
                content: last_revision.content,
                moderation: last_revision.moderation,
                comment: last_revision.comment,
              },
            },
          });
        }
        items
      },
    };

    Ok(ForumThread {
      id: thread_meta.id,
      key: thread_meta.key,
      title: thread_meta.title,
      ctime: thread_meta.ctime,
      posts,
      is_pinned: thread_meta.is_pinned,
      is_locked: thread_meta.is_locked,
      section: ForumSectionMeta {
        id: section_meta.id,
        key: section_meta.key,
        display_name: section_meta.display_name,
        ctime: section_meta.ctime,
        locale: section_meta.locale,
        threads: section_meta.threads,
        this: forum_self,
      },
    })
  }

  pub async fn get_post(&self, acx: &AuthContext, options: &GetForumPostOptions) -> Result<ForumPost, GetPostError> {
    let time = options.time.unwrap_or_else(|| self.clock.now());

    let post: RawForumPost = self
      .forum_store
      .get_post(&RawGetForumPostOptions {
        post: options.post,
        offset: options.revision_offset,
        limit: options.revision_limit,
      })
      .await
      .map_err(|e| GetPostError::Other(e))?;

    let author = match post.author {
      RawForumActor::UserForumActor(a) => ForumActor::UserForumActor(UserForumActor {
        role: a.role,
        user: self
          .user_store
          .get_short_user(&GetShortUserOptions {
            r#ref: a.user.id.into(),
            time: Some(time),
          })
          .await
          .map_err(GetPostError::Other)?
          .ok_or_else(|| GetPostError::Other(String::from("AssertionError: Expected post author to exist").into()))?,
      }),
      _ => todo!(),
    };

    let thread = post.thread;
    let section = self
      .forum_store
      .get_section_meta(&GetForumSectionMetaOptions {
        section: thread.section.into(),
      })
      .await
      .map_err(|e| GetPostError::Other(Box::new(e)))?;

    let forum_self = match acx {
      AuthContext::User(acx) => {
        let mut roles = Vec::new();
        if acx.is_administrator {
          roles.push(ForumRole::Administrator);
        }
        if section
          .role_grants
          .iter()
          .any(|grant| grant.role == ForumRole::Moderator && grant.user.id == acx.user.id)
        {
          roles.push(ForumRole::Moderator);
        }
        ForumSectionSelf { roles }
      }
      _ => ForumSectionSelf { roles: vec![] },
    };

    let revisions = post.revisions;

    let revisions = ForumPostRevisionListing {
      offset: revisions.offset,
      limit: revisions.limit,
      count: revisions.count,
      items: {
        let mut items: Vec<ForumPostRevision> = Vec::new();
        for item in revisions.items {
          let author = match item.author {
            RawForumActor::UserForumActor(a) => ForumActor::UserForumActor(UserForumActor {
              role: a.role,
              user: self
                .user_store
                .get_short_user(&GetShortUserOptions {
                  r#ref: a.user.id.into(),
                  time: Some(time),
                })
                .await
                .map_err(GetPostError::Other)?
                .ok_or_else(|| {
                  GetPostError::Other(String::from("AssertionError: Expected revision author to exist").into())
                })?,
            }),
            _ => todo!(),
          };
          items.push(ForumPostRevision {
            id: item.id,
            time: item.time,
            author,
            content: item.content,
            moderation: item.moderation,
            comment: item.comment,
          });
        }
        items
      },
    };

    Ok(ForumPost {
      id: post.id,
      ctime: post.ctime,
      author,
      revisions,
      thread: ForumThreadMetaWithSection {
        id: thread.id,
        key: thread.key,
        title: thread.title,
        ctime: thread.ctime,
        is_pinned: thread.is_pinned,
        is_locked: thread.is_locked,
        posts: thread.posts,
        section: ForumSectionMeta {
          id: section.id,
          key: section.key,
          display_name: section.display_name,
          ctime: section.ctime,
          locale: section.locale,
          threads: section.threads,
          this: forum_self,
        },
      },
    })
  }
}

#[cfg(feature = "neon")]
impl<TyClock, TyForumStore, TyUserStore> neon::prelude::Finalize for ForumService<TyClock, TyForumStore, TyUserStore>
where
  TyClock: Clock,
  TyForumStore: ForumStore,
  TyUserStore: UserStore,
{
}