shomei-postgres-0.2.0.0: src/Shomei/Account/Credential/Postgres.hs
-- | PostgreSQL interpreter for the 'CredentialStore' port.
module Shomei.Account.Credential.Postgres
( runCredentialStorePostgres,
-- * Statements shared with the unit-of-work interpreter
-- | Exported so @Shomei.Session.UnitOfWork.Postgres@ can lift the store-owned statement
-- into a transaction instead of restating its SQL.
updatePasswordHashStmt,
)
where
import Contravariant.Extras (contrazip2, contrazip7)
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.Credential.Domain (Credential (..))
import Shomei.Account.Credential.Store (CredentialStore (..))
import Shomei.Account.Email.Domain (Email, emailText)
import Shomei.Account.LoginId.Domain (LoginId, loginIdText)
import Shomei.Account.Password.Domain (PasswordHash (..))
import Shomei.Error (AuthError (..))
import Shomei.Id (CredentialId, UserId, credentialIdFromUUID, credentialIdToUUID, genCredentialId, userIdFromUUID, userIdToUUID)
import Shomei.Persistence.Codec.Postgres (loginIdFromDb, maybeEmailFromDb)
import Shomei.Persistence.Database.Postgres (Database, postgresUnavailable, postgresWriteError, runSession)
import Shomei.Prelude
type CredRow = (UUID, UUID, Text, Maybe Text, Text, UTCTime, UTCTime)
runCredentialStorePostgres ::
(Database :> es, IOE :> es, Error AuthError :> es) =>
Eff (CredentialStore : es) a ->
Eff es a
runCredentialStorePostgres = interpret_ \case
CreatePasswordCredential uid loginId mEmail pwHash -> do
cid <- genCredentialId
ts <- liftIO getCurrentTime
let row = (credentialIdToUUID cid, userIdToUUID uid, loginIdText loginId, emailText <$> mEmail, passwordHashText pwHash, ts, ts)
res <- runSession (Session.statement row insertCredentialStmt)
either (throwError . postgresWriteError identityConflict) (const (pure (mkCredential cid uid loginId mEmail pwHash ts))) res
FindPasswordCredentialByLoginId loginId -> do
res <- runSession (Session.statement (loginIdText loginId) findCredByLoginIdStmt)
row <- either dbFail pure res
traverse rebuild row
FindPasswordCredentialByEmail email -> do
res <- runSession (Session.statement (emailText email) findCredByEmailStmt)
row <- either dbFail pure res
traverse rebuild row
UpdatePasswordHash uid pwHash -> do
res <- runSession (Session.statement (userIdToUUID uid, passwordHashText pwHash) updatePasswordHashStmt)
either dbFail (const (pure ())) res
where
dbFail = throwError . postgresUnavailable
rebuild r = either (throwError . InternalAuthError) pure (rebuildCredential r)
identityConflict :: Text -> Maybe AuthError
identityConflict = \case
"shomei_password_credentials_login_id_key" -> Just LoginIdAlreadyRegistered
"shomei_password_credentials_login_id_lower_key" -> Just LoginIdAlreadyRegistered
"shomei_password_credentials_email_key" -> Just EmailAlreadyRegistered
"shomei_password_credentials_email_lower_key" -> Just EmailAlreadyRegistered
_ -> Nothing
passwordHashText :: PasswordHash -> Text
passwordHashText (PasswordHash t) = t
mkCredential :: CredentialId -> UserId -> LoginId -> Maybe Email -> PasswordHash -> UTCTime -> Credential
mkCredential cid uid loginId mEmail pwHash ts =
PasswordCredential
{ credentialId = cid,
userId = uid,
loginId = loginId,
email = mEmail,
passwordHash = pwHash,
createdAt = ts,
updatedAt = ts
}
rebuildCredential :: CredRow -> Either Text Credential
rebuildCredential (cid, uid, lid, e, ph, c, u) = do
loginId <- loginIdFromDb lid
email <- maybeEmailFromDb e
pure
PasswordCredential
{ credentialId = credentialIdFromUUID cid,
userId = userIdFromUUID uid,
loginId = loginId,
email = email,
passwordHash = PasswordHash ph,
createdAt = c,
updatedAt = u
}
credRowDecoder :: D.Row CredRow
credRowDecoder =
(,,,,,,)
<$> D.column (D.nonNullable D.uuid)
<*> D.column (D.nonNullable D.uuid)
<*> D.column (D.nonNullable D.text)
<*> D.column (D.nullable D.text)
<*> D.column (D.nonNullable D.text)
<*> D.column (D.nonNullable D.timestamptz)
<*> D.column (D.nonNullable D.timestamptz)
insertCredentialStmt :: Statement CredRow ()
insertCredentialStmt =
preparable
"""
INSERT INTO shomei.shomei_password_credentials
(credential_id, user_id, login_id, email, password_hash, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"""
( contrazip7
(E.param (E.nonNullable E.uuid))
(E.param (E.nonNullable E.uuid))
(E.param (E.nonNullable E.text))
(E.param (E.nullable E.text))
(E.param (E.nonNullable E.text))
(E.param (E.nonNullable E.timestamptz))
(E.param (E.nonNullable E.timestamptz))
)
D.noResult
findCredByLoginIdStmt :: Statement Text (Maybe CredRow)
findCredByLoginIdStmt =
preparable
"""
SELECT credential_id, user_id, login_id, email, password_hash, created_at, updated_at
FROM shomei.shomei_password_credentials
WHERE login_id = $1
"""
(E.param (E.nonNullable E.text))
(D.rowMaybe credRowDecoder)
findCredByEmailStmt :: Statement Text (Maybe CredRow)
findCredByEmailStmt =
preparable
"""
SELECT credential_id, user_id, login_id, email, password_hash, created_at, updated_at
FROM shomei.shomei_password_credentials
WHERE email = $1
"""
(E.param (E.nonNullable E.text))
(D.rowMaybe credRowDecoder)
updatePasswordHashStmt :: Statement (UUID, Text) ()
updatePasswordHashStmt =
preparable
"""
UPDATE shomei.shomei_password_credentials
SET password_hash = $2
WHERE user_id = $1
"""
(contrazip2 (E.param (E.nonNullable E.uuid)) (E.param (E.nonNullable E.text)))
D.noResult