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) whereimport ClassyPreludeimport Control.Lensimport Data.Hourglassimport Database.SQLite.Simpleimport Database.SQLite.Simple.ToFieldimport qualified Text.Read as Rimport Aftokimport Aftok.Auctionsimport Aftok.Projectsimport Aftok.Databaseimport Aftok.TimeLogimport Aftok.Usersnewtype PLogEntry = PLogEntry LogEntrymakePrisms ''PLogEntryinstance ToRow PLogEntry wheretoRow (PLogEntry (LogEntry a e)) =toRow (a ^. address, e ^. (eventType . to eventName), e ^. eventTime)instance FromRow PLogEntry wherefromRow =let workEventParser = WorkEvent <$> (field >>= nameEvent) <*> fieldlogEntryParser = LogEntry <$> (fmap BtcAddr field) <*> workEventParserin fmap PLogEntry logEntryParsernewtype PAuction = PAuction AuctionmakePrisms ''PAuctioninstance FromRow PAuction wherefromRow =let auctionParser = Auction <$> fmap R.read field <*> fieldin fmap PAuction auctionParsernewtype PBid = PBid BidmakePrisms ''PBidinstance FromRow PBid wherefromRow =let bidParser = Bid <$> fmap UserId field <*> fmap Seconds field <*> fmap R.read field <*> fieldin fmap PBid bidParsernewtype PSeconds = PSeconds Secondsinstance ToField PSeconds wheretoField (PSeconds (Seconds i)) = toField inewtype PUserId = PUserId UserIdinstance ToField PUserId wheretoField (PUserId (UserId i)) = toField inewtype PAuctionId = PAuctionId AuctionIdinstance ToField PAuctionId wheretoField (PAuctionId (AuctionId i)) = toField i-- TODO: Record the user idrecordEvent' :: ProjectId -> UserId -> LogEntry -> ReaderT Connection IO ()recordEvent' _ _ logEntry = doconn <- asklift $ execute conn"INSERT INTO work_events (btc_addr, event_type, event_time) VALUES (?, ?, ?)"(logEntry ^. (from _PLogEntry))readWorkIndex' :: ProjectId -> ReaderT Connection IO WorkIndexreadWorkIndex' _ = doconn <- askrows <- lift $ query_ conn"SELECT btc_addr, event_type, event_time from work_events"lift . pure . workIndex $ fmap (^. _PLogEntry) rowsnewAuction' :: ProjectId -> Auction -> ReaderT Connection IO AuctionIdnewAuction' _ auc = doconn <- asklift $ execute conn"INSERT INTO auctions (raise_amount, end_time) VALUES (?, ?)"(show $ auc ^. raiseAmount, auc ^. auctionEnd)lift . fmap AuctionId $ lastInsertRowId connreadAuction' :: AuctionId -> ReaderT Connection IO (Maybe Auction)readAuction' aucId = doconn <- askrows <- lift $ query conn"SELECT raise_amount, end_time FROM auctions WHERE ROWID = ?"(Only $ PAuctionId aucId)lift . return . headMay $ fmap (^. _PAuction) rowsrecordBid' :: AuctionId -> Bid -> ReaderT Connection IO ()recordBid' aucId bid = doconn <- asklift $ 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 = doconn <- askrows <- lift $ query conn"SELECT user_id, bid_seconds, bid_amount, bid_time FROM bids WHERE auction_id = ?"(Only $ PAuctionId aucId)lift . return $ fmap (^. _PBid) rowscreateUser' :: User -> ReaderT Connection IO UserIdcreateUser' u = doconn <- asklift $ execute conn"INSERT INTO users (btc_addr, email) VALUES (?, ?)"(u ^. (userAddress . address), u ^. userEmail)lift . fmap UserId $ lastInsertRowId connsqliteQDB :: 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) = doinvCode <- liftIO randomInvCodevoid $ 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) = doinvitations <- pquery invitationParser"SELECT project_id, invitor_id, invitee_email, invitation_time, acceptance_time \\FROM invitations WHERE invitation_key = ?"(Only $ renderInvCode ic)pure $ headMay invitationsdbEval (AcceptInvitation (UserId uid) ic t) = transactQDBM $ dovoid $ 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| InvitationAlreadyAcceptedderiving (Eq, Show, Typeable)data DBError = OpForbidden UserId OpForbiddenReason| SubjectNotFoundderiving (Eq, Show, Typeable)instance Exception DBErrorraiseOpForbidden :: UserId -> OpForbiddenReason -> DBOp x -> DBOp xraiseOpForbidden uid r = RaiseDBError (OpForbidden uid r)raiseSubjectNotFound :: DBOp x -> DBOp xraiseSubjectNotFound = RaiseDBError SubjectNotFound
else OpForbidden uid UserNotProjectMember act
else raiseOpForbidden uid UserNotProjectMember actaddUserToProject :: ProjectId -> InvitingUID -> InvitedUID -> DBProg ()addUserToProject pid current new =withProjectAuth pid current $ AddUserToProject pid current newcreateInvitation :: ProjectId -> InvitingUID -> Email -> C.UTCTime -> DBProg InvitationCodecreateInvitation pid current email t =withProjectAuth pid current $ CreateInvitation pid current email tfindInvitation :: InvitationCode -> DBProg (Maybe Invitation)findInvitation ic = fc $ FindInvitation icacceptInvitation :: UserId -> InvitationCode -> C.UTCTime -> DBProg ()acceptInvitation uid ic t = doinv <- findInvitation iclet act = AcceptInvitation uid ic tcase inv ofNothing ->fc $ raiseSubjectNotFound actJust i | t .-. (i ^. invitationTime) > fromSeconds (60 * 60 * 72 :: Int) ->fc $ raiseOpForbidden uid InvitationExpired actJust i | isJust (i ^. acceptanceTime) ->fc $ raiseOpForbidden uid InvitationAlreadyAccepted actJust 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 ''InvitationCoderandomInvCode :: IO InvitationCoderandomInvCode = InvitationCode <$> randBytes 256
newtype InvitationId = InvitationId UUID deriving (Show, Eq)
parseInvCode :: Text -> Either String InvitationCodeparseInvCode t = docode <- B64.decode . encodeUtf8 $ tif length code == 256then Right $ InvitationCode codeelse Left "Invitation code appears to be invalid."
data Acceptance = Acceptance{ _acceptedInvitation :: InvitationId, _blockHeight :: Integer, _observedAt :: UTCTime
renderInvCode :: InvitationCode -> TextrenderInvCode (InvitationCode bs) = decodeUtf8 $ B64.encode bsdata Invitation = Invitation{ _projectId :: ProjectId, _invitingUser :: UserId, _invitedEmail :: Email, _invitationTime :: C.UTCTime, _acceptanceTime :: Maybe C.UTCTime
requireProjectId :: Handler App App ProjectIdrequireProjectId = dopidMay <- getParam "projectId"case ProjectId <$> (readMay =<< fmap decodeUtf8 pidMay) ofNothing -> snapError 400 "Value of parameter projectId could not be parsed to a valid value."Just pid -> pure pid
requireProjectId :: MonadSnap m => m ProjectIdrequireProjectId = domaybePid <- parseParam "projectId" pidParsermaybe (snapError 400 "Value of parameter \"projectId\" cannot be parsed as a valid UUID")puremaybePidwherepidParser = dobs <- takeByteStringpure $ ProjectId <$> fromASCIIBytes bs
projectInviteHandler :: QConfig -> Handler App App ()projectInviteHandler cfg = douid <- requireUserIdpid <- requireProjectIdtoEmail <- 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 tinviteEmail <- liftIO $projectInviteEmail (templatePath cfg) (p ^. projectName) (u ^. userEmail) toEmail invCodemaybeSuccess <- liftIO $ Sendgrid.sendEmail (sendgridAuth cfg) inviteEmailmaybe(snapError 500 "The invitation record was created successfully, but the introductory email could not be sent.")(const $ pure ())maybeSuccessprojectInviteEmail :: System.IO.FilePath-> ProjectName-> Email -> Email-> InvitationCode-> IO Sendgrid.EmailMessageprojectInviteEmail templatePath pn from' to' invCode = dotemplates <- directoryGroup templatePathtemplate <- maybe (fail "Could not find template for invitation email") pure $getStringTemplate "invitation_email" templateslet 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 asnapEval p = liftPG . runReaderT . runQDBM $ interpret dbEval p
snapEval :: (MonadSnap m, HasPostgres m) => DBProg a -> m asnapEval p = dolet 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 aparseParam name parser = domaybeBytes <- getParam namecase maybeBytes ofNothing -> 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" cookieTimeoutpgs <- nestSnaplet "db" db $ pgsInit' pgsConfig
initCookieSessionManager (authSiteKey cfg) "quookie" (cookieTimeout cfg)pgs <- nestSnaplet "db" db $ pgsInit' (pgsConfig cfg)
projectCreateRoute = void $ method POST projectCreateHandlerprojectRoute = serveJSON projectJSON $ method GET projectGetHandlerlistProjectsRoute = serveJSON (fmap qdbProjectJSON) $ method GET projectListHandlerpayoutsRoute = 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_companionsadd joined_at timestamp with time zone not nulldefault (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);