about summary refs log tree commit diff
path: root/users/wpcarro/assessments/tt/src/Types.hs
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NamedFieldPuns #-}
--------------------------------------------------------------------------------
module Types where
--------------------------------------------------------------------------------
import Data.Aeson
import Utils
import Data.Text
import Data.Typeable
import Database.SQLite.Simple
import Database.SQLite.Simple.Ok
import Database.SQLite.Simple.FromField
import Database.SQLite.Simple.ToField
import GHC.Generics
import Web.Cookie
import Servant.API
import System.Envy (FromEnv, fromEnv, env)
import Crypto.Random.Types (MonadRandom)

import qualified Data.Time.Calendar as Calendar
import qualified Crypto.KDF.BCrypt as BC
import qualified Data.Time.Clock as Clock
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString as BS
import qualified Data.Text.Encoding as TE
import qualified Data.Maybe as M
import qualified Data.UUID as UUID
--------------------------------------------------------------------------------

-- | Top-level application configuration.
data Config = Config
  { mailgunAPIKey :: Text
  , dbFile :: FilePath
  , configClient :: Text
  , configServer :: Text
  } deriving (Eq, Show)

instance FromEnv Config where
  fromEnv _ = do
    mailgunAPIKey <- env "MAILGUN_API_KEY"
    dbFile <- env "DB_FILE"
    configClient <- env "CLIENT"
    configServer <- env "SERVER"
    pure Config {..}

-- TODO(wpcarro): Properly handle NULL for columns like profilePicture.
forNewtype :: (Typeable b) => (Text -> b) -> FieldParser b
forNewtype wrapper y =
  case fieldData y of
    (SQLText x) -> Ok (wrapper x)
    x -> returnError ConversionFailed y ("We expected SQLText, but we received: " ++ show x)

newtype Username = Username Text
  deriving (Eq, Show, Generic)

instance ToJSON Username
instance FromJSON Username

instance ToField Username where
  toField (Username x) = SQLText x

instance FromField Username where
  fromField = forNewtype Username

newtype HashedPassword = HashedPassword BS.ByteString
  deriving (Eq, Show, Generic)

instance ToField HashedPassword where
  toField (HashedPassword x) = SQLText (TE.decodeUtf8 x)

instance FromField HashedPassword where
  fromField y =
    case fieldData y of
      (SQLText x) -> x |> TE.encodeUtf8 |> HashedPassword |> Ok
      x -> returnError ConversionFailed y ("We expected SQLText, but we received: " ++ show x)

newtype ClearTextPassword = ClearTextPassword Text
  deriving (Eq, Show, Generic)

instance ToJSON ClearTextPassword
instance FromJSON ClearTextPassword

instance ToField ClearTextPassword where
  toField (ClearTextPassword x) = SQLText x

instance FromField ClearTextPassword where
  fromField = forNewtype ClearTextPassword

newtype Email = Email Text
  deriving (Eq, Show, Generic)

instance ToJSON Email
instance FromJSON Email

instance ToField Email where
  toField (Email x) = SQLText x

instance FromField Email where
  fromField = forNewtype Email

data Role = RegularUser | Manager | Admin
  deriving (Eq, Show, Generic)

instance ToJSON Role where
  toJSON RegularUser = "user"
  toJSON Manager = "manager"
  toJSON Admin = "admin"

instance FromJSON Role where
  parseJSON = withText "Role" $ \x ->
    case x of
      "user" -> pure RegularUser
      "manager" -> pure Manager
      "admin" -> pure Admin
      _ -> fail "Expected \"user\" or \"manager\" or \"admin\""

instance ToField Role where
  toField RegularUser = SQLText "user"
  toField Manager = SQLText "manager"
  toField Admin = SQLText "admin"

instance FromField Role where
  fromField y =
    case fieldData y of
      (SQLText "user") -> Ok RegularUser
      (SQLText "manager") -> Ok Manager
      (SQLText "admin") -> Ok Admin
      x -> returnError ConversionFailed y ("We expected user, manager, admin, but we received: " ++ show x)

-- TODO(wpcarro): Prefer Data.ByteString instead of Text
newtype ProfilePicture = ProfilePicture Text
  deriving (Eq, Show, Generic)

instance ToJSON ProfilePicture
instance FromJSON ProfilePicture

instance ToField ProfilePicture where
  toField (ProfilePicture x) = SQLText x

instance FromField ProfilePicture where
  fromField = forNewtype ProfilePicture

data Account = Account
  { accountUsername :: Username
  , accountPassword :: HashedPassword
  , accountEmail :: Email
  , accountRole :: Role
  , accountProfilePicture :: Maybe ProfilePicture
  } deriving (Eq, Show, Generic)

-- | Return a tuple with all of the fields for an Account record to use for SQL.
accountFields :: Account -> (Username, HashedPassword, Email, Role, Maybe ProfilePicture)
accountFields (Account {..})
  = ( accountUsername
    , accountPassword
    , accountEmail
    , accountRole
    , accountProfilePicture
    )

instance FromRow Account where
  fromRow = do
    accountUsername <- field
    accountPassword <- field
    accountEmail <- field
    accountRole <- field
    accountProfilePicture <- field
    pure Account{..}

data Session = Session
  { sessionUsername :: Username
  , sessionRole :: Role
  } deriving (Eq, Show)

instance ToJSON Session where
  toJSON (Session username role) =
    object [ "username" .= username
           , "role" .= role
           ]

newtype Comment = Comment Text
  deriving (Eq, Show, Generic)

instance ToJSON Comment
instance FromJSON Comment

instance ToField Comment where
  toField (Comment x) = SQLText x

instance FromField Comment where
  fromField = forNewtype Comment

newtype Destination = Destination Text
  deriving (Eq, Show, Generic)

instance ToJSON Destination
instance FromJSON Destination

instance ToField Destination where
  toField (Destination x) = SQLText x

instance FromField Destination where
  fromField = forNewtype Destination

newtype Year = Year Integer deriving (Eq, Show)
newtype Month = Month Integer deriving (Eq, Show)
newtype Day = Day Integer deriving (Eq, Show)
data Date = Date
  { dateYear :: Year
  , dateMonth :: Month
  , dateDay :: Day
  } deriving (Eq, Show)

data Trip = Trip
  { tripUsername :: Username
  , tripDestination :: Destination
  , tripStartDate :: Calendar.Day
  , tripEndDate :: Calendar.Day
  , tripComment :: Comment
  } deriving (Eq, Show, Generic)

instance FromRow Trip where
  fromRow = do
    tripUsername <- field
    tripDestination <- field
    tripStartDate <- field
    tripEndDate <- field
    tripComment <- field
    pure Trip{..}

-- | The fields used as the Primary Key for a Trip entry.
data TripPK = TripPK
  { tripPKUsername :: Username
  , tripPKDestination :: Destination
  , tripPKStartDate :: Calendar.Day
  } deriving (Eq, Show, Generic)

tripPKFields :: TripPK -> (Username, Destination, Calendar.Day)
tripPKFields (TripPK{..})
  = (tripPKUsername, tripPKDestination, tripPKStartDate)

instance FromJSON TripPK where
  parseJSON = withObject "TripPK" $ \x -> do
    tripPKUsername    <- x .: "username"
    tripPKDestination <- x .: "destination"
    tripPKStartDate   <- x .: "startDate"
    pure TripPK{..}

-- | Return the tuple representation of a Trip record for SQL.
tripFields :: Trip
           -> (Username, Destination, Calendar.Day, Calendar.Day, Comment)
tripFields (Trip{..})
  = ( tripUsername
    , tripDestination
    , tripStartDate
    , tripEndDate
    , tripComment
    )

instance ToJSON Trip where
  toJSON (Trip username destination startDate endDate comment) =
    object [ "username" .= username
           , "destination" .= destination
           , "startDate" .= startDate
           , "endDate" .= endDate
           , "comment" .= comment
           ]

instance FromJSON Trip where
  parseJSON = withObject "Trip" $ \x -> do
    tripUsername    <- x .: "username"
    tripDestination <- x .: "destination"
    tripStartDate   <- x .: "startDate"
    tripEndDate     <- x .: "endDate"
    tripComment     <- x .: "comment"
    pure Trip{..}

-- | Users and Accounts both refer to the same underlying entities; however,
-- Users model the user-facing Account details, hiding sensitive details like
-- passwords and emails.
data User = User
  { userUsername :: Username
  , userProfilePicture :: Maybe ProfilePicture
  , userRole :: Role
  } deriving (Eq, Show, Generic)

instance ToJSON User where
  toJSON (User username profilePicture role) =
    object [ "username" .= username
           , "profilePicture" .= profilePicture
           , "role" .= role
           ]

userFromAccount :: Account -> User
userFromAccount account =
  User { userUsername = accountUsername account
       , userProfilePicture = accountProfilePicture account
       , userRole = accountRole account
       }

-- | This is the data that a user needs to supply to authenticate with the
-- application.
data AccountCredentials = AccountCredentials
  { accountCredentialsUsername :: Username
  , accountCredentialsPassword :: ClearTextPassword
  } deriving (Eq, Show, Generic)

instance FromJSON AccountCredentials where
  parseJSON = withObject "AccountCredentials" $ \x -> do
    accountCredentialsUsername <- x.: "username"
    accountCredentialsPassword <- x.: "password"
    pure AccountCredentials{..}


-- | Hash password `x`.
hashPassword :: (MonadRandom m) => ClearTextPassword -> m HashedPassword
hashPassword (ClearTextPassword x) = do
  hashed <- BC.hashPassword 12 (x |> unpack |> B.pack)
  pure $ HashedPassword hashed

-- | Return True if the cleartext password matches the hashed password.
passwordsMatch :: ClearTextPassword -> HashedPassword -> Bool
passwordsMatch (ClearTextPassword clear) (HashedPassword hashed) =
  BC.validatePassword (clear |> unpack |> B.pack) hashed

data CreateAccountRequest = CreateAccountRequest
  { createAccountRequestUsername :: Username
  , createAccountRequestPassword :: ClearTextPassword
  , createAccountRequestEmail :: Email
  , createAccountRequestRole :: Role
  } deriving (Eq, Show)

instance FromJSON CreateAccountRequest where
  parseJSON = withObject "CreateAccountRequest" $ \x -> do
    createAccountRequestUsername <- x .: "username"
    createAccountRequestPassword <- x .: "password"
    createAccountRequestEmail <- x .: "email"
    createAccountRequestRole <- x .: "role"
    pure $ CreateAccountRequest{..}

createAccountRequestFields :: CreateAccountRequest
                           -> (Username, ClearTextPassword, Email, Role)
createAccountRequestFields CreateAccountRequest{..} =
  ( createAccountRequestUsername
  , createAccountRequestPassword
  , createAccountRequestEmail
  , createAccountRequestRole
  )

newtype SessionUUID = SessionUUID UUID.UUID
  deriving (Eq, Show, Generic)

instance FromField SessionUUID where
  fromField y =
    case fieldData y of
      (SQLText x) ->
        case UUID.fromText x of
          Nothing -> returnError ConversionFailed y ("Could not convert to UUID: " ++ show x)
          Just uuid -> Ok $ SessionUUID uuid
      _ -> returnError ConversionFailed y "Expected SQLText for SessionUUID, but we received"

instance ToField SessionUUID where
  toField (SessionUUID uuid) =
    uuid |> UUID.toText |> SQLText

data StoredSession = StoredSession
  { storedSessionUUID :: SessionUUID
  , storedSessionUsername :: Username
  , storedSessionTsCreated :: Clock.UTCTime
  } deriving (Eq, Show, Generic)

instance FromRow StoredSession where
  fromRow = do
    storedSessionUUID <- field
    storedSessionUsername <- field
    storedSessionTsCreated <- field
    pure StoredSession {..}

data LoginAttempt = LoginAttempt
  { loginAttemptUsername :: Username
  , loginAttemptNumAttempts :: Integer
  } deriving (Eq, Show)

instance FromRow LoginAttempt where
  fromRow = do
    loginAttemptUsername <- field
    loginAttemptNumAttempts <- field
    pure LoginAttempt {..}

newtype SessionCookie = SessionCookie Cookies

instance FromHttpApiData SessionCookie where
  parseHeader x =
    x |> parseCookies |> SessionCookie |> pure
  parseQueryParam x =
    x |> TE.encodeUtf8 |> parseCookies |> SessionCookie |> pure

newtype RegistrationSecret = RegistrationSecret UUID.UUID
  deriving (Eq, Show, Generic)

instance FromHttpApiData RegistrationSecret where
  parseQueryParam x =
    case UUID.fromText x of
      Nothing -> Left x
      Just uuid -> Right (RegistrationSecret uuid)

instance FromField RegistrationSecret where
  fromField y =
    case fieldData y of
      (SQLText x) ->
        case UUID.fromText x of
          Nothing -> returnError ConversionFailed y ("Could not convert text to UUID: " ++ show x)
          Just uuid -> Ok $ RegistrationSecret uuid
      _ -> returnError ConversionFailed y "Field data is not SQLText, which is what we expect"

instance ToField RegistrationSecret where
  toField (RegistrationSecret secretUUID) =
    secretUUID |> UUID.toText |> SQLText

instance FromJSON RegistrationSecret

data VerifyAccountRequest = VerifyAccountRequest
  { verifyAccountRequestUsername :: Username
  , verifyAccountRequestSecret :: RegistrationSecret
  } deriving (Eq, Show)

instance FromJSON VerifyAccountRequest where
  parseJSON = withObject "VerifyAccountRequest" $ \x -> do
    verifyAccountRequestUsername <- x .: "username"
    verifyAccountRequestSecret   <- x .: "secret"
    pure VerifyAccountRequest{..}

data PendingAccount = PendingAccount
  { pendingAccountSecret :: RegistrationSecret
  , pendingAccountUsername :: Username
  , pendingAccountPassword :: HashedPassword
  , pendingAccountRole :: Role
  , pendingAccountEmail :: Email
  } deriving (Eq, Show)

instance FromRow PendingAccount where
  fromRow = do
    pendingAccountSecret <- field
    pendingAccountUsername <- field
    pendingAccountPassword <- field
    pendingAccountRole <- field
    pendingAccountEmail <- field
    pure PendingAccount {..}

data UpdateTripRequest = UpdateTripRequest
  { updateTripRequestTripPK :: TripPK
  , updateTripRequestDestination :: Maybe Destination
  , updateTripRequestStartDate :: Maybe Calendar.Day
  , updateTripRequestEndDate :: Maybe Calendar.Day
  , updateTripRequestComment :: Maybe Comment
  } deriving (Eq, Show)

instance FromJSON UpdateTripRequest where
  parseJSON = withObject "UpdateTripRequest" $ \x -> do
    updateTripRequestTripPK <- x .: "tripKey"
    -- the following four fields might not be present
    updateTripRequestDestination <- x .:? "destination"
    updateTripRequestStartDate   <- x .:? "startDate"
    updateTripRequestEndDate     <- x .:? "endDate"
    updateTripRequestComment     <- x .:? "comment"
    pure UpdateTripRequest{..}

-- | Apply the updates in the UpdateTripRequest to Trip.
updateTrip :: UpdateTripRequest -> Trip -> Trip
updateTrip UpdateTripRequest{..} Trip{..} = Trip
  { tripUsername    = tripUsername
  , tripDestination = M.fromMaybe tripDestination updateTripRequestDestination
  , tripStartDate   = M.fromMaybe tripStartDate updateTripRequestStartDate
  , tripEndDate     = M.fromMaybe tripEndDate updateTripRequestEndDate
  , tripComment     = M.fromMaybe tripComment updateTripRequestComment
  }

data UnfreezeAccountRequest = UnfreezeAccountRequest
  { unfreezeAccountRequestUsername :: Username
  } deriving (Eq, Show)

instance FromJSON UnfreezeAccountRequest where
  parseJSON = withObject "UnfreezeAccountRequest" $ \x -> do
    unfreezeAccountRequestUsername <- x .: "username"
    pure UnfreezeAccountRequest{..}

data InviteUserRequest = InviteUserRequest
  { inviteUserRequestEmail :: Email
  , inviteUserRequestRole :: Role
  } deriving (Eq, Show)

instance FromJSON InviteUserRequest where
  parseJSON = withObject "InviteUserRequest" $ \x -> do
    inviteUserRequestEmail <- x .: "email"
    inviteUserRequestRole <- x .: "role"
    pure InviteUserRequest{..}

newtype InvitationSecret = InvitationSecret UUID.UUID
  deriving (Eq, Show, Generic)

instance ToJSON InvitationSecret
instance FromJSON InvitationSecret

instance ToField InvitationSecret where
  toField (InvitationSecret secretUUID) =
    secretUUID |> UUID.toText |> SQLText

instance FromField InvitationSecret where
  fromField y =
    case fieldData y of
      (SQLText x) ->
        case UUID.fromText x of
          Nothing -> returnError ConversionFailed y ("Could not convert text to UUID: " ++ show x)
          Just z -> Ok $ InvitationSecret z
      _ -> returnError ConversionFailed y "Field data is not SQLText, which is what we expect"

data Invitation = Invitation
  { invitationEmail :: Email
  , invitationRole :: Role
  , invitationSecret :: InvitationSecret
  } deriving (Eq, Show)

instance FromRow Invitation where
  fromRow = Invitation <$> field
                       <*> field
                       <*> field

data AcceptInvitationRequest = AcceptInvitationRequest
  { acceptInvitationRequestUsername :: Username
  , acceptInvitationRequestPassword :: ClearTextPassword
  , acceptInvitationRequestEmail :: Email
  , acceptInvitationRequestSecret :: InvitationSecret
  } deriving (Eq, Show)

instance FromJSON AcceptInvitationRequest where
  parseJSON = withObject "AcceptInvitationRequest" $ \x -> do
    acceptInvitationRequestUsername <- x .: "username"
    acceptInvitationRequestPassword <- x .: "password"
    acceptInvitationRequestEmail <- x .: "email"
    acceptInvitationRequestSecret <- x .: "secret"
    pure AcceptInvitationRequest{..}