Add ability to send email via SendGrid. This should probably be factored out into something more testable.
2XQD6KKKD6QVHFHAEMVE3XXY7X2T7BLTLL7XIILZAXNJJH2YONUQC
V2VDN77HCSRYYWXDJJ2XOVHV4P6PVWNJZLXZ7JUYPQEZQIH5BZ3QC
JEOPOOPTQ7ESS2IG7KROXNF67RB37X63GVM6UK3FYMZG6VUUQG2AC
ZITLSTYXUOESFELOW3DLBKWKMSS5ZJYCTKMK4Z44WGIYAKYSMMVAC
4ZLEDBK7VGLKFUPENAFLUJYNFLKFYJ3TREPQ7P6PKMYGJUXB55HQC
64C6AWH66FDKU6UE6Z6JPX2J2GBM2JOPTH2GL6LHKAIUBGNGDZ5AC
OBFPJS2GHO2PEHBHGEHKIUOUAFIQHPIZXEVD2YIE3ZIE2PVMH5VAC
WO2MINIF4TXOHWSE7JWXRZYN64XRVLYIRFMF4SMPSOXKA2V77KMQC
RPAJLHMTUJU4AYNBOHVGHGGB4NY2NLY3BVPYN5FMWB3ZIMAUQHCQC
PBD7LZYQHXAA3KLH2ZUX5GW4UFML6BQ32KXZF4KZ6OYFASUYFJ5QC
QMRKFEPGFBCEWAIXPEIG5ILKAJ2JH5L3TOITHR4HNJXK5FN3KXBQC
5DRIWGLUKMQZU2ZPBXSTLAWJKAMOD5YXAHM5LEDQHDFGYYLHWCDQC
TLQ72DSJD7GGPWN6HGBHAVPBRQFKEQ6KSK43U7JWWID4ZWAF47JAC
GCVQD44VRPQVKPZEPIC4AOIXLJIG2ZMV3QI2Y7KALUT6NVUBSGSAC
7KZP4RHZ3QSYTPPQ257A65Z5UPX44TF2LAI2U5EMULQCLDCEUK2AC
HE3JTXO37O4MOMWPZ4BRBHP53KBPZDG3PCSUCVNOKIS7IY26OCIAC
4IQVQL4TS35GL2GYZJG254TKJLL5EHMRSFT77Z4VTRZIG2TMBM3QC
BROSTG5KP3NUNLSYPVQID254TE47E5RKQAKLPIY7PGWETE6JNMTAC
EMVTF2IWNQGRL44FC4JNG5FYYQTZSFPNM6SOM7IAEH6T7PPK2NVAC
WZUHEZSBRKHQMNWDKVG4X6DDIQEAXTGI6IGAJ5ERPRQ3W2KUMX4QC
TCOAKCGGHOIRJCTZYEZQ3K6KCGL2LGAYGYFRGSPCHBTJJY2V6AXAC
IZEVQF627FA7VV25KJAWYWGGC35LZUUBBQRPN5ZAINTQLPEDRTEAC
LAROLAYUGJ4Q5AEFV5EJMIA2ZKBNCBWHHHPCJ3CKCNIUIYUKRFVQC
75N3UJ4JK56KXF56GASGPAWLFYGJDETVJNYTF4KXFCQM767JUU5AC
5W5M56VJFJEBXMGBVKGCKPHOEMVTKUOQMLPJP7VNDQLTYNJXXLHQC
4U7F3CPIDTK6JSEDMNMHVKSR7HOQDLZQD2PPVMDLHO5SFSIMUXZAC
64VI73NPSFNWTL6UXM6YHRFLNJZ3NUJ2R3CL53MO2HSZWFGBIRTQC
WFZDMVUXZ2KPTMRAZGEYHKEJTKOKWVYCXKKAKQ7K6I5TMSLBUJ4QC
JKMHA2QGDSVHD4DKDYQUYNJJ3LUQCOPOWEC3543BDWDXLYIBBZXQC
MXLZBRQNXRIJ4BTAEDSLA4N5PABEG7GMWSM7GS4ACJQ6BE4PVAKQC
MWUPXTBF2LATVOJLJTXSDFB3OMFGMXDNETWJA3JHUOUBTUJ7WJAAC
LUM4VQJIHJKQWWD5NVWTVSNPKQTMGQQICTFOTM6W4BMME2G3G5RQC
FRPWIKCNGK6PM6VCKEHEUG5A2LWL7WFN66L4CPQ7DLN4WAS3TIZQC
Z7KS5XHHC6PAMTVBHXY7KUSS3BWAOU6FSYIITUCFOOJZU4OUJHBAC
XMONXALY6ZE6GED7TZGLNS5AUHTO23C5AUC74LEBQSFXRMQDPOLQC
7XN3I3QJHYMKU2DCUXX34WQMSJ4ZJOWW7FME34EANO3E5W4Q632AC
TNR3TEHKVADAEZSTOD2XLSUTSW5AWST2YUW4CWK5KE7DSC6XHZNAC
A6HKMINBNGQLLX4QJMYWKQ4JAEHVJ4HIRVDKPPDI3FJUO2AAB7OQC
KNSI575VAW6HRCZYXOEPQ4DTSML4EORML5MV4DJBRKE7TXCPS4EAC
LD4GLVSF6YTA7OZWIGJ45H6TUXGM4WKUIYXKWQFNUP36WDMYSMXAC
O5FVTOM6YFBLEPF3S576K6IMT6ZZ5VQCSB3YVXNS4CKBITKCRZ7AC
W35DDBFYF6Z4ZPCFEO5RPAONZLYCSNTXUSTS6FIUUVZHCI6Q7GHAC
NVOCQVASZWTKQJG7GPH7KHKZZR7NUG4WLV5YY4KAIRPCJRWCZPIAC
I2KHGVD44KT4MQJXGCTVSQKMBO6TVCY72F26TLXGWRL6PHGF6RNQC
BXGLKYRXO2O4NRM3BLNWQ7AWVPQXAMFS57MFYHJNOZZEZZW5BH6AC
ADMKQQGCGVSHHIMVQ4XFRDCG544SBJCYALSKZV45CQQBZ4ACUH2AC
EQXRXRZDYCM7BDAVBOXQYPG6C7IJT3OFGNIXCDAHJJBRKAXNGL7AC
, lens >= 4.4.0.2
, network-bitcoin >= 1.7.0
, old-locale
, postgresql-simple >= 0.4.9 && < 0.5
, safe >= 0.3.8
, lens >= 4.11 && < 4.12
, network-bitcoin >= 1.8 && < 1.9
, old-locale >= 1.0
, postgresql-simple >= 0.4.10 && < 0.5
, safe >= 0.3.9 && < 0.4
, snap >= 0.13 && < 0.14
, snap-core >= 0.9 && < 0.10
, snap-server >= 0.9 && < 0.10
, snaplet-postgresql-simple >= 0.6
, sendgrid-haskell >= 1.0
, snap >= 0.14
, snap-core >= 0.9 && < 0.11
, snap-server >= 0.9 && < 0.11
, snaplet-postgresql-simple >= 0.6 && < 0.11
{-# LANGUAGE ScopedTypeVariables, OverloadedStrings, NoImplicitPrelude #-}
{-# LANGUAGE TemplateHaskell #-}
module Aftok.Database.SQLite (sqliteQDB) where
import ClassyPrelude
import Control.Lens
import Data.Hourglass
import Database.SQLite.Simple
import Database.SQLite.Simple.ToField
import qualified Text.Read as R
import Aftok
import Aftok.Auctions
import Aftok.Projects
import Aftok.Database
import Aftok.TimeLog
import Aftok.Users
newtype PLogEntry = PLogEntry LogEntry
makePrisms ''PLogEntry
instance ToRow PLogEntry where
toRow (PLogEntry (LogEntry a e)) =
toRow (a ^. address, e ^. (eventType . to eventName), e ^. eventTime)
instance FromRow PLogEntry where
fromRow =
let workEventParser = WorkEvent <$> (field >>= nameEvent) <*> field
logEntryParser = LogEntry <$> (fmap BtcAddr field) <*> workEventParser
in fmap PLogEntry logEntryParser
newtype PAuction = PAuction Auction
makePrisms ''PAuction
instance FromRow PAuction where
fromRow =
let auctionParser = Auction <$> fmap R.read field <*> field
in fmap PAuction auctionParser
newtype PBid = PBid Bid
makePrisms ''PBid
instance FromRow PBid where
fromRow =
let bidParser = Bid <$> fmap UserId field <*> fmap Seconds field <*> fmap R.read field <*> field
in fmap PBid bidParser
newtype PSeconds = PSeconds Seconds
instance ToField PSeconds where
toField (PSeconds (Seconds i)) = toField i
newtype PUserId = PUserId UserId
instance ToField PUserId where
toField (PUserId (UserId i)) = toField i
newtype PAuctionId = PAuctionId AuctionId
instance ToField PAuctionId where
toField (PAuctionId (AuctionId i)) = toField i
-- TODO: Record the user id
recordEvent' :: ProjectId -> UserId -> LogEntry -> ReaderT Connection IO ()
recordEvent' _ _ logEntry = do
conn <- ask
lift $ execute conn
"INSERT INTO work_events (btc_addr, event_type, event_time) VALUES (?, ?, ?)"
(logEntry ^. (from _PLogEntry))
readWorkIndex' :: ProjectId -> ReaderT Connection IO WorkIndex
readWorkIndex' _ = do
conn <- ask
rows <- lift $ query_ conn
"SELECT btc_addr, event_type, event_time from work_events"
lift . pure . workIndex $ fmap (^. _PLogEntry) rows
newAuction' :: ProjectId -> Auction -> ReaderT Connection IO AuctionId
newAuction' _ auc = do
conn <- ask
lift $ execute conn
"INSERT INTO auctions (raise_amount, end_time) VALUES (?, ?)"
(show $ auc ^. raiseAmount, auc ^. auctionEnd)
lift . fmap AuctionId $ lastInsertRowId conn
readAuction' :: AuctionId -> ReaderT Connection IO (Maybe Auction)
readAuction' aucId = do
conn <- ask
rows <- lift $ query conn
"SELECT raise_amount, end_time FROM auctions WHERE ROWID = ?"
(Only $ PAuctionId aucId)
lift . return . headMay $ fmap (^. _PAuction) rows
recordBid' :: AuctionId -> Bid -> ReaderT Connection IO ()
recordBid' aucId bid = do
conn <- ask
lift $ execute conn
"INSERT INTO bids (auction_id, user_id, bid_seconds, bid_amount, bid_time) values (?, ?, ?, ?, ?)"
( PAuctionId aucId
, PUserId $ bid ^. bidUser
, PSeconds $ bid ^. bidSeconds
, show $ bid ^. bidAmount
, bid ^. bidTime
)
readBids' :: AuctionId -> ReaderT Connection IO [Bid]
readBids' aucId = do
conn <- ask
rows <- lift $ query conn
"SELECT user_id, bid_seconds, bid_amount, bid_time FROM bids WHERE auction_id = ?"
(Only $ PAuctionId aucId)
lift . return $ fmap (^. _PBid) rows
createUser' :: User -> ReaderT Connection IO UserId
createUser' u = do
conn <- ask
lift $ execute conn
"INSERT INTO users (btc_addr, email) VALUES (?, ?)"
(u ^. (userAddress . address), u ^. userEmail)
lift . fmap UserId $ lastInsertRowId conn
sqliteQDB :: QDB (ReaderT Connection IO)
sqliteQDB = QDB
{ recordEvent = recordEvent'
, readWorkIndex = readWorkIndex'
, newAuction = newAuction'
, readAuction = readAuction'
, recordBid = recordBid'
, readBids = readBids'
, createUser = createUser'
, findUser = \_ -> pure Nothing
, findUserByUserName = \_ -> pure Nothing
}
dbEval (CreateInvitation (ProjectId pid) (UserId uid) (Email e) t) = do
invCode <- liftIO randomInvCode
void $ pexec
"INSERT INTO invitations (project_id, invitor_id, invitee_email, invitation_key, invitation_time) \
\VALUES (?, ?, ?, ?, ?)"
(pid, uid, e, renderInvCode invCode, fromThyme t)
pure invCode
dbEval (FindInvitation ic) = do
invitations <- pquery invitationParser
"SELECT project_id, invitor_id, invitee_email, invitation_time, acceptance_time \
\FROM invitations WHERE invitation_key = ?"
(Only $ renderInvCode ic)
pure $ headMay invitations
dbEval (AcceptInvitation (UserId uid) ic t) = transactQDBM $ do
void $ pexec
"UPDATE invitations SET acceptance_time = ? WHERE invitation_key = ?"
(fromThyme t, renderInvCode ic)
void $ pexec
"INSERT INTO project_companions (project_id, user_id, invited_by, joined_at) \
\SELECT i.project_id, ?, i.invitor_id, ? \
\FROM invitations i \
\WHERE i.invitation_key = ?"
(uid, fromThyme t, renderInvCode ic)
deriving (Eq, Show)
| InvitationExpired
| InvitationAlreadyAccepted
deriving (Eq, Show, Typeable)
data DBError = OpForbidden UserId OpForbiddenReason
| SubjectNotFound
deriving (Eq, Show, Typeable)
instance Exception DBError
raiseOpForbidden :: UserId -> OpForbiddenReason -> DBOp x -> DBOp x
raiseOpForbidden uid r = RaiseDBError (OpForbidden uid r)
raiseSubjectNotFound :: DBOp x -> DBOp x
raiseSubjectNotFound = RaiseDBError SubjectNotFound
else OpForbidden uid UserNotProjectMember act
else raiseOpForbidden uid UserNotProjectMember act
addUserToProject :: ProjectId -> InvitingUID -> InvitedUID -> DBProg ()
addUserToProject pid current new =
withProjectAuth pid current $ AddUserToProject pid current new
createInvitation :: ProjectId -> InvitingUID -> Email -> C.UTCTime -> DBProg InvitationCode
createInvitation pid current email t =
withProjectAuth pid current $ CreateInvitation pid current email t
findInvitation :: InvitationCode -> DBProg (Maybe Invitation)
findInvitation ic = fc $ FindInvitation ic
acceptInvitation :: UserId -> InvitationCode -> C.UTCTime -> DBProg ()
acceptInvitation uid ic t = do
inv <- findInvitation ic
let act = AcceptInvitation uid ic t
case inv of
Nothing ->
fc $ raiseSubjectNotFound act
Just i | t .-. (i ^. invitationTime) > fromSeconds (60 * 60 * 72 :: Int) ->
fc $ raiseOpForbidden uid InvitationExpired act
Just i | isJust (i ^. acceptanceTime) ->
fc $ raiseOpForbidden uid InvitationAlreadyAccepted act
Just i ->
withProjectAuth (i ^. projectId) (i ^. invitingUser) act
data Invitation = Invitation
{ _invitationProject :: ProjectId
, _currentMember :: UserId
, _sentAt :: UTCTime
, _expiresAt :: UTCTime
, _toAddr :: BtcAddr
, _amount :: BTC
}
makeLenses ''Invitation
newtype InvitationCode = InvitationCode ByteString deriving (Eq)
makePrisms ''InvitationCode
randomInvCode :: IO InvitationCode
randomInvCode = InvitationCode <$> randBytes 256
newtype InvitationId = InvitationId UUID deriving (Show, Eq)
parseInvCode :: Text -> Either String InvitationCode
parseInvCode t = do
code <- B64.decode . encodeUtf8 $ t
if length code == 256
then Right $ InvitationCode code
else Left "Invitation code appears to be invalid."
data Acceptance = Acceptance
{ _acceptedInvitation :: InvitationId
, _blockHeight :: Integer
, _observedAt :: UTCTime
renderInvCode :: InvitationCode -> Text
renderInvCode (InvitationCode bs) = decodeUtf8 $ B64.encode bs
data Invitation = Invitation
{ _projectId :: ProjectId
, _invitingUser :: UserId
, _invitedEmail :: Email
, _invitationTime :: C.UTCTime
, _acceptanceTime :: Maybe C.UTCTime
requireProjectId :: Handler App App ProjectId
requireProjectId = do
pidMay <- getParam "projectId"
case ProjectId <$> (readMay =<< fmap decodeUtf8 pidMay) of
Nothing -> snapError 400 "Value of parameter projectId could not be parsed to a valid value."
Just pid -> pure pid
requireProjectId :: MonadSnap m => m ProjectId
requireProjectId = do
maybePid <- parseParam "projectId" pidParser
maybe (snapError 400 "Value of parameter \"projectId\" cannot be parsed as a valid UUID")
pure
maybePid
where
pidParser = do
bs <- takeByteString
pure $ ProjectId <$> fromASCIIBytes bs
projectInviteHandler :: QConfig -> Handler App App ()
projectInviteHandler cfg = do
uid <- requireUserId
pid <- requireProjectId
toEmail <- parseParam "email" (fmap (Email . decodeUtf8) takeByteString)
t <- liftIO C.getCurrentTime
(Just u, Just p, invCode) <- snapEval $
(,,) <$> findUser uid
<*> findProject pid uid
<*> createInvitation pid uid toEmail t
inviteEmail <- liftIO $
projectInviteEmail (templatePath cfg) (p ^. projectName) (u ^. userEmail) toEmail invCode
maybeSuccess <- liftIO $ Sendgrid.sendEmail (sendgridAuth cfg) inviteEmail
maybe
(snapError 500 "The invitation record was created successfully, but the introductory email could not be sent.")
(const $ pure ())
maybeSuccess
projectInviteEmail :: System.IO.FilePath
-> ProjectName
-> Email -> Email
-> InvitationCode
-> IO Sendgrid.EmailMessage
projectInviteEmail templatePath pn from' to' invCode = do
templates <- directoryGroup templatePath
template <- maybe (fail "Could not find template for invitation email") pure $
getStringTemplate "invitation_email" templates
let setAttrs = setAttribute "invCode" (renderInvCode invCode)
return $ Sendgrid.EmailMessage
{ from = unpack $ from' ^. _Email
, to = unpack $ to' ^. _Email
, subject = unpack $ "Welcome to the "<>pn<>" Aftok!"
, text = render $ setAttrs template
}
let u = User <$> (UserName <$> v .: "username")
<*> (BtcAddr <$> v .: "btcAddr")
<*> v .: "email"
in CU <$> u <*> (fromString <$> v .: "password")
let parseUser = User <$> (UserName <$> v .: "username")
<*> (BtcAddr <$> v .: "btcAddr")
<*> (Email <$> v .: "email")
parseInvitationCodes c = either
(\e -> fail $ "Invitation code was rejected as invalid: " <> e)
pure
(traverse parseInvCode c)
in CU <$> parseUser
<*> (fromString <$> v .: "password")
<*> (parseInvitationCodes =<< v .: "invitation_codes")
snapEval :: DBProg a -> Handler App App a
snapEval p = liftPG . runReaderT . runQDBM $ interpret dbEval p
snapEval :: (MonadSnap m, HasPostgres m) => DBProg a -> m a
snapEval p = do
let handleDBError (OpForbidden (UserId uid) reason) =
snapError 403 $ tshow reason <> " (User " <> tshow uid <> ")"
handleDBError (SubjectNotFound) =
snapError 404 "The subject of the requested operation could not be found."
e <- liftPG $ \conn -> runEitherT (runQDBM conn $ interpret dbEval p)
either handleDBError pure e
parseParam :: MonadSnap m => ByteString -> Parser a -> m a
parseParam name parser = do
maybeBytes <- getParam name
case maybeBytes of
Nothing -> snapError 400 $ "Parameter "<> tshow name <>" is required"
Just bytes -> either
(const . snapError 400 $ "Value of parameter "<> tshow name <>" could not be parsed to a valid value.")
pure
(parseOnly parser bytes)
initCookieSessionManager authSiteKey "quookie" cookieTimeout
pgs <- nestSnaplet "db" db $ pgsInit' pgsConfig
initCookieSessionManager (authSiteKey cfg) "quookie" (cookieTimeout cfg)
pgs <- nestSnaplet "db" db $ pgsInit' (pgsConfig cfg)
projectCreateRoute = void $ method POST projectCreateHandler
projectRoute = serveJSON projectJSON $ method GET projectGetHandler
listProjectsRoute = serveJSON (fmap qdbProjectJSON) $ method GET projectListHandler
payoutsRoute = serveJSON payoutsJSON $ method GET payoutsHandler
, ("projects", projectCreateRoute)
, ("projects", listProjectsRoute)
, ("projects/:projectId", projectRoute)
, ("projects/:projectId/payouts", payoutsRoute)
, ("projects/:projectId/payouts", payoutsRoute)
, ("projects/:projectId/invite", inviteRoute)
, ("events/:eventId/amend", amendEventRoute)
alter table project_companions
add joined_at timestamp with time zone not null
default (now() at time zone "UTC");
create table invitations (
id uuid primary key default uuid_generate_v4(),
project_id uuid references projects(id) not null,
invitor_id uuid references users (id) not null,
invitee_email text not null,
invitation_key text not null,
invitation_time timestamp with time zone not null default (now() at time zone 'UTC'),
acceptance_time timestamp with time zone
);