packages feed

shomei-postgres-0.2.0.0: src/Shomei/Account/Verification/Postgres.hs

-- | PostgreSQL interpreter for the email-verification token store.
module Shomei.Account.Verification.Postgres
  ( runVerificationTokenStorePostgres,
  )
where

import Contravariant.Extras (contrazip2, contrazip8)
import Data.UUID (UUID)
import Effectful (Eff, IOE, (:>))
import Effectful.Dispatch.Dynamic (interpret_)
import Effectful.Error.Static (Error, throwError)
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Session qualified as Session
import Hasql.Statement (Statement, preparable)
import Shomei.Account.OneTimeToken.Domain (OneTimeTokenHash (..), OneTimeTokenStatus (..))
import Shomei.Account.Verification.Domain (NewVerificationToken (..), PersistedVerificationToken (..))
import Shomei.Account.Verification.Store (VerificationTokenStore (..))
import Shomei.Error (AuthError (..))
import Shomei.Id
  ( VerificationTokenId,
    genVerificationTokenId,
    userIdFromUUID,
    userIdToUUID,
    verificationTokenIdFromUUID,
    verificationTokenIdToUUID,
  )
import Shomei.Persistence.Codec.Postgres (oneTimeTokenStatusFromText, oneTimeTokenStatusToText)
import Shomei.Persistence.Database.Postgres (Database, postgresUnavailable, runSession)
import Shomei.Prelude

type TokenRow = (UUID, UUID, Text, Text, UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime)

runVerificationTokenStorePostgres ::
  (Database :> es, IOE :> es, Error AuthError :> es) =>
  Eff (VerificationTokenStore : es) a ->
  Eff es a
runVerificationTokenStorePostgres = interpret_ \case
  CreateVerificationToken nvt -> do
    tid <- genVerificationTokenId
    let persisted = mkPersisted tid nvt
        row =
          ( verificationTokenIdToUUID tid,
            userIdToUUID nvt.userId,
            tokenHashText nvt.tokenHash,
            oneTimeTokenStatusToText OneTimeTokenActive,
            nvt.createdAt,
            nvt.expiresAt,
            Nothing,
            Nothing
          )
    res <- runSession (Session.statement row insertTokenStmt)
    either dbFail (const (pure persisted)) res
  FindVerificationTokenByHash h -> do
    res <- runSession (Session.statement (tokenHashText h) findByHashStmt)
    row <- either dbFail pure res
    traverse rebuild row
  MarkVerificationTokenConsumed tid t -> do
    res <- runSession (Session.statement (verificationTokenIdToUUID tid, t) markConsumedStmt)
    either dbFail (pure . isJust) res
  RevokeUserVerificationTokens uid t -> do
    res <- runSession (Session.statement (userIdToUUID uid, t) revokeUserTokensStmt)
    either dbFail (const (pure ())) res
  where
    dbFail = throwError . postgresUnavailable
    rebuild r = either (throwError . InternalAuthError) pure (rebuildToken r)

tokenHashText :: OneTimeTokenHash -> Text
tokenHashText (OneTimeTokenHash t) = t

mkPersisted :: VerificationTokenId -> NewVerificationToken -> PersistedVerificationToken
mkPersisted tid nvt =
  PersistedVerificationToken
    { verificationTokenId = tid,
      userId = nvt.userId,
      tokenHash = nvt.tokenHash,
      status = OneTimeTokenActive,
      createdAt = nvt.createdAt,
      expiresAt = nvt.expiresAt,
      consumedAt = Nothing,
      revokedAt = Nothing
    }

rebuildToken :: TokenRow -> Either Text PersistedVerificationToken
rebuildToken (tid, uid, h, st, c, e, consumed, revoked) = do
  status <- oneTimeTokenStatusFromText st
  pure
    PersistedVerificationToken
      { verificationTokenId = verificationTokenIdFromUUID tid,
        userId = userIdFromUUID uid,
        tokenHash = OneTimeTokenHash h,
        status = status,
        createdAt = c,
        expiresAt = e,
        consumedAt = consumed,
        revokedAt = revoked
      }

tokenRowDecoder :: D.Row TokenRow
tokenRowDecoder =
  (,,,,,,,)
    <$> D.column (D.nonNullable D.uuid)
    <*> D.column (D.nonNullable D.uuid)
    <*> D.column (D.nonNullable D.text)
    <*> D.column (D.nonNullable D.text)
    <*> D.column (D.nonNullable D.timestamptz)
    <*> D.column (D.nonNullable D.timestamptz)
    <*> D.column (D.nullable D.timestamptz)
    <*> D.column (D.nullable D.timestamptz)

insertTokenStmt :: Statement TokenRow ()
insertTokenStmt =
  preparable
    """
    INSERT INTO shomei.shomei_email_verification_tokens
      (verification_token_id, user_id, token_hash, status, created_at, expires_at,
       consumed_at, revoked_at)
    VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
    """
    ( contrazip8
        (E.param (E.nonNullable E.uuid))
        (E.param (E.nonNullable E.uuid))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.timestamptz))
        (E.param (E.nonNullable E.timestamptz))
        (E.param (E.nullable E.timestamptz))
        (E.param (E.nullable E.timestamptz))
    )
    D.noResult

findByHashStmt :: Statement Text (Maybe TokenRow)
findByHashStmt =
  preparable
    """
    SELECT verification_token_id, user_id, token_hash, status, created_at, expires_at,
           consumed_at, revoked_at
    FROM shomei.shomei_email_verification_tokens
    WHERE token_hash = $1
    """
    (E.param (E.nonNullable E.text))
    (D.rowMaybe tokenRowDecoder)

-- | Compare-and-swap: the @status = 'active'@ guard and the write are one statement, so two
-- concurrent confirmations of the same one-time token cannot both consume it. The loser
-- matches zero rows and returns no @RETURNING@ row.
markConsumedStmt :: Statement (UUID, UTCTime) (Maybe UUID)
markConsumedStmt =
  preparable
    """
    UPDATE shomei.shomei_email_verification_tokens
    SET status = 'consumed', consumed_at = $2
    WHERE verification_token_id = $1
      AND status = 'active'
    RETURNING verification_token_id
    """
    (contrazip2 (E.param (E.nonNullable E.uuid)) (E.param (E.nonNullable E.timestamptz)))
    (D.rowMaybe (D.column (D.nonNullable D.uuid)))

revokeUserTokensStmt :: Statement (UUID, UTCTime) ()
revokeUserTokensStmt =
  preparable
    """
    UPDATE shomei.shomei_email_verification_tokens
    SET status = 'revoked', revoked_at = $2
    WHERE user_id = $1
      AND status = 'active'
    """
    (contrazip2 (E.param (E.nonNullable E.uuid)) (E.param (E.nonNullable E.timestamptz)))
    D.noResult