diff --git a/apps/simplex-directory-service/Main.hs b/apps/simplex-directory-service/Main.hs index 88e7739aa0..e5c3fda573 100644 --- a/apps/simplex-directory-service/Main.hs +++ b/apps/simplex-directory-service/Main.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} module Main where @@ -5,12 +6,21 @@ module Main where import Directory.Options import Directory.Service import Directory.Store +import Directory.Store.Migrate import Simplex.Chat.Terminal (terminalChatConfig) main :: IO () main = do - opts@DirectoryOpts {directoryLog, runCLI} <- welcomeGetOpts - st <- restoreDirectoryStore directoryLog - if runCLI - then directoryServiceCLI st opts - else directoryService st opts terminalChatConfig + opts@DirectoryOpts {directoryLog, migrateDirectoryLog, runCLI} <- welcomeGetOpts + case migrateDirectoryLog of + Just cmd -> migrate cmd opts terminalChatConfig + Nothing -> do + st <- openDirectoryLog directoryLog + if runCLI + then directoryServiceCLI st opts + else directoryService st opts terminalChatConfig + where + migrate = \case + MLCheck -> checkDirectoryLog + MLImport -> importDirectoryLogToDB + MLExport -> exportDBToDirectoryLog diff --git a/apps/simplex-directory-service/src/Directory/Listing.hs b/apps/simplex-directory-service/src/Directory/Listing.hs index cef478c273..0d4e8d351c 100644 --- a/apps/simplex-directory-service/src/Directory/Listing.hs +++ b/apps/simplex-directory-service/src/Directory/Listing.hs @@ -11,7 +11,6 @@ module Directory.Listing where import Control.Applicative ((<|>)) -import Control.Concurrent.STM import Control.Monad import Crypto.Hash (Digest, MD5) import qualified Crypto.Hash as CH @@ -26,7 +25,6 @@ import qualified Data.ByteString.Lazy as LB import Data.Int (Int64) import Data.List (isPrefixOf) import Data.Maybe (catMaybes, fromMaybe) -import qualified Data.Set as S import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) @@ -90,10 +88,10 @@ recentRoundedTime roundTo now t let secs = (systemSeconds (utcToSystemTime t) `div` roundTo) * roundTo in Just $ systemToUTCTime $ MkSystemTime secs 0 -groupDirectoryEntry :: UTCTime -> GroupInfoSummary -> Maybe (DirectoryEntry, Maybe (FilePath, ImageFileData)) -groupDirectoryEntry now (GIS GroupInfo {groupProfile, chatTs, createdAt} summary gLink_) = +groupDirectoryEntry :: UTCTime -> GroupInfo -> Maybe GroupLink -> Maybe (DirectoryEntry, Maybe (FilePath, ImageFileData)) +groupDirectoryEntry now GroupInfo {groupProfile, chatTs, createdAt, groupSummary} gLink_ = let GroupProfile {displayName, shortDescr, description, image, memberAdmission} = groupProfile - entryType = DETGroup memberAdmission summary + entryType = DETGroup memberAdmission groupSummary entry groupLink = let de = DirectoryEntry @@ -122,22 +120,21 @@ groupDirectoryEntry now (GIS GroupInfo {groupProfile, chatTs, createdAt} summary Right img'' -> Just (imgFile, img'') Left _ -> Nothing -generateListing :: DirectoryStore -> FilePath -> [GroupInfoSummary] -> IO () -generateListing st dir gs = do +generateListing :: FilePath -> [(GroupInfo, GroupReg, Maybe GroupLink)] -> IO () +generateListing dir gs = do createDirectoryIfMissing True dir oldDirs <- filter ((directoryDataPath <> ".") `isPrefixOf`) <$> listDirectory dir ts <- getCurrentTime let newDirPath = directoryDataPath <> "." <> iso8601Show ts <> "/" newDir = dir newDirPath - gs' <- filterListedGroups st gs createDirectoryIfMissing True (newDir listingImageFolder) - gs'' <- - fmap catMaybes $ forM gs' $ \g@(GIS GroupInfo {groupId} _ _) -> - forM (groupDirectoryEntry ts g) $ \(g', img) -> do + gs' <- + fmap catMaybes $ forM gs $ \(g, gr, link_) -> + forM (groupDirectoryEntry ts g link_) $ \(g', img) -> do forM_ img $ \(imgFile, imgData) -> B.writeFile (newDir imgFile) imgData - pure (groupId, g') - saveListing newDir listingFileName gs'' - saveListing newDir promotedFileName =<< filterPromotedGroups st gs'' + pure (g', gr) + saveListing newDir listingFileName gs' + saveListing newDir promotedFileName $ filter (\(_, GroupReg {promoted}) -> promoted) gs' -- atomically update the link let newSymLink = newDir <> ".link" symLink = dir directoryDataPath @@ -145,12 +142,7 @@ generateListing st dir gs = do renamePath newSymLink symLink mapM_ (removePathForcibly . (dir )) oldDirs where - saveListing newDir f = LB.writeFile (newDir f) . J.encode . DirectoryListing . map snd - -filterPromotedGroups :: DirectoryStore -> [(GroupId, DirectoryEntry)] -> IO [(GroupId, DirectoryEntry)] -filterPromotedGroups st gs = do - pgs <- readTVarIO $ promotedGroups st - pure $ filter (\g -> fst g `S.member` pgs) gs + saveListing newDir f = LB.writeFile (newDir f) . J.encode . DirectoryListing . map fst toFormattedText :: Text -> MarkdownList toFormattedText t = fromMaybe [FormattedText Nothing t] $ parseMaybeMarkdownList t diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index e0052b3b1e..93a93ed61e 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -7,16 +7,20 @@ module Directory.Options ( DirectoryOpts (..), + MigrateLog (..), getDirectoryOpts, mkChatOpts, ) where +import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) import Options.Applicative import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller (updateStr, versionNumber, versionString) import Simplex.Chat.Options (ChatCmdLog (..), ChatOpts (..), CoreChatOpts, CreateBotOpts (..), coreChatOptsP) +import Simplex.Messaging.Parsers (parseAll) data DirectoryOpts = DirectoryOpts { coreOptions :: CoreChatOpts, @@ -30,6 +34,7 @@ data DirectoryOpts = DirectoryOpts profileNameLimit :: Int, captchaGenerator :: Maybe FilePath, directoryLog :: Maybe FilePath, + migrateDirectoryLog :: Maybe MigrateLog, serviceName :: T.Text, runCLI :: Bool, searchResults :: Int, @@ -37,6 +42,8 @@ data DirectoryOpts = DirectoryOpts testing :: Bool } +data MigrateLog = MLCheck | MLImport | MLExport + directoryOpts :: FilePath -> FilePath -> Parser DirectoryOpts directoryOpts appDir defaultDbName = do coreOptions <- coreChatOptsP appDir defaultDbName @@ -107,12 +114,20 @@ directoryOpts appDir defaultDbName = do <> help "Executable to generate captcha files, must accept text as parameter and save file to stdout as base64 up to 12500 bytes" ) directoryLog <- - Just - <$> strOption + optional $ + strOption ( long "directory-file" <> metavar "DIRECTORY_FILE" <> help "Append only log for directory state" ) + migrateDirectoryLog <- + optional $ + option + parseMigrateLog + ( long "migrate-directory-file" + <> metavar "MIGRATE_COMMAND" + <> help "Command to import/export directory log file" + ) serviceName <- strOption ( long "service-name" @@ -145,6 +160,7 @@ directoryOpts appDir defaultDbName = do profileNameLimit, captchaGenerator, directoryLog, + migrateDirectoryLog, serviceName = T.pack serviceName, runCLI, searchResults = 10, @@ -181,3 +197,13 @@ mkChatOpts DirectoryOpts {coreOptions, serviceName} = createBot = Just CreateBotOpts {botDisplayName = serviceName, allowFiles = False}, maintenance = False } + +parseMigrateLog :: ReadM MigrateLog +parseMigrateLog = eitherReader $ parseAll mlP . encodeUtf8 . T.pack + where + mlP = + A.takeTill (== ' ') >>= \case + "check" -> pure MLCheck + "import" -> pure MLImport + "export" -> pure MLExport + _ -> fail "bad MigrateLog" diff --git a/apps/simplex-directory-service/src/Directory/Search.hs b/apps/simplex-directory-service/src/Directory/Search.hs index 2d4cbf9c7b..d71c128370 100644 --- a/apps/simplex-directory-service/src/Directory/Search.hs +++ b/apps/simplex-directory-service/src/Directory/Search.hs @@ -1,12 +1,5 @@ -{-# LANGUAGE DuplicateRecordFields #-} -{-# LANGUAGE NamedFieldPuns #-} - module Directory.Search where -import Data.List (sortOn) -import Data.Ord (Down (..)) -import Data.Set (Set) -import qualified Data.Set as S import Data.Text (Text) import Data.Time.Clock (UTCTime) import Simplex.Chat.Types @@ -14,19 +7,7 @@ import Simplex.Chat.Types data SearchRequest = SearchRequest { searchType :: SearchType, searchTime :: UTCTime, - sentGroups :: Set GroupId + lastGroup :: GroupId -- cursor for search } data SearchType = STAll | STRecent | STSearch Text - -takeTop :: Int -> [GroupInfoSummary] -> [GroupInfoSummary] -takeTop n = take n . sortOn (\(GIS _ GroupSummary {currentMembers} _) -> Down currentMembers) - -takeRecent :: Int -> [GroupInfoSummary] -> [GroupInfoSummary] -takeRecent n = take n . sortOn (\(GIS GroupInfo {createdAt} _ _) -> Down createdAt) - -groupIds :: [GroupInfoSummary] -> Set GroupId -groupIds = S.fromList . map (\(GIS GroupInfo {groupId} _ _) -> groupId) - -filterNotSent :: Set GroupId -> [GroupInfoSummary] -> [GroupInfoSummary] -filterNotSent sentGroups = filter (\(GIS GroupInfo {groupId} _ _) -> groupId `S.notMember` sentGroups) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index b7f1bb3629..c8aeb9061a 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -8,15 +8,13 @@ {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TupleSections #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Directory.Service ( welcomeGetOpts, directoryService, directoryServiceCLI, - newServiceState, - directoryStartHook, - acceptMemberHook, ) where @@ -26,11 +24,11 @@ import Control.Logger.Simple import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class +import Data.Bifunctor (first) import Data.List (find, intercalate) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M -import Data.Maybe (fromMaybe, isJust, isNothing, maybeToList) -import Data.Set (Set) +import Data.Maybe (fromMaybe, isJust, isNothing) import qualified Data.Set as S import Data.Text (Text) import qualified Data.Text as T @@ -44,6 +42,7 @@ import Directory.Listing import Directory.Options import Directory.Search import Directory.Store +import Directory.Store.Migrate import Directory.Util import Simplex.Chat.Bot import Simplex.Chat.Bot.KnownContacts @@ -54,7 +53,7 @@ import Simplex.Chat.Messages import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..)) import Simplex.Chat.Store.Direct (getContact) -import Simplex.Chat.Store.Groups (getGroupInfo, getGroupLink, getGroupSummary, getUserGroupsWithSummary, setGroupCustomData) +import Simplex.Chat.Store.Groups (getGroupLink, getGroupMember, setGroupCustomData) -- TODO remove setGroupCustomData import Simplex.Chat.Store.Profiles (GroupLinkInfo (..), getGroupLinkInfo) import Simplex.Chat.Store.Shared (StoreError (..)) import Simplex.Chat.Terminal (terminalChatConfig) @@ -67,7 +66,7 @@ import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ConnectionLink (. import Simplex.Messaging.Encoding.String import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Util (raceAny_, safeDecodeUtf8, tshow, unlessM, whenM, ($>>=), (<$$>)) +import Simplex.Messaging.Util (eitherToMaybe, raceAny_, safeDecodeUtf8, tshow, unlessM, (<$$>)) import System.Directory (getAppUserDataDirectory) import System.Exit (exitFailure) import System.Process (readProcess) @@ -141,17 +140,23 @@ welcomeGetOpts = do knownContact KnownContact {contactId, localDisplayName = n} = knownName contactId n knownName i n = show i <> ":" <> T.unpack (viewName n) -directoryServiceCLI :: DirectoryStore -> DirectoryOpts -> IO () +directoryServiceCLI :: DirectoryLog -> DirectoryOpts -> IO () directoryServiceCLI st opts = do env <- newServiceState opts eventQ <- newTQueueIO let eventHook cc resp = atomically $ resp <$ writeTQueue eventQ (cc, resp) - chatHooks = defaultChatHooks {postStartHook = Just $ directoryStartHook opts env, eventHook = Just eventHook, acceptMember = Just $ acceptMemberHook opts env} + chatHooks = + defaultChatHooks + { preStartHook = Just $ directoryPreStartHook opts, + postStartHook = Just $ directoryPostStartHook opts env, + eventHook = Just eventHook, + acceptMember = Just $ acceptMemberHook opts env + } raceAny_ $ [ simplexChatCLI' terminalChatConfig {chatHooks} (mkChatOpts opts) Nothing, processEvents eventQ env ] - <> updateListingsThread_ st opts env + <> updateListingsThread_ opts env where processEvents eventQ env = forever $ do (cc, resp) <- atomically $ readTQueue eventQ @@ -159,24 +164,27 @@ directoryServiceCLI st opts = do forM_ u_ $ \user -> directoryServiceEvent st opts env user cc resp updateListingDelay :: Int -updateListingDelay = 15 * 60 * 1000000 -- update every 15 minutes +updateListingDelay = 5 * 60 * 1000000 -- update every 5 minutes -updateListingsThread_ :: DirectoryStore -> DirectoryOpts -> ServiceState -> [IO ()] -updateListingsThread_ st opts env = maybe [] (\f -> [updateListingsThread f]) $ webFolder opts +updateListingsThread_ :: DirectoryOpts -> ServiceState -> [IO ()] +updateListingsThread_ opts env = maybe [] (\f -> [updateListingsThread f]) $ webFolder opts where updateListingsThread f = do cc <- atomically $ takeTMVar $ updateListingsJob env forever $ do u <- readTVarIO $ currentUser cc - forM_ u $ \user -> updateGroupListingFiles cc st user f + forM_ u $ \user -> updateGroupListingFiles cc user f delay <- registerDelay updateListingDelay atomically $ void (takeTMVar $ updateListingsJob env) `orElse` unlessM (readTVar delay) retry listingsUpdated :: ServiceState -> ChatController -> IO () listingsUpdated env = void . atomically . tryPutTMVar (updateListingsJob env) -directoryStartHook :: DirectoryOpts -> ServiceState -> ChatController -> IO () -directoryStartHook opts env cc = +directoryPreStartHook :: DirectoryOpts -> ChatController -> IO () +directoryPreStartHook opts ChatController {config, chatStore} = runDirectoryMigrations opts config chatStore + +directoryPostStartHook :: DirectoryOpts -> ServiceState -> ChatController -> IO () +directoryPostStartHook opts env cc = readTVarIO (currentUser cc) >>= \case Nothing -> putStrLn "No current user" >> exitFailure Just User {userId, profile = p@LocalProfile {preferences}} -> do @@ -207,12 +215,13 @@ directoryCommands = where idParam = Just "" -directoryService :: DirectoryStore -> DirectoryOpts -> ChatConfig -> IO () +directoryService :: DirectoryLog -> DirectoryOpts -> ChatConfig -> IO () directoryService st opts@DirectoryOpts {testing} cfg = do env <- newServiceState opts let chatHooks = defaultChatHooks - { postStartHook = Just $ directoryStartHook opts env, + { preStartHook = Just $ directoryPreStartHook opts, + postStartHook = Just $ directoryPostStartHook opts env, acceptMember = Just $ acceptMemberHook opts env } simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \user cc -> do @@ -223,7 +232,7 @@ directoryService st opts@DirectoryOpts {testing} cfg = do (_, resp) <- atomically . readTBQueue $ outputQ cc directoryServiceEvent st opts env user cc resp ] - <> updateListingsThread_ st opts env + <> updateListingsThread_ opts env acceptMemberHook :: DirectoryOpts -> ServiceState -> GroupInfo -> GroupLinkInfo -> Profile -> IO (Either GroupRejectionReason (GroupAcceptance, GroupMemberRole)) acceptMemberHook @@ -248,7 +257,7 @@ acceptMemberHook when (hasBlockedWords blockedWordsCfg displayName) $ throwError GRRBlockedName groupMemberAcceptance :: GroupInfo -> DirectoryMemberAcceptance -groupMemberAcceptance GroupInfo {customData} = memberAcceptance $ fromCustomData customData +groupMemberAcceptance GroupInfo {customData} = (\DirectoryGroupData {memberAcceptance = ma} -> ma) $ fromCustomData customData useMemberFilter :: Maybe ImageData -> Maybe ProfileCondition -> Bool useMemberFilter img_ = \case @@ -266,7 +275,7 @@ readBlockedWordsConfig DirectoryOpts {blockedFragmentsFile, blockedWordsFile, na unless testing $ putStrLn $ "Blocked fragments: " <> show (length blockedFragments) <> ", blocked words: " <> show (length blockedWords) <> ", spelling rules: " <> show (M.size spelling) pure BlockedWordsConfig {blockedFragments, blockedWords, extensionRules, spelling} -directoryServiceEvent :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> Either ChatError ChatEvent -> IO () +directoryServiceEvent :: DirectoryLog -> DirectoryOpts -> ServiceState -> User -> ChatController -> Either ChatError ChatEvent -> IO () directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults} env@ServiceState {searchRequests} user@User {userId} cc event = forM_ (crDirectoryEvent event) $ \case DEContactConnected ct -> deContactConnected ct @@ -298,12 +307,16 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName forM_ adminUsers $ \KnownContact {contactId} -> action contactId withSuperUsers action = void . forkIO $ forM_ superUsers $ \KnownContact {contactId} -> action contactId notifyAdminUsers s = withAdminUsers $ \contactId -> sendMessage' cc contactId s - notifyOwner GroupReg {dbContactId} = sendMessage' cc dbContactId + notifyOwner = sendMessage' cc . dbContactId ctId `isOwner` GroupReg {dbContactId} = ctId == dbContactId - withGroupReg GroupInfo {groupId, localDisplayName} err action = do - getGroupReg st groupId >>= \case - Just gr -> action gr - Nothing -> logError $ "Error: " <> err <> ", group: " <> localDisplayName <> ", can't find group registration ID " <> tshow groupId + withGroupReg :: GroupInfo -> Text -> (GroupReg -> IO ()) -> IO () + withGroupReg GroupInfo {groupId, localDisplayName} err action = + getGroupReg cc groupId >>= \case + Right gr -> action gr + Left e -> do + let msg = "Error: " <> err <> ", group: " <> tshow groupId <> " " <> localDisplayName <> ", " <> T.pack e + notifyAdminUsers msg + logError msg groupInfoText p@GroupProfile {description = d} = groupNameDescr p <> maybe "" ("\nWelcome message:\n" <>) d groupNameDescr GroupProfile {displayName = n, fullName = fn, shortDescr = sd_} = n <> maybe "" (\d' -> " (" <> d' <> ")") descr @@ -318,46 +331,31 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName groupAlreadyListed GroupInfo {groupProfile = p} = "The group " <> groupNameDescr p <> " is already listed in the directory, please choose another name." - getGroups :: Text -> IO (Maybe [GroupInfoSummary]) - getGroups = getGroups_ . Just - - getGroups_ :: Maybe Text -> IO (Maybe [GroupInfoSummary]) - getGroups_ search_ = - sendChatCmd cc (APIListGroups userId Nothing $ T.unpack <$> search_) >>= \case - Right CRGroupsList {groups} -> pure $ Just groups - _ -> pure Nothing - - getDuplicateGroup :: GroupInfo -> IO (Maybe DuplicateGroup) - getDuplicateGroup GroupInfo {groupId, groupProfile = GroupProfile {displayName, fullName}} = - getGroups fullName >>= mapM duplicateGroup + getDuplicateGroup :: GroupInfo -> IO (Either String DuplicateGroup) + getDuplicateGroup GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = + duplicateGroup <$$> getDuplicateGroupRegs cc user displayName where - sameGroupNotRemoved (GIS g@GroupInfo {groupId = gId, groupProfile = GroupProfile {displayName = n, fullName = fn}} _ _) = - gId /= groupId && n == displayName && fn == fullName && not (memberRemoved $ membership g) - duplicateGroup [] = pure DGUnique - duplicateGroup groups = do - let gs = filter sameGroupNotRemoved groups - if null gs - then pure DGUnique - else do - (lgs, rgs) <- atomically $ (,) <$> readTVar (listedGroups st) <*> readTVar (reservedGroups st) - let reserved = any (\(GIS GroupInfo {groupId = gId} _ _) -> gId `S.member` lgs || gId `S.member` rgs) gs - if reserved - then pure DGReserved - else do - removed <- foldM (\r -> fmap (r &&) . isGroupRemoved) True gs - pure $ if removed then DGUnique else DGRegistered - isGroupRemoved (GIS GroupInfo {groupId = gId} _ _) = - getGroupReg st gId >>= \case - Just GroupReg {groupRegStatus} -> groupRemoved <$> readTVarIO groupRegStatus - Nothing -> pure True + duplicateGroup [] = DGUnique + duplicateGroup ((GroupInfo {groupId = gId, membership}, GroupReg {groupRegStatus = status}) : groups) + | gId == groupId || memberRemoved membership = duplicateGroup groups + | otherwise = case grDirectoryStatus status of + DSListed -> DGReserved + DSReserved -> DGReserved + DSRegistered -> case duplicateGroup groups of + DGReserved -> DGReserved + _ -> DGRegistered + DSRemoved -> duplicateGroup groups - processInvitation :: Contact -> GroupInfo -> IO () - processInvitation ct g@GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = do - void $ addGroupReg st ct g GRSProposed - r <- sendChatCmd cc $ APIJoinGroup groupId MFNone - sendMessage cc ct $ case r of - Right CRUserAcceptedGroupSent {} -> "Joining the group " <> displayName <> "…" - _ -> "Error joining group " <> displayName <> ", please re-send the invitation!" + processInvitation :: Contact -> GroupInfo -> Maybe GroupReg -> IO () + processInvitation ct g@GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = \case + Nothing -> addGroupReg notifyAdminUsers st cc ct g GRSProposed joinGroup + Just _gr -> setGroupStatus notifyAdminUsers st env cc groupId GRSProposed joinGroup + where + joinGroup _ = do + r <- sendChatCmd cc $ APIJoinGroup groupId MFNone + sendMessage cc ct $ case r of + Right CRUserAcceptedGroupSent {} -> "Joining the group " <> displayName <> "…" + _ -> "Error joining group " <> displayName <> ", please re-send the invitation!" deContactConnected :: Contact -> IO () deContactConnected ct = when (contactDirect ct) $ do @@ -376,15 +374,15 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName Just msg -> sendMessage cc ct msg Nothing -> getDuplicateGroup g >>= \case - Just DGUnique -> processInvitation ct g - Just DGRegistered -> askConfirmation - Just DGReserved -> sendMessage cc ct $ groupAlreadyListed g - Nothing -> sendMessage cc ct "Error: getDuplicateGroup. Please notify the developers." + Right DGUnique -> processInvitation ct g Nothing + Right DGRegistered -> askConfirmation + Right DGReserved -> sendMessage cc ct $ groupAlreadyListed g + Left e -> sendMessage cc ct $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e where - askConfirmation = do - ugrId <- addGroupReg st ct g GRSPendingConfirmation - sendMessage cc ct $ "The group " <> groupNameDescr p <> " is already submitted to the directory.\nTo confirm the registration, please send:" - sendMessage cc ct $ "/confirm " <> tshow ugrId <> ":" <> viewName displayName + askConfirmation = + addGroupReg notifyAdminUsers st cc ct g GRSPendingConfirmation $ \GroupReg {userGroupRegId} -> do + sendMessage cc ct $ "The group " <> groupNameDescr p <> " is already submitted to the directory.\nTo confirm the registration, please send:" + sendMessage cc ct $ "/confirm " <> tshow userGroupRegId <> ":" <> viewName displayName badRolesMsg :: GroupRolesStatus -> Maybe Text badRolesMsg = \case @@ -393,9 +391,9 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName GRSContactNotOwner -> Just "You must have a group *owner* role to register the group" GRSBadRoles -> Just "You must have a group *owner* role and you must grant directory service *admin* role to register the group" - getGroupRolesStatus :: GroupInfo -> GroupReg -> IO (Maybe GroupRolesStatus) - getGroupRolesStatus GroupInfo {membership = GroupMember {memberRole = serviceRole}} gr = - rStatus <$$> getGroupMember gr + getGroupRolesStatus :: GroupInfo -> GroupReg -> IO (Either String GroupRolesStatus) + getGroupRolesStatus GroupInfo {groupId, membership = GroupMember {memberRole = serviceRole}} gr = + rStatus <$$> getOwnerGroupMember groupId gr where rStatus GroupMember {memberRole} = groupRolesStatus memberRole serviceRole @@ -406,50 +404,52 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName (GROwner, _) -> GRSServiceNotAdmin _ -> GRSBadRoles - getGroupMember :: GroupReg -> IO (Maybe GroupMember) - getGroupMember GroupReg {dbGroupId, dbOwnerMemberId} = - readTVarIO dbOwnerMemberId - $>>= \mId -> resp <$> sendChatCmd cc (APIGroupMemberInfo dbGroupId mId) - where - resp = \case - Right CRGroupMemberInfo {member} -> Just member - _ -> Nothing + getOwnerGroupMember :: GroupId -> GroupReg -> IO (Either String GroupMember) + getOwnerGroupMember gId GroupReg {dbOwnerMemberId} = case dbOwnerMemberId of + Just mId -> withDB "getGroupMember" cc $ \db -> withExceptT show $ getGroupMember db (vr cc) user gId mId + Nothing -> pure $ Left "no owner member in group registration" deServiceJoinedGroup :: ContactId -> GroupInfo -> GroupMember -> IO () - deServiceJoinedGroup ctId g owner = do + deServiceJoinedGroup ctId g@GroupInfo {groupId} owner = do logInfo $ "service joined group " <> viewGroupName g withGroupReg g "joined group" $ \gr -> when (ctId `isOwner` gr) $ do - setGroupRegOwner st gr owner - let GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = g - notifyOwner gr $ "Joined the group " <> displayName <> ", creating the link…" - sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case - Right CRGroupLinkCreated {groupLink = GroupLink {connLinkContact = gLink}} -> do - setGroupStatus st env cc gr GRSPendingUpdate - notifyOwner - gr - "Created the public link to join the group via this directory service that is always online.\n\n\ - \Please add it to the group welcome message.\n\ - \For example, add:" - notifyOwner gr $ "Link to join the group " <> displayName <> ": " <> groupLinkText gLink - Left (ChatError e) -> case e of - CEGroupUserRole {} -> notifyOwner gr "Failed creating group link, as service is no longer an admin." - CEGroupMemberUserRemoved -> notifyOwner gr "Failed creating group link, as service is removed from the group." - CEGroupNotJoined _ -> notifyOwner gr $ unexpectedError "group not joined" - CEGroupMemberNotActive -> notifyOwner gr $ unexpectedError "service membership is not active" - _ -> notifyOwner gr $ unexpectedError "can't create group link" - _ -> notifyOwner gr $ unexpectedError "can't create group link" + let GroupInfo {groupProfile = GroupProfile {displayName}} = g + setGroupRegOwner cc groupId owner >>= \case + Left e -> do + let msg = "Error updating group " <> tshow groupId <> " owner: " <> T.pack e + logError msg + notifyOwner gr msg + Right () -> do + logGUpdateOwner st groupId $ groupMemberId' owner + notifyOwner gr $ "Joined the group " <> displayName <> ", creating the link…" + sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case + Right CRGroupLinkCreated {groupLink = GroupLink {connLinkContact = gLink}} -> + setGroupStatus notifyAdminUsers st env cc groupId GRSPendingUpdate $ \gr' -> do + notifyOwner + gr' + "Created the public link to join the group via this directory service that is always online.\n\n\ + \Please add it to the group welcome message.\n\ + \For example, add:" + notifyOwner gr' $ "Link to join the group " <> displayName <> ": " <> groupLinkText gLink + Left (ChatError e) -> case e of + CEGroupUserRole {} -> notifyOwner gr "Failed creating group link, as service is no longer an admin." + CEGroupMemberUserRemoved -> notifyOwner gr "Failed creating group link, as service is removed from the group." + CEGroupNotJoined _ -> notifyOwner gr $ unexpectedError "group not joined" + CEGroupMemberNotActive -> notifyOwner gr $ unexpectedError "service membership is not active" + _ -> notifyOwner gr $ unexpectedError "can't create group link" + _ -> notifyOwner gr $ unexpectedError "can't create group link" deGroupUpdated :: GroupMember -> GroupInfo -> GroupInfo -> IO () deGroupUpdated m@GroupMember {memberProfile = LocalProfile {displayName = mName}} fromGroup toGroup = do logInfo $ "group updated " <> viewGroupName toGroup unless (sameProfile p p') $ do - withGroupReg toGroup "group updated" $ \gr -> do + withGroupReg toGroup "group updated" $ \gr@GroupReg {groupRegStatus} -> do let userGroupRef = userGroupReference gr toGroup byMember = case memberContactId m of Just ctId | ctId `isOwner` gr -> "" -- group registration owner, not any group owner. _ -> " by " <> mName -- owner notification from directory will include the name. - readTVarIO (groupRegStatus gr) >>= \case + case groupRegStatus of GRSPendingConfirmation -> pure () GRSProposed -> pure () GRSPendingUpdate -> @@ -480,35 +480,32 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName GroupProfile {displayName = n, fullName = fn, shortDescr = sd, image = i, description = d} GroupProfile {displayName = n', fullName = fn', shortDescr = sd', image = i', description = d'} = n == n' && fn == fn' && i == i' && sd == sd' && (T.words <$> d) == (T.words <$> d') - groupLinkAdded gr byMember = do + groupLinkAdded gr byMember = getDuplicateGroup toGroup >>= \case - Nothing -> notifyOwner gr "Error: getDuplicateGroup. Please notify the developers." - Just DGReserved -> notifyOwner gr $ groupAlreadyListed toGroup - _ -> do - let gaId = 1 - setGroupStatus st env cc gr $ GRSPendingApproval gaId - notifyOwner gr $ - ("Thank you! The group link for " <> userGroupReference gr toGroup <> " is added to the welcome message" <> byMember) + Left e -> notifyOwner gr $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e + Right DGReserved -> notifyOwner gr $ groupAlreadyListed toGroup + _ -> setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval gaId) $ \gr' -> do + notifyOwner gr' $ + ("Thank you! The group link for " <> userGroupReference gr' toGroup <> " is added to the welcome message" <> byMember) <> ".\nYou will be notified once the group is added to the directory - it may take up to 48 hours." - checkRolesSendToApprove gr gaId + checkRolesSendToApprove gr' gaId + where + gaId = 1 processProfileChange gr byMember isActive n' = do let userGroupRef = userGroupReference gr toGroup groupRef = groupReference toGroup groupProfileUpdate >>= \case - GPNoServiceLink -> do - setGroupStatus st env cc gr GRSPendingUpdate - notifyOwner gr $ + GPNoServiceLink -> setGroupStatus notifyAdminUsers st env cc groupId GRSPendingUpdate $ \gr' -> do + notifyOwner gr' $ ("The group profile is updated for " <> userGroupRef <> byMember <> ", but no link is added to the welcome message.\n\n") <> "The group will remain hidden from the directory until the group link is added and the group is re-approved." - GPServiceLinkRemoved -> do - setGroupStatus st env cc gr GRSPendingUpdate - notifyOwner gr $ + GPServiceLinkRemoved -> setGroupStatus notifyAdminUsers st env cc groupId GRSPendingUpdate $ \gr' -> do + notifyOwner gr' $ ("The group link for " <> userGroupRef <> " is removed from the welcome message" <> byMember) <> ".\n\nThe group is hidden from the directory until the group link is added and the group is re-approved." notifyAdminUsers $ "The group link is removed from " <> groupRef <> ", de-listed." - GPServiceLinkAdded _ -> do - setGroupStatus st env cc gr $ GRSPendingApproval n' - notifyOwner gr $ + GPServiceLinkAdded _ -> setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n') $ \gr' -> do + notifyOwner gr' $ ("The group link is added to " <> userGroupRef <> byMember) <> "!\nIt is hidden from the directory until approved." notifyAdminUsers $ "The group link is added to " <> groupRef <> byMember <> "." @@ -519,13 +516,12 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName ("The group " <> userGroupRef <> " is updated" <> byMember) <> "!\nThe group is listed in directory." notifyAdminUsers $ "The group " <> groupRef <> " is updated" <> byMember <> " - only link or whitespace changes.\nThe group remained listed in directory." - | otherwise -> do - setGroupStatus st env cc gr $ GRSPendingApproval n' - notifyOwner gr $ + | otherwise -> setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n') $ \gr' -> do + notifyOwner gr' $ ("The group " <> userGroupRef <> " is updated" <> byMember) <> "!\nIt is hidden from the directory until approved." notifyAdminUsers $ "The group " <> groupRef <> " is updated" <> byMember <> "." - checkRolesSendToApprove gr n' + checkRolesSendToApprove gr' n' where onlyLinkChanged GroupProfile {displayName = dn, fullName = fn, shortDescr = sd, image = i, description = d} @@ -553,9 +549,9 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName _ -> GPServiceLinkError checkRolesSendToApprove gr gaId = do (badRolesMsg <$$> getGroupRolesStatus toGroup gr) >>= \case - Nothing -> notifyOwner gr "Error: getGroupRolesStatus. Please notify the developers." - Just (Just msg) -> notifyOwner gr msg - Just Nothing -> sendToApprove toGroup gr gaId + Left e -> notifyOwner gr $ "Error: getGroupRolesStatus. Please notify the developers.\n" <> T.pack e + Right (Just msg) -> notifyOwner gr msg + Right Nothing -> sendToApprove toGroup gr gaId dePendingMember :: GroupInfo -> GroupMember -> IO () dePendingMember g@GroupInfo {groupProfile = GroupProfile {displayName}} m @@ -588,7 +584,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName approvePendingMember :: DirectoryMemberAcceptance -> GroupInfo -> GroupMember -> IO () approvePendingMember a g@GroupInfo {groupId} m@GroupMember {memberProfile = LocalProfile {displayName, image}} = do - gli_ <- join <$> withDB' "getGroupLinkInfo" cc (\db -> getGroupLinkInfo db userId groupId) + gli_ <- join . eitherToMaybe <$> withDB' "getGroupLinkInfo" cc (\db -> getGroupLinkInfo db userId groupId) let role = if useMemberFilter image (makeObserver a) then GRObserver else maybe GRMember (\GroupLinkInfo {memberRole} -> memberRole) gli_ gmId = groupMemberId' m sendChatCmd cc (APIAcceptMember groupId gmId role) >>= \case @@ -635,39 +631,36 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName useMemberFilter image $ passCaptcha a sendToApprove :: GroupInfo -> GroupReg -> GroupApprovalId -> IO () - sendToApprove GroupInfo {groupProfile = p@GroupProfile {displayName, image = image'}} GroupReg {dbGroupId, dbContactId, promoted} gaId = do - -- TODO account for promotion + sendToApprove GroupInfo {groupId, groupProfile = p@GroupProfile {displayName, image = image'}, groupSummary} GroupReg {dbContactId, promoted} gaId = do ct_ <- getContact' cc user dbContactId - gr_ <- getGroupAndSummary cc user dbGroupId - let membersStr = maybe "" (\(_, s) -> "_" <> tshow (currentMembers s) <> " members_\n") gr_ + let membersStr = "_" <> tshow (currentMembers groupSummary) <> " members_\n" text = - maybe ("The group ID " <> tshow dbGroupId <> " submitted: ") (\c -> localDisplayName' c <> " submitted the group ID " <> tshow dbGroupId <> ": ") ct_ + either (\_ -> "The group ID " <> tshow groupId <> " submitted: ") (\c -> localDisplayName' c <> " submitted the group ID " <> tshow groupId <> ": ") ct_ <> ("\n" <> groupInfoText p <> "\n" <> membersStr <> "\nTo approve send:") msg = maybe (MCText text) (\image -> MCImage {text, image}) image' - promote <- readTVarIO promoted withAdminUsers $ \cId -> do sendComposedMessage' cc cId Nothing msg - sendMessage' cc cId $ "/approve " <> tshow dbGroupId <> ":" <> viewName displayName <> " " <> tshow gaId <> if promote then " promote=on" else "" + sendMessage' cc cId $ "/approve " <> tshow groupId <> ":" <> viewName displayName <> " " <> tshow gaId <> if promoted then " promote=on" else "" deContactRoleChanged :: GroupInfo -> ContactId -> GroupMemberRole -> IO () - deContactRoleChanged g@GroupInfo {membership = GroupMember {memberRole = serviceRole}} ctId contactRole = do + deContactRoleChanged g@GroupInfo {groupId, membership = GroupMember {memberRole = serviceRole}} ctId contactRole = do logInfo $ "contact ID " <> tshow ctId <> " role changed in group " <> viewGroupName g <> " to " <> tshow contactRole - withGroupReg g "contact role changed" $ \gr -> do + withGroupReg g "contact role changed" $ \gr@GroupReg {groupRegStatus} -> do let userGroupRef = userGroupReference gr g uCtRole = "Your role in the group " <> userGroupRef <> " is changed to " <> ctRole - when (ctId `isOwner` gr) $ do - readTVarIO (groupRegStatus gr) >>= \case - GRSSuspendedBadRoles -> when (rStatus == GRSOk) $ do - setGroupStatus st env cc gr GRSActive - notifyOwner gr $ uCtRole <> ".\n\nThe group is listed in the directory again." - notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suCtRole - GRSPendingApproval gaId -> when (rStatus == GRSOk) $ do + when (ctId `isOwner` gr) $ + case groupRegStatus of + GRSSuspendedBadRoles | rStatus == GRSOk -> + setGroupStatus notifyAdminUsers st env cc groupId GRSActive $ \gr' -> do + notifyOwner gr' $ uCtRole <> ".\n\nThe group is listed in the directory again." + notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suCtRole + GRSPendingApproval gaId | rStatus == GRSOk -> do sendToApprove g gr gaId notifyOwner gr $ uCtRole <> ".\n\nThe group is submitted for approval." - GRSActive -> when (rStatus /= GRSOk) $ do - setGroupStatus st env cc gr GRSSuspendedBadRoles - notifyOwner gr $ uCtRole <> ".\n\nThe group is no longer listed in the directory." - notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suCtRole + GRSActive | rStatus /= GRSOk -> + setGroupStatus notifyAdminUsers st env cc groupId GRSSuspendedBadRoles $ \gr' -> do + notifyOwner gr' $ uCtRole <> ".\n\nThe group is no longer listed in the directory." + notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suCtRole _ -> pure () where rStatus = groupRolesStatus contactRole serviceRole @@ -676,65 +669,64 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName suCtRole = "(user role is set to " <> ctRole <> ")." deServiceRoleChanged :: GroupInfo -> GroupMemberRole -> IO () - deServiceRoleChanged g serviceRole = do + deServiceRoleChanged g@GroupInfo {groupId} serviceRole = do logInfo $ "service role changed in group " <> viewGroupName g <> " to " <> tshow serviceRole - withGroupReg g "service role changed" $ \gr -> do + withGroupReg g "service role changed" $ \gr@GroupReg {groupRegStatus} -> do let userGroupRef = userGroupReference gr g uSrvRole = serviceName <> " role in the group " <> userGroupRef <> " is changed to " <> srvRole - readTVarIO (groupRegStatus gr) >>= \case - GRSSuspendedBadRoles -> when (serviceRole == GRAdmin) $ - whenContactIsOwner gr $ do - setGroupStatus st env cc gr GRSActive - notifyOwner gr $ uSrvRole <> ".\n\nThe group is listed in the directory again." - notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suSrvRole - GRSPendingApproval gaId -> when (serviceRole == GRAdmin) $ + case groupRegStatus of + GRSSuspendedBadRoles | serviceRole == GRAdmin -> + whenContactIsOwner gr $ + setGroupStatus notifyAdminUsers st env cc groupId GRSActive $ \gr' -> do + notifyOwner gr' $ uSrvRole <> ".\n\nThe group is listed in the directory again." + notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suSrvRole + GRSPendingApproval gaId | serviceRole == GRAdmin -> whenContactIsOwner gr $ do sendToApprove g gr gaId notifyOwner gr $ uSrvRole <> ".\n\nThe group is submitted for approval." - GRSActive -> when (serviceRole /= GRAdmin) $ do - setGroupStatus st env cc gr GRSSuspendedBadRoles - notifyOwner gr $ uSrvRole <> ".\n\nThe group is no longer listed in the directory." - notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suSrvRole + GRSActive | serviceRole /= GRAdmin -> + setGroupStatus notifyAdminUsers st env cc groupId GRSSuspendedBadRoles $ \gr' -> do + notifyOwner gr' $ uSrvRole <> ".\n\nThe group is no longer listed in the directory." + notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suSrvRole _ -> pure () where groupRef = groupReference g srvRole = "*" <> strEncodeTxt serviceRole <> "*" suSrvRole = "(" <> serviceName <> " role is changed to " <> srvRole <> ")." whenContactIsOwner gr action = - getGroupMember gr + getOwnerGroupMember groupId gr >>= mapM_ (\cm@GroupMember {memberRole} -> when (memberRole == GROwner && memberActive cm) action) deContactRemovedFromGroup :: ContactId -> GroupInfo -> IO () - deContactRemovedFromGroup ctId g = do + deContactRemovedFromGroup ctId g@GroupInfo {groupId} = do logInfo $ "contact ID " <> tshow ctId <> " removed from group " <> viewGroupName g withGroupReg g "contact removed" $ \gr -> do - when (ctId `isOwner` gr) $ do - setGroupStatus st env cc gr GRSRemoved - notifyOwner gr $ "You are removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." - notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group owner is removed)." + when (ctId `isOwner` gr) $ + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr' -> do + notifyOwner gr' $ "You are removed from the group " <> userGroupReference gr' g <> ".\n\nThe group is no longer listed in the directory." + notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group owner is removed)." deContactLeftGroup :: ContactId -> GroupInfo -> IO () - deContactLeftGroup ctId g = do + deContactLeftGroup ctId g@GroupInfo {groupId} = do logInfo $ "contact ID " <> tshow ctId <> " left group " <> viewGroupName g - withGroupReg g "contact left" $ \gr -> do - when (ctId `isOwner` gr) $ do - setGroupStatus st env cc gr GRSRemoved - notifyOwner gr $ "You left the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." - notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group owner left)." + -- TODO combine + withGroupReg g "contact left" $ \gr -> + when (ctId `isOwner` gr) $ + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr' -> do + notifyOwner gr' $ "You left the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." + notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group owner left)." deServiceRemovedFromGroup :: GroupInfo -> IO () - deServiceRemovedFromGroup g = do + deServiceRemovedFromGroup g@GroupInfo {groupId} = do logInfo $ "service removed from group " <> viewGroupName g - withGroupReg g "service removed" $ \gr -> do - setGroupStatus st env cc gr GRSRemoved + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr -> do notifyOwner gr $ serviceName <> " is removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (directory service is removed)." deGroupDeleted :: GroupInfo -> IO () - deGroupDeleted g = do + deGroupDeleted g@GroupInfo {groupId} = do logInfo $ "group removed " <> viewGroupName g - withGroupReg g "group removed" $ \gr -> do - setGroupStatus st env cc gr GRSRemoved + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr -> do notifyOwner gr $ "The group " <> userGroupReference gr g <> " is deleted.\n\nThe group is no longer listed in the directory." notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group is deleted)." @@ -759,57 +751,59 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName \`/link ` - view and upgrade group link.\n\ \`/delete :` - remove the group you submitted from directory, with _ID_ and _name_ as shown by /list command.\n\n\ \To search for groups, send the search text." - DCSearchGroup s -> withFoundListedGroups (Just s) $ sendSearchResults s + DCSearchGroup s -> + sendFoundListedGroups (STSearch s) Nothing "No groups found" $ \gs n -> -- $ sendSearchResults s + let more = if n > length gs then ", sending top " <> tshow (length gs) else "" + in "Found " <> tshow n <> " group(s)" <> more <> "." DCSearchNext -> atomically (TM.lookup (contactId' ct) searchRequests) >>= \case - Just search@SearchRequest {searchType, searchTime} -> do + Just SearchRequest {searchType, searchTime, lastGroup} -> do currentTime <- getCurrentTime if diffUTCTime currentTime searchTime > 300 -- 5 minutes then do atomically $ TM.delete (contactId' ct) searchRequests showAllGroups - else case searchType of - STSearch s -> withFoundListedGroups (Just s) $ sendNextSearchResults takeTop search - STAll -> withFoundListedGroups Nothing $ sendNextSearchResults takeTop search - STRecent -> withFoundListedGroups Nothing $ sendNextSearchResults takeRecent search + else + sendFoundListedGroups searchType (Just lastGroup) "No more groups" $ \gs _ -> + "Sending " <> tshow (length gs) <> " more group(s)." Nothing -> showAllGroups where showAllGroups = deUserCommand ct ciId DCAllGroups - DCAllGroups -> withFoundListedGroups Nothing $ sendAllGroups takeTop "top" STAll - DCRecentGroups -> withFoundListedGroups Nothing $ sendAllGroups takeRecent "the most recent" STRecent + DCAllGroups -> sendFoundListedGroups STAll Nothing "No groups listed" $ allGroupsReply "top" + DCRecentGroups -> sendFoundListedGroups STRecent Nothing "No groups listed" $ allGroupsReply "the most recent" DCSubmitGroup _link -> pure () DCConfirmDuplicateGroup ugrId gName -> - withUserGroupReg ugrId gName $ \g@GroupInfo {groupProfile = GroupProfile {displayName}} gr -> - readTVarIO (groupRegStatus gr) >>= \case - GRSPendingConfirmation -> - getDuplicateGroup g >>= \case - Nothing -> sendMessage cc ct "Error: getDuplicateGroup. Please notify the developers." - Just DGReserved -> sendMessage cc ct $ groupAlreadyListed g - _ -> processInvitation ct g - _ -> sendReply $ "Error: the group ID " <> tshow ugrId <> " (" <> displayName <> ") is not pending confirmation." + withUserGroupReg ugrId gName $ \g@GroupInfo {groupProfile = GroupProfile {displayName}} gr@GroupReg {groupRegStatus} -> case groupRegStatus of + GRSPendingConfirmation -> + getDuplicateGroup g >>= \case + Left e -> sendMessage cc ct $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e + Right DGReserved -> sendMessage cc ct $ groupAlreadyListed g + _ -> processInvitation ct g $ Just gr + _ -> sendReply $ "Error: the group ID " <> tshow ugrId <> " (" <> displayName <> ") is not pending confirmation." DCListUserGroups -> - getUserGroupRegs st (contactId' ct) >>= \grs -> do - sendReply $ tshow (length grs) <> " registered group(s)" - void . forkIO $ forM_ (reverse grs) $ \gr@GroupReg {dbGroupId, userGroupRegId} -> - let useGroupId = if isAdmin then dbGroupId else userGroupRegId - in sendGroupInfo ct gr useGroupId Nothing + getUserGroupRegs cc user (contactId' ct) >>= \case + Left e -> sendReply $ "Error reading groups: " <> T.pack e + Right gs -> sendGroupsInfo ct ciId isAdmin (gs, length gs) DCDeleteGroup gId gName -> - (if isAdmin then withGroupAndReg sendReply else withUserGroupReg) gId gName $ \GroupInfo {groupProfile = GroupProfile {displayName}} gr -> do - delGroupReg st gr - sendReply $ (if isAdmin then "The group " else "Your group ") <> displayName <> " is deleted from the directory" + (if isAdmin then withGroupAndReg sendReply else withUserGroupReg) gId gName $ \GroupInfo {groupProfile = GroupProfile {displayName}} GroupReg {dbGroupId} -> do + delGroupReg cc dbGroupId >>= \case + Right () -> do + logGDelete st dbGroupId + sendReply $ (if isAdmin then "The group " else "Your group ") <> displayName <> " is deleted from the directory" + Left e -> sendReply $ "Error deleting group " <> displayName <> ": " <> T.pack e DCMemberRole gId gName_ mRole_ -> (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g _gr -> do let GroupInfo {groupProfile = GroupProfile {displayName = n}} = g case mRole_ of Nothing -> getGroupLink' cc user g >>= \case - Just GroupLink {connLinkContact = gLink, acceptMemberRole} -> do + Right GroupLink {connLinkContact = gLink, acceptMemberRole} -> do let anotherRole = case acceptMemberRole of GRObserver -> GRMember; _ -> GRObserver sendReply $ initialRole n acceptMemberRole <> ("Send /'role " <> tshow gId <> " " <> strEncodeTxt anotherRole <> "' to change it.\n\n") <> onlyViaLink gLink - Nothing -> sendReply $ "Error: failed reading the initial member role for the group " <> n + Left _ -> sendReply $ "Error: failed reading the initial member role for the group " <> n Just mRole -> do setGroupLinkRole cc g mRole >>= \case Just gLink -> sendReply $ initialRole n mRole <> "\n" <> onlyViaLink gLink @@ -825,8 +819,8 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName Just a' | a /= a' -> do let d = toCustomData $ DirectoryGroupData a' withDB' "setGroupCustomData" cc (\db -> setGroupCustomData db user g $ Just d) >>= \case - Just () -> sendSettigns n a' " set to" - Nothing -> sendReply $ "Error changing spam filter settings for group " <> n + Right () -> sendSettigns n a' " set to" + Left e -> sendReply $ "Error changing spam filter settings for group " <> n <> ": " <> T.pack e _ -> sendSettigns n a "" where sendSettigns n a setTo = @@ -903,59 +897,37 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName isAdmin = knownCt `elem` adminUsers || knownCt `elem` superUsers withUserGroupReg ugrId = withUserGroupReg_ ugrId . Just withUserGroupReg_ ugrId gName_ action = - getUserGroupReg st (contactId' ct) ugrId >>= \case - Nothing -> sendReply $ "Group ID " <> tshow ugrId <> " not found" - Just gr@GroupReg {dbGroupId} -> do - getGroup cc user dbGroupId >>= \case - Nothing -> sendReply $ "Group ID " <> tshow ugrId <> " not found" - Just g@GroupInfo {groupProfile = GroupProfile {displayName}} - | maybe True (displayName ==) gName_ -> action g gr - | otherwise -> sendReply $ "Group ID " <> tshow ugrId <> " has the display name " <> displayName + getUserGroupReg cc user (contactId' ct) ugrId >>= \case + -- TODO differentiate group not found error + Left e -> sendReply $ "Group ID " <> tshow ugrId <> " error:" <> T.pack e + Right (g@GroupInfo {groupProfile = GroupProfile {displayName}}, gr) + | maybe True (displayName ==) gName_ -> action g gr + | otherwise -> sendReply $ "Group ID " <> tshow ugrId <> " has the display name " <> displayName sendReply = mkSendReply ct ciId - withFoundListedGroups s_ action = - getGroups_ s_ >>= \case - Just groups -> filterListedGroups st groups >>= action - Nothing -> sendReply "Error: getGroups. Please notify the developers." - sendSearchResults s = \case - [] -> sendReply "No groups found" - gs -> do - let gs' = takeTop searchResults gs - moreGroups = length gs - length gs' - more = if moreGroups > 0 then ", sending top " <> tshow (length gs') else "" - reply = "Found " <> tshow (length gs) <> " group(s)" <> more <> "." - updateSearchRequest (STSearch s) $ groupIds gs' - sendFoundGroups reply gs' moreGroups - sendAllGroups takeFirst sortName searchType = \case - [] -> sendReply "No groups listed" - gs -> do - let gs' = takeFirst searchResults gs - moreGroups = length gs - length gs' - more = if moreGroups > 0 then ", sending " <> sortName <> " " <> tshow (length gs') else "" - reply = tshow (length gs) <> " group(s) listed" <> more <> "." - updateSearchRequest searchType $ groupIds gs' - sendFoundGroups reply gs' moreGroups - sendNextSearchResults takeFirst SearchRequest {searchType, sentGroups} = \case - [] -> do - sendReply "Sorry, no more groups" - atomically $ TM.delete (contactId' ct) searchRequests - gs -> do - let gs' = takeFirst searchResults $ filterNotSent sentGroups gs - sentGroups' = sentGroups <> groupIds gs' - moreGroups = length gs - S.size sentGroups' - reply = "Sending " <> tshow (length gs') <> " more group(s)." - updateSearchRequest searchType sentGroups' - sendFoundGroups reply gs' moreGroups - updateSearchRequest :: SearchType -> Set GroupId -> IO () - updateSearchRequest searchType sentGroups = do + sendFoundListedGroups searchType lastGroup_ notFound replyStr = + searchListedGroups cc user searchType lastGroup_ searchResults >>= \case + Right ([], _) -> do + atomically $ TM.delete (contactId' ct) searchRequests + sendReply notFound + Right (gs, n) -> do + let moreGroups = n - length gs + updateSearchRequest searchType $ last gs + sendFoundGroups (replyStr gs n) gs moreGroups + Left e -> sendReply $ "Error: searchListedGroups. Please notify the developers.\n" <> T.pack e + allGroupsReply sortName gs n = + let more = if n > length gs then ", sending " <> sortName <> " " <> tshow (length gs) else "" + in tshow n <> " group(s) listed" <> more <> "." + updateSearchRequest :: SearchType -> (GroupInfo, GroupReg) -> IO () + updateSearchRequest searchType (GroupInfo {groupId}, _) = do searchTime <- getCurrentTime - let search = SearchRequest {searchType, searchTime, sentGroups} + let search = SearchRequest {searchType, searchTime, lastGroup = groupId} atomically $ TM.insert (contactId' ct) search searchRequests sendFoundGroups reply gs moreGroups = void . forkIO $ sendComposedMessages_ cc (SRDirect $ contactId' ct) msgs where msgs = replyMsg :| map foundGroup gs <> [moreMsg | moreGroups > 0] replyMsg = (Just ciId, MCText reply) - foundGroup (GIS GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_}} GroupSummary {currentMembers} _) = + foundGroup (GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_}, groupSummary = GroupSummary {currentMembers}}, _) = let membersStr = "_" <> tshow currentMembers <> " members_" showId = if isAdmin then tshow groupId <> ". " else "" text = showId <> groupInfoText p <> "\n" <> membersStr @@ -967,26 +939,18 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName | knownCt `elem` adminUsers || knownCt `elem` superUsers = case cmd of DCApproveGroup {groupId, displayName = n, groupApprovalId, promote} -> withGroupAndReg sendReply groupId n $ \g gr@GroupReg {userGroupRegId = ugrId, promoted} -> - readTVarIO (groupRegStatus gr) >>= \case + case groupRegStatus gr of GRSPendingApproval gaId - | gaId == groupApprovalId -> do + | gaId == groupApprovalId -> getDuplicateGroup g >>= \case - Nothing -> sendReply "Error: getDuplicateGroup. Please notify the developers." - Just DGReserved -> sendReply $ "The group " <> groupRef <> " is already listed in the directory." - _ -> do - getGroupRolesStatus g gr >>= \case - Just GRSOk -> do - setGroupStatus st env cc gr GRSActive - forM_ promote $ \promo -> - if promo -- admins can unpromote, only super-user can promote when approving - then - unlessM (readTVarIO promoted) $ - if knownCt `elem` superUsers - then setGroupPromoted st env cc gr True - else sendReply "You cannot promote groups" - else do - whenM (readTVarIO promoted) $ setGroupPromoted st env cc gr False - notifyOtherSuperUsers $ "Group promotion is disabled for " <> groupRef + Left e -> sendReply $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e + Right DGReserved -> sendReply $ "The group " <> groupRef <> " is already listed in the directory." + _ -> getGroupRolesStatus g gr >>= \case + Right GRSOk -> do + let grPromoted' + | promoted || knownCt `elem` superUsers = fromMaybe promoted promote + | otherwise = False + setGroupStatusPromo sendReply st env cc gr GRSActive grPromoted' $ do let approved = "The group " <> userGroupReference' gr n <> " is approved" notifyOwner gr $ (approved <> " and listed in directory - please moderate it!\n") @@ -1002,12 +966,12 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName owner <- groupOwnerInfo groupRef $ dbContactId gr pure $ "Invited " <> owner <> " to owners' group " <> viewName ogName Left err -> pure err - sendReply $ "Group approved!" <> maybe "" ("\n" <>) invited + sendReply $ "Group approved" <> (if grPromoted' then " (promoted)" else "") <>"!" <> maybe "" ("\n" <>) invited notifyOtherSuperUsers $ approved <> " by " <> viewName (localDisplayName' ct) <> maybe "" ("\n" <>) invited - Just GRSServiceNotAdmin -> replyNotApproved serviceNotAdmin - Just GRSContactNotOwner -> replyNotApproved "user is not an owner." - Just GRSBadRoles -> replyNotApproved $ "user is not an owner, " <> serviceNotAdmin - Nothing -> sendReply "Error: getGroupRolesStatus. Please notify the developers." + Right GRSServiceNotAdmin -> replyNotApproved serviceNotAdmin + Right GRSContactNotOwner -> replyNotApproved "user is not an owner." + Right GRSBadRoles -> replyNotApproved $ "user is not an owner, " <> serviceNotAdmin + Left e -> sendReply $ "Error: getGroupRolesStatus. Please notify the developers.\n" <> T.pack e where replyNotApproved reason = sendReply $ "Group is not approved: " <> reason serviceNotAdmin = serviceName <> " is not an admin." @@ -1019,32 +983,36 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName DCSuspendGroup groupId gName -> do let groupRef = groupReference' groupId gName withGroupAndReg sendReply groupId gName $ \_ gr -> - readTVarIO (groupRegStatus gr) >>= \case - GRSActive -> do - setGroupStatus st env cc gr GRSSuspended + case groupRegStatus gr of + GRSActive -> setGroupStatus sendReply st env cc groupId GRSSuspended $ \gr' -> do let suspended = "The group " <> userGroupReference' gr gName <> " is suspended" - notifyOwner gr $ suspended <> " and hidden from directory. Please contact the administrators." + notifyOwner gr' $ suspended <> " and hidden from directory. Please contact the administrators." sendReply "Group suspended!" notifyOtherSuperUsers $ suspended <> " by " <> viewName (localDisplayName' ct) _ -> sendReply $ "The group " <> groupRef <> " is not active, can't be suspended." DCResumeGroup groupId gName -> do let groupRef = groupReference' groupId gName withGroupAndReg sendReply groupId gName $ \_ gr -> - readTVarIO (groupRegStatus gr) >>= \case - GRSSuspended -> do - setGroupStatus st env cc gr GRSActive + case groupRegStatus gr of + GRSSuspended -> setGroupStatus sendReply st env cc groupId GRSActive $ \gr' -> do let groupStr = "The group " <> userGroupReference' gr gName - notifyOwner gr $ groupStr <> " is listed in the directory again!" + notifyOwner gr' $ groupStr <> " is listed in the directory again!" sendReply "Group listing resumed!" notifyOtherSuperUsers $ groupStr <> " listing resumed by " <> viewName (localDisplayName' ct) _ -> sendReply $ "The group " <> groupRef <> " is not suspended, can't be resumed." - DCListLastGroups count -> listGroups count False - DCListPendingGroups count -> listGroups count True + DCListLastGroups count -> + listLastGroups cc user count >>= \case + Left e -> sendReply $ "Error reading groups: " <> T.pack e + Right gs -> sendGroupsInfo ct ciId True $ first reverse gs + DCListPendingGroups count -> + listPendingGroups cc user count >>= \case + Left e -> sendReply $ "Error reading groups: " <> T.pack e + Right gs -> sendGroupsInfo ct ciId True $ first reverse gs DCSendToGroupOwner groupId gName msg -> do let groupRef = groupReference' groupId gName - withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {dbContactId} -> do + withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {dbContactId = ctId} -> do notifyOwner gr msg - owner <- groupOwnerInfo groupRef dbContactId + owner <- groupOwnerInfo groupRef ctId sendReply $ "Forwarded to " <> owner DCInviteOwnerToGroup groupId gName -> case ownersGroup of Just og@KnownGroup {localDisplayName = ogName} -> @@ -1066,17 +1034,6 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName knownCt = knownContact ct sendReply = mkSendReply ct ciId notifyOtherSuperUsers s = withSuperUsers $ \ctId -> unless (ctId == contactId' ct) $ sendMessage' cc ctId s - listGroups count pending = - readTVarIO (groupRegs st) >>= \groups -> do - grs <- - if pending - then filterM (fmap pendingApproval . readTVarIO . groupRegStatus) groups - else pure groups - sendReply $ tshow (length grs) <> " registered group(s)" <> (if length grs > count then ", showing the last " <> tshow count else "") - void . forkIO $ forM_ (reverse $ take count grs) $ \gr@GroupReg {dbGroupId, dbContactId} -> do - ct_ <- getContact' cc user dbContactId - let ownerStr = "Owner: " <> maybe "getContact error" localDisplayName' ct_ - sendGroupInfo ct gr dbGroupId $ Just ownerStr inviteToOwnersGroup :: KnownGroup -> GroupReg -> (Either Text () -> IO a) -> IO a inviteToOwnersGroup KnownGroup {groupId = ogId} GroupReg {dbContactId = ctId} cont = sendChatCmd cc (APIListMembers ogId) >>= \case @@ -1099,20 +1056,17 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName owner_ <- getContact' cc user dbContactId let ownerInfo = "the owner of the group " <> groupRef ownerName ct' = "@" <> viewName (localDisplayName' ct') <> ", " - pure $ maybe "" ownerName owner_ <> ownerInfo + pure $ either (const "") ownerName owner_ <> ownerInfo deSuperUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRSuperUser -> IO () deSuperUserCommand ct ciId cmd | knownContact ct `elem` superUsers = case cmd of DCPromoteGroup groupId gName promote' -> withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {groupRegStatus, promoted} -> do - status <- readTVarIO groupRegStatus - promote <- readTVarIO promoted - when (promote' /= promote) $ setGroupPromoted st env cc gr promote' - let msg = - "Group promotion " - <> (if promote' then "enabled" <> (if status == GRSActive then "." else ", but the group is not listed.") else "disabled.") - sendReply msg + let notify = sendReply $ "Group promotion " <> (if promote' then "enabled" <> (if groupRegStatus == GRSActive then "." else ", but the group is not listed.") else "disabled.") + if promote' /= promoted + then setGroupPromoted sendReply st env cc gr promote' notify + else notify DCExecuteCommand cmdStr -> sendChatCmdStr cc cmdStr >>= \case Right r -> do @@ -1137,61 +1091,90 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName withGroupAndReg_ :: (Text -> IO ()) -> GroupId -> Maybe GroupName -> (GroupInfo -> GroupReg -> IO ()) -> IO () withGroupAndReg_ sendReply gId gName_ action = - getGroup cc user gId >>= \case - Nothing -> sendReply $ "Group ID " <> tshow gId <> " not found (getGroup)" - Just g@GroupInfo {groupProfile = GroupProfile {displayName}} + getGroupAndReg cc user gId >>= \case + Left e -> sendReply $ "Group " <> tshow gId <> " error (getGroup): " <> T.pack e + Right (g@GroupInfo {groupProfile = GroupProfile {displayName}}, gr) | maybe False (displayName ==) gName_ -> - getGroupReg st gId >>= \case - Nothing -> sendReply $ "Registration for group ID " <> tshow gId <> " not found (getGroupReg)" - Just gr -> action g gr + action g gr | otherwise -> sendReply $ "Group ID " <> tshow gId <> " has the display name " <> displayName - sendGroupInfo :: Contact -> GroupReg -> GroupId -> Maybe Text -> IO () - sendGroupInfo ct gr@GroupReg {dbGroupId} useGroupId ownerStr_ = do - grStatus <- readTVarIO $ groupRegStatus gr - let statusStr = "Status: " <> groupRegStatusText grStatus - getGroupAndSummary cc user dbGroupId >>= \case - Just (GroupInfo {groupProfile = p@GroupProfile {image = image_}}, GroupSummary {currentMembers}) -> do - let membersStr = "_" <> tshow currentMembers <> " members_" + getOwnersInfo :: [(GroupInfo, GroupReg)] -> IO [((GroupInfo, GroupReg), Maybe (Either String Contact))] + getOwnersInfo gs = + fmap (either (\e -> map (,Just (Left e)) gs) id) $ withDB' "getOwnersInfo" cc $ \db -> + mapM (\g@(_, gr) -> fmap ((g,) . Just . first show) $ runExceptT $ getContact db (vr cc) user $ dbContactId gr) gs + + sendGroupsInfo :: Contact -> ChatItemId -> Bool -> ([(GroupInfo, GroupReg)], Int) -> IO () + sendGroupsInfo ct ciId isAdmin (gs, n) = do + let more = if n > length gs then ", showing the last " <> tshow (length gs) else "" + replyMsg = (Just ciId, MCText $ tshow n <> " registered group(s)" <> more) + gs' <- if isAdmin then getOwnersInfo gs else pure $ map (,Nothing) gs + sendComposedMessages_ cc (SRDirect $ contactId' ct) $ replyMsg :| map groupMessage gs' + where + groupMessage ((g, gr), ct_) = + let GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_}, groupSummary} = g + GroupReg {userGroupRegId, groupRegStatus} = gr + useGroupId = if isAdmin then groupId else userGroupRegId + statusStr = "Status: " <> groupRegStatusText groupRegStatus + membersStr = "_" <> tshow (currentMembers groupSummary) <> " members_" cmds = "/'role " <> tshow useGroupId <> "', /'filter " <> tshow useGroupId <> "'" - text = T.unlines $ [tshow useGroupId <> ". " <> groupInfoText p] <> maybeToList ownerStr_ <> [membersStr, statusStr, cmds] + ownerStr = maybe "" (("Owner: " <>) . either (("getContact error: " <>) . T.pack) localDisplayName') ct_ + text = T.unlines $ [tshow useGroupId <> ". " <> groupInfoText p] ++ [ownerStr | isAdmin] ++ [membersStr, statusStr, cmds] msg = maybe (MCText text) (\image -> MCImage {text, image}) image_ - sendComposedMessage cc ct Nothing msg - Nothing -> do - let text = T.unlines $ [tshow useGroupId <> ". Error: getGroup. Please notify the developers."] <> maybeToList ownerStr_ <> [statusStr] - sendComposedMessage cc ct Nothing $ MCText text + in (Nothing, msg) -setGroupStatus :: DirectoryStore -> ServiceState -> ChatController -> GroupReg -> GroupRegStatus -> IO () -setGroupStatus st env cc gr grStatus' = do +setGroupStatusPromo :: (Text -> IO ()) -> DirectoryLog -> ServiceState -> ChatController -> GroupReg -> GroupRegStatus -> Bool -> IO () -> IO () +setGroupStatusPromo sendReply st env cc GroupReg {dbGroupId = gId} grStatus' grPromoted' continue = do let status' = grDirectoryStatus grStatus' - status <- setGroupStatusStore st gr grStatus' - when ((status == DSListed || status' == DSListed) && status /= status') $ listingsUpdated env cc + setGroupStatusPromoStore cc gId grStatus' grPromoted' >>= \case + Left e -> sendReply $ "Error updating group " <> tshow gId <> " status: " <> T.pack e + Right (status, grPromoted) -> do + when ((status == DSListed || status' == DSListed) && (status /= status' || grPromoted /= grPromoted')) $ + listingsUpdated env cc + logGUpdateStatus st gId grStatus' + logGUpdatePromotion st gId grPromoted' + continue -setGroupPromoted :: DirectoryStore -> ServiceState -> ChatController -> GroupReg -> Bool -> IO () -setGroupPromoted st env cc gr grPromoted' = do - (status, grPromoted) <- setGroupPromotedStore st gr grPromoted' - when (status == DSListed && grPromoted' /= grPromoted) $ listingsUpdated env cc +addGroupReg :: (Text -> IO ()) -> DirectoryLog -> ChatController -> Contact -> GroupInfo -> GroupRegStatus -> (GroupReg -> IO ()) -> IO () +addGroupReg sendMsg st cc ct g@GroupInfo {groupId} grStatus continue = + addGroupRegStore cc ct g grStatus >>= \case + Left e -> sendMsg $ "Error creating group registation for group " <> tshow groupId <> ": " <> T.pack e + Right gr -> do + logGCreate st gr + continue gr -updateGroupListingFiles :: ChatController -> DirectoryStore -> User -> FilePath -> IO () -updateGroupListingFiles cc st u dir = - withDB' "generateListing" cc (\db -> getUserGroupsWithSummary db (vr cc) u Nothing Nothing) >>= \case - Just gs -> generateListing st dir gs - Nothing -> putStrLn "generateListing error: failed to read groups" +setGroupStatus :: (Text -> IO ()) -> DirectoryLog -> ServiceState -> ChatController -> GroupId -> GroupRegStatus -> (GroupReg -> IO ()) -> IO () +setGroupStatus sendMsg st env cc gId grStatus' continue = do + let status' = grDirectoryStatus grStatus' + setGroupStatusStore cc gId grStatus' >>= \case + Left e -> sendMsg $ "Error updating group " <> tshow gId <> " status: " <> T.pack e + Right (grStatus, gr) -> do + let status = grDirectoryStatus grStatus + when ((status == DSListed || status' == DSListed) && status /= status') $ listingsUpdated env cc + logGUpdateStatus st gId grStatus' + continue gr -getContact' :: ChatController -> User -> ContactId -> IO (Maybe Contact) -getContact' cc user ctId = withDB "getContact" cc $ \db -> getContact db (vr cc) user ctId +setGroupPromoted :: (Text -> IO ()) -> DirectoryLog -> ServiceState -> ChatController -> GroupReg -> Bool -> IO () -> IO () +setGroupPromoted sendReply st env cc GroupReg {dbGroupId = gId} grPromoted' continue = + setGroupPromotedStore cc gId grPromoted' >>= \case + Left e -> sendReply $ "Error updating group " <> tshow gId <> " status: " <> T.pack e + Right (status, grPromoted) -> do + when (status == DSListed && grPromoted' /= grPromoted) $ listingsUpdated env cc + logGUpdatePromotion st gId grPromoted' + continue -getGroup :: ChatController -> User -> GroupId -> IO (Maybe GroupInfo) -getGroup cc user gId = withDB "getGroupInfo" cc $ \db -> getGroupInfo db (vr cc) user gId +updateGroupListingFiles :: ChatController -> User -> FilePath -> IO () +updateGroupListingFiles cc u dir = + getAllListedGroups cc u >>= \case + Right gs -> generateListing dir gs + Left e -> logError $ "generateListing error: failed to read groups: " <> T.pack e -getGroupAndSummary :: ChatController -> User -> GroupId -> IO (Maybe (GroupInfo, GroupSummary)) -getGroupAndSummary cc user gId = - withDB "getGroupAndSummary" cc $ \db -> (,) <$> getGroupInfo db (vr cc) user gId <*> liftIO (getGroupSummary db user gId) +getContact' :: ChatController -> User -> ContactId -> IO (Either String Contact) +getContact' cc user ctId = withDB "getContact" cc $ \db -> withExceptT show $ getContact db (vr cc) user ctId -getGroupLink' :: ChatController -> User -> GroupInfo -> IO (Maybe GroupLink) +getGroupLink' :: ChatController -> User -> GroupInfo -> IO (Either String GroupLink) getGroupLink' cc user gInfo = - withDB "getGroupLink" cc $ \db -> getGroupLink db user gInfo + withDB "getGroupLink" cc $ \db -> withExceptT groupDBError $ getGroupLink db user gInfo setGroupLinkRole :: ChatController -> GroupInfo -> GroupMemberRole -> IO (Maybe CreatedLinkContact) setGroupLinkRole cc GroupInfo {groupId} mRole = resp <$> sendChatCmd cc (APIGroupLinkMemberRole groupId mRole) diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index 1c8e1a24b5..b10afc9be9 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -1,12 +1,17 @@ {-# LANGUAGE BangPatterns #-} +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeOperators #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Directory.Store - ( DirectoryStore (..), + ( DirectoryLog (..), GroupReg (..), GroupRegStatus (..), UserGroupRegId, @@ -15,17 +20,28 @@ module Directory.Store DirectoryMemberAcceptance (..), DirectoryStatus (..), ProfileCondition (..), - restoreDirectoryStore, - addGroupReg, + DirectoryLogRecord (..), + openDirectoryLog, + readDirectoryLogData, + addGroupRegStore, + insertGroupReg, delGroupReg, + deleteGroupReg, setGroupStatusStore, + setGroupStatusPromoStore, setGroupPromotedStore, grDirectoryStatus, setGroupRegOwner, - getGroupReg, getUserGroupReg, getUserGroupRegs, - filterListedGroups, + getAllGroupRegs_, + getDuplicateGroupRegs, + getGroupReg, + getGroupAndReg, + listLastGroups, + listPendingGroups, + getAllListedGroups, + searchListedGroups, groupRegStatusText, pendingApproval, groupRemoved, @@ -35,12 +51,19 @@ module Directory.Store basicJoinFilter, moderateJoinFilter, strongJoinFilter, + groupDBError, + logGCreate, + logGDelete, + logGUpdateOwner, + logGUpdateStatus, + logGUpdatePromotion, ) where import Control.Applicative ((<|>)) -import Control.Concurrent.STM import Control.Monad +import Control.Monad.Except +import Control.Monad.IO.Class import Data.Aeson ((.:), (.=)) import qualified Data.Aeson.KeyMap as JM import qualified Data.Aeson.TH as JQ @@ -49,51 +72,51 @@ import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Int (Int64) -import Data.List (find, foldl', sortOn) +import Data.List (sortOn) import Data.Map (Map) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust) -import Data.Set (Set) -import qualified Data.Set as S import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock (UTCTime (..), getCurrentTime) +import Data.Time.Clock.System (systemEpochDay) +import Directory.Search +import Directory.Util +import Simplex.Chat.Controller +import Simplex.Chat.Protocol (supportedChatVRange) +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) +import Simplex.Chat.Store +import Simplex.Chat.Store.Groups +import Simplex.Chat.Store.Shared (groupInfoQueryFields, groupInfoQueryFrom) import Simplex.Chat.Types +import Simplex.Messaging.Agent.Store.DB (BoolInt (..), fromTextField_) +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) -import Simplex.Messaging.Util (ifM, whenM) -import System.Directory +import Simplex.Messaging.Util (eitherToMaybe, firstRow, maybeFirstRow', safeDecodeUtf8) import System.IO (BufferMode (..), Handle, IOMode (..), hSetBuffering, openFile) -data DirectoryStore = DirectoryStore - { groupRegs :: TVar [GroupReg], -- most recent first, reversed when listed - listedGroups :: TVar (Set GroupId), -- includes promoted - promotedGroups :: TVar (Set GroupId), - reservedGroups :: TVar (Set GroupId), - directoryLogFile :: Maybe Handle - } +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..), Query, (:.) (..)) +import Database.SQLite.Simple.QQ (sql) +#endif -data DirectoryStoreData = DirectoryStoreData - { groupRegs_ :: [GroupReg], - listedGroups_ :: Set GroupId, - promotedGroups_ :: Set GroupId, - reservedGroups_ :: Set GroupId +data DirectoryLog = DirectoryLog + { directoryLogFile :: Maybe Handle } data GroupReg = GroupReg { dbGroupId :: GroupId, userGroupRegId :: UserGroupRegId, dbContactId :: ContactId, - dbOwnerMemberId :: TVar (Maybe GroupMemberId), - groupRegStatus :: TVar GroupRegStatus, - promoted :: TVar Bool - } - -data GroupRegData = GroupRegData - { dbGroupId_ :: GroupId, - userGroupRegId_ :: UserGroupRegId, - dbContactId_ :: ContactId, - dbOwnerMemberId_ :: Maybe GroupMemberId, - groupRegStatus_ :: GroupRegStatus, - promoted_ :: Bool + dbOwnerMemberId :: Maybe GroupMemberId, + groupRegStatus :: GroupRegStatus, + promoted :: Bool, + createdAt :: UTCTime } data DirectoryGroupData = DirectoryGroupData @@ -203,101 +226,219 @@ toCustomData :: DirectoryGroupData -> CustomData toCustomData DirectoryGroupData {memberAcceptance} = CustomData $ JM.fromList ["memberAcceptance" .= memberAcceptance] -addGroupReg :: DirectoryStore -> Contact -> GroupInfo -> GroupRegStatus -> IO UserGroupRegId -addGroupReg st ct GroupInfo {groupId} grStatus = do - grData <- addGroupReg_ - logGCreate st grData - pure $ userGroupRegId_ grData +addGroupRegStore :: ChatController -> Contact -> GroupInfo -> GroupRegStatus -> IO (Either String GroupReg) +addGroupRegStore cc Contact {contactId = dbContactId} GroupInfo {groupId = dbGroupId} groupRegStatus = + withDB' "addGroupRegStore" cc $ \db -> do + createdAt <- getCurrentTime + maxUgrId <- + maybeFirstRow' 0 (fromMaybe 0 . fromOnly) $ + DB.query db "SELECT MAX(user_group_reg_id) FROM sx_directory_group_regs WHERE contact_id = ?" (Only dbContactId) + let gr = GroupReg {dbGroupId, userGroupRegId = maxUgrId + 1, dbContactId, dbOwnerMemberId = Nothing, groupRegStatus, promoted = False, createdAt} + insertGroupReg db gr + pure gr + +insertGroupReg :: DB.Connection -> GroupReg -> IO () +insertGroupReg db GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted, createdAt} = do + DB.execute + db + [sql| + INSERT INTO sx_directory_group_regs + (group_id, user_group_reg_id, contact_id, owner_member_id, group_reg_status, group_promoted, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?) + |] + (dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, BI promoted, createdAt, createdAt) + +delGroupReg :: ChatController -> GroupId -> IO (Either String ()) +delGroupReg cc gId = withDB' "delGroupReg" cc (`deleteGroupReg` gId) + +deleteGroupReg :: DB.Connection -> GroupId -> IO () +deleteGroupReg db gId = DB.execute db "DELETE FROM sx_directory_group_regs WHERE group_id = ?" (Only gId) + +setGroupStatusStore :: ChatController -> GroupId -> GroupRegStatus -> IO (Either String (GroupRegStatus, GroupReg)) +setGroupStatusStore cc gId grStatus' = + withDB "setGroupStatusStore" cc $ \db -> do + gr <- getGroupReg_ db gId + ts <- liftIO getCurrentTime + liftIO $ DB.execute db "UPDATE sx_directory_group_regs SET group_reg_status = ?, updated_at = ? WHERE group_id = ?" (grStatus', ts, gId) + pure (groupRegStatus gr, gr {groupRegStatus = grStatus'}) + +setGroupStatusPromoStore :: ChatController -> GroupId -> GroupRegStatus -> Bool -> IO (Either String (DirectoryStatus, Bool)) +setGroupStatusPromoStore cc gId grStatus' grPromoted' = + withDB "setGroupStatusPromoStore" cc $ \db -> do + GroupReg {groupRegStatus, promoted} <- getGroupReg_ db gId + ts <- liftIO getCurrentTime + liftIO $ DB.execute db "UPDATE sx_directory_group_regs SET group_reg_status = ?, group_promoted = ?, updated_at = ? WHERE group_id = ?" (grStatus', BI grPromoted', ts, gId) + pure (grDirectoryStatus groupRegStatus, promoted) + +setGroupPromotedStore :: ChatController -> GroupId -> Bool -> IO (Either String (DirectoryStatus, Bool)) +setGroupPromotedStore cc gId grPromoted' = + withDB "setGroupPromotedStore" cc $ \db -> do + GroupReg {groupRegStatus, promoted} <- getGroupReg_ db gId + ts <- liftIO getCurrentTime + liftIO $ DB.execute db "UPDATE sx_directory_group_regs SET group_promoted = ?, updated_at = ? WHERE group_id = ?" (BI grPromoted', ts, gId) + pure (grDirectoryStatus groupRegStatus, promoted) + +groupDBError :: StoreError -> String +groupDBError = \case + SEGroupNotFound _ -> "group not found" + e -> show e + +setGroupRegOwner :: ChatController -> GroupId -> GroupMember -> IO (Either String ()) +setGroupRegOwner cc gId owner = do + ts <- getCurrentTime + withDB' "setGroupRegOwner" cc $ \db -> + DB.execute + db + [sql| + UPDATE sx_directory_group_regs + SET owner_member_id = ?, updated_at = ? + WHERE group_id = ? + |] + (groupMemberId' owner, ts, gId) + +getGroupReg :: ChatController -> GroupId -> IO (Either String GroupReg) +getGroupReg cc gId = withDB "getGroupReg" cc (`getGroupReg_` gId) + +getGroupReg_ :: DB.Connection -> GroupId -> ExceptT String IO GroupReg +getGroupReg_ db gId = + ExceptT $ firstRow rowToGroupReg "group registration not found" $ + DB.query + db + [sql| + SELECT group_id, user_group_reg_id, contact_id, owner_member_id, group_reg_status, group_promoted, created_at + FROM sx_directory_group_regs + WHERE group_id = ? + |] + (Only gId) + +getGroupAndReg :: ChatController -> User -> GroupId -> IO (Either String (GroupInfo, GroupReg)) +getGroupAndReg cc user@User {userId, userContactId} gId = + withDB "getGroupAndReg" cc $ \db -> + ExceptT $ firstRow (toGroupInfoReg (vr cc) user) ("group " ++ show gId ++ " not found") $ + DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) + +getUserGroupReg :: ChatController -> User -> ContactId -> UserGroupRegId -> IO (Either String (GroupInfo, GroupReg)) +getUserGroupReg cc user@User {userId, userContactId} ctId ugrId = + withDB "getUserGroupReg" cc $ \db -> + ExceptT $ firstRow (toGroupInfoReg (vr cc) user) ("group " ++ show ugrId ++ " not found") $ + DB.query db (groupReqQuery <> " AND r.contact_id = ? AND r.user_group_reg_id = ?") (userId, userContactId, ctId, ugrId) + +getUserGroupRegs :: ChatController -> User -> ContactId -> IO (Either String [(GroupInfo, GroupReg)]) +getUserGroupRegs cc user@User {userId, userContactId} ctId = + withDB' "getUserGroupRegs" cc $ \db -> + map (toGroupInfoReg (vr cc) user) + <$> DB.query db (groupReqQuery <> " AND r.contact_id = ? ORDER BY r.user_group_reg_id") (userId, userContactId, ctId) + +getAllListedGroups :: ChatController -> User -> IO (Either String [(GroupInfo, GroupReg, Maybe GroupLink)]) +getAllListedGroups cc user@User {userId, userContactId} = + withDB' "getAllListedGroups" cc $ \db -> + DB.query db (groupReqQuery <> " AND r.group_reg_status = ?") (userId, userContactId, GRSActive) + >>= mapM (withGroupLink db . toGroupInfoReg (vr cc) user) where - addGroupReg_ = do - let grData = GroupRegData {dbGroupId_ = groupId, userGroupRegId_ = 1, dbContactId_ = ctId, dbOwnerMemberId_ = Nothing, groupRegStatus_ = grStatus, promoted_ = False} - gr <- dataToGroupReg grData - atomically $ stateTVar (groupRegs st) $ \grs -> - let ugrId = 1 + foldl' maxUgrId 0 grs - grData' = grData {userGroupRegId_ = ugrId} - gr' = gr {userGroupRegId = ugrId} - in (grData', gr' : grs) - ctId = contactId' ct - maxUgrId mx GroupReg {dbContactId, userGroupRegId} - | dbContactId == ctId && userGroupRegId > mx = userGroupRegId - | otherwise = mx + withGroupLink db (g, gr) = (g,gr,) . eitherToMaybe <$> runExceptT (getGroupLink db user g) -delGroupReg :: DirectoryStore -> GroupReg -> IO () -delGroupReg st gr@GroupReg {dbGroupId = gId, groupRegStatus} = do - logGDelete st gId - atomically $ writeTVar groupRegStatus GRSRemoved - atomically $ unlistGroup st gr - atomically $ modifyTVar' (groupRegs st) $ filter ((gId /=) . dbGroupId) - -setGroupStatusStore :: DirectoryStore -> GroupReg -> GroupRegStatus -> IO DirectoryStatus -setGroupStatusStore st gr grStatus' = do - logGUpdateStatus st (dbGroupId gr) grStatus' - atomically $ do - grStatus <- swapTVar (groupRegStatus gr) grStatus' - updateListing st gr - pure $ grDirectoryStatus grStatus +searchListedGroups :: ChatController -> User -> SearchType -> Maybe GroupId -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) +searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pageSize = + withDB' "searchListedGroups" cc $ \db -> + case searchType of + STAll -> case lastGroup_ of + Nothing -> do + gs <- groups $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) + n <- count $ DB.query db countQuery' (Only GRSActive) + pure (gs, n) + Just gId -> do + gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) + n <- count $ DB.query db (countQuery' <> " AND r.group_id > ? " <> orderBy) (GRSActive, gId) + pure (gs, n) + where + countQuery' = countQuery <> " WHERE r.group_reg_status = ? " + orderBy = " ORDER BY g.summary_current_members_count DESC " + STRecent -> case lastGroup_ of + Nothing -> do + gs <- groups $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) + n <- count $ DB.query db countQuery' (Only GRSActive) + pure (gs, n) + Just gId -> do + gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) + n <- count $ DB.query db (countQuery' <> " AND r.group_id > ? " <> orderBy) (GRSActive, gId) + pure (gs, n) + where + countQuery' = countQuery <> " WHERE r.group_reg_status = ? " + orderBy = " ORDER BY r.created_at DESC " + STSearch search -> case lastGroup_ of + Nothing -> do + gs <- groups $ DB.query db (listedGroupQuery <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, s, s, s, s, pageSize) + n <- count $ DB.query db (countQuery' <> searchCond) (GRSActive, s, s, s, s) + pure (gs, n) + Just gId -> do + gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, s, s, s, s, pageSize) + n <- count $ DB.query db (countQuery' <> " AND r.group_id > ? " <> searchCond <> orderBy) (GRSActive, gId, s, s, s, s) + pure (gs, n) + where + s = T.toLower search + countQuery' = countQuery <> " JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE r.group_reg_status = ? " + orderBy = " ORDER BY g.summary_current_members_count DESC " where - status' = grDirectoryStatus grStatus' - updateListing = case status' of - DSListed -> listGroup - DSReserved -> reserveGroup - DSRegistered -> unlistGroup - DSRemoved -> unlistGroup + groups = (map (toGroupInfoReg (vr cc) user) <$>) + count = maybeFirstRow' 0 fromOnly + listedGroupQuery = groupReqQuery <> " AND r.group_reg_status = ? " + countQuery = "SELECT COUNT(1) FROM groups g JOIN sx_directory_group_regs r ON g.group_id = r.group_id " + searchCond = + [sql| + AND (LOWER(gp.display_name) LIKE '%' || ? || '%' + OR LOWER(gp.full_name) LIKE '%' || ? || '%' + OR LOWER(gp.short_descr) LIKE '%' || ? || '%' + OR LOWER(gp.description) LIKE '%' || ? || '%' + ) + |] -setGroupPromotedStore :: DirectoryStore -> GroupReg -> Bool -> IO (DirectoryStatus, Bool) -setGroupPromotedStore st gr grPromoted' = do - let gId = dbGroupId gr - logGUpdatePromotion st gId grPromoted' - atomically $ do - grPromoted <- swapTVar (promoted gr) grPromoted' - status <- grDirectoryStatus <$> readTVar (groupRegStatus gr) - let update = if status == DSListed && grPromoted' then S.insert else S.delete - modifyTVar' (promotedGroups st) $ update gId - pure (status, grPromoted) +getAllGroupRegs_ :: DB.Connection -> User -> IO [(GroupInfo, GroupReg)] +getAllGroupRegs_ db user@User {userId, userContactId} = + map (toGroupInfoReg supportedChatVRange user) + <$> DB.query db groupReqQuery (userId, userContactId) -setGroupRegOwner :: DirectoryStore -> GroupReg -> GroupMember -> IO () -setGroupRegOwner st gr owner = do - let memberId = groupMemberId' owner - logGUpdateOwner st (dbGroupId gr) memberId - atomically $ writeTVar (dbOwnerMemberId gr) (Just memberId) +getDuplicateGroupRegs :: ChatController -> User -> Text -> IO (Either String [(GroupInfo, GroupReg)]) +getDuplicateGroupRegs cc user@User {userId, userContactId} displayName = + withDB' "getDuplicateGroupRegs" cc $ \db -> + map (toGroupInfoReg (vr cc) user) + <$> DB.query db (groupReqQuery <> " AND gp.display_name = ?") (userId, userContactId, displayName) -getGroupReg :: DirectoryStore -> GroupId -> IO (Maybe GroupReg) -getGroupReg st gId = find ((gId ==) . dbGroupId) <$> readTVarIO (groupRegs st) +listLastGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) +listLastGroups cc user@User {userId, userContactId} count = + withDB' "getUserGroupRegs" cc $ \db -> do + gs <- + map (toGroupInfoReg (vr cc) user) + <$> DB.query db (groupReqQuery <> " ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) + n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs" + pure (gs, n) -getUserGroupReg :: DirectoryStore -> ContactId -> UserGroupRegId -> IO (Maybe GroupReg) -getUserGroupReg st ctId ugrId = find (\r -> ctId == dbContactId r && ugrId == userGroupRegId r) <$> readTVarIO (groupRegs st) +listPendingGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) +listPendingGroups cc user@User {userId, userContactId} count = + withDB' "getUserGroupRegs" cc $ \db -> do + gs <- + map (toGroupInfoReg (vr cc) user) + <$> DB.query db (groupReqQuery <> " AND r.group_reg_status LIKE 'pending_approval%' ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) + n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs WHERE group_reg_status LIKE 'pending_approval%'" + pure (gs, n) -getUserGroupRegs :: DirectoryStore -> ContactId -> IO [GroupReg] -getUserGroupRegs st ctId = filter ((ctId ==) . dbContactId) <$> readTVarIO (groupRegs st) +toGroupInfoReg :: VersionRangeChat -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg) +toGroupInfoReg vr' User {userContactId} (groupRow :. grRow) = + (toGroupInfo vr' userContactId [] groupRow, rowToGroupReg grRow) -filterListedGroups :: DirectoryStore -> [GroupInfoSummary] -> IO [GroupInfoSummary] -filterListedGroups st gs = do - lgs <- readTVarIO $ listedGroups st - pure $ filter (\(GIS GroupInfo {groupId} _ _) -> groupId `S.member` lgs) gs +type GroupRegRow = (GroupId, UserGroupRegId, ContactId, Maybe GroupMemberId, GroupRegStatus, BoolInt, UTCTime) -listGroup :: DirectoryStore -> GroupReg -> STM () -listGroup st gr = do - let gId = dbGroupId gr - modifyTVar' (listedGroups st) $ S.insert gId - whenM (readTVar $ promoted gr) $ modifyTVar' (promotedGroups st) $ S.insert gId - modifyTVar' (reservedGroups st) $ S.delete gId +rowToGroupReg :: GroupRegRow -> GroupReg +rowToGroupReg (dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, BI promoted, createdAt) = + GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted, createdAt} -reserveGroup :: DirectoryStore -> GroupReg -> STM () -reserveGroup st gr = do - let gId = dbGroupId gr - modifyTVar' (listedGroups st) $ S.delete gId - modifyTVar' (promotedGroups st) $ S.delete gId - modifyTVar' (reservedGroups st) $ S.insert gId - -unlistGroup :: DirectoryStore -> GroupReg -> STM () -unlistGroup st gr = do - let gId = dbGroupId gr - modifyTVar' (listedGroups st) $ S.delete gId - modifyTVar' (promotedGroups st) $ S.delete gId - modifyTVar' (reservedGroups st) $ S.delete gId +groupReqQuery :: Query +groupReqQuery = groupInfoQueryFields <> groupRegFields <> groupInfoQueryFrom <> groupRegFromCond + where + groupRegFields = ", r.group_id, r.user_group_reg_id, r.contact_id, r.owner_member_id, r.group_reg_status, r.group_promoted, r.created_at " + groupRegFromCond = " JOIN sx_directory_group_regs r ON r.group_id = g.group_id WHERE g.user_id = ? AND mu.contact_id = ? " data DirectoryLogRecord - = GRCreate GroupRegData + = GRCreate GroupReg | GRDelete GroupId | GRUpdateStatus GroupId GroupRegStatus | GRUpdatePromotion GroupId Bool @@ -310,22 +451,22 @@ data DLRTag | GRUpdatePromotion_ | GRUpdateOwner_ -logDLR :: DirectoryStore -> DirectoryLogRecord -> IO () +logDLR :: DirectoryLog -> DirectoryLogRecord -> IO () logDLR st r = forM_ (directoryLogFile st) $ \h -> B.hPutStrLn h (strEncode r) -logGCreate :: DirectoryStore -> GroupRegData -> IO () +logGCreate :: DirectoryLog -> GroupReg -> IO () logGCreate st = logDLR st . GRCreate -logGDelete :: DirectoryStore -> GroupId -> IO () +logGDelete :: DirectoryLog -> GroupId -> IO () logGDelete st = logDLR st . GRDelete -logGUpdateStatus :: DirectoryStore -> GroupId -> GroupRegStatus -> IO () +logGUpdateStatus :: DirectoryLog -> GroupId -> GroupRegStatus -> IO () logGUpdateStatus st gId = logDLR st . GRUpdateStatus gId -logGUpdatePromotion :: DirectoryStore -> GroupId -> Bool -> IO () +logGUpdatePromotion :: DirectoryLog -> GroupId -> Bool -> IO () logGUpdatePromotion st gId = logDLR st . GRUpdatePromotion gId -logGUpdateOwner :: DirectoryStore -> GroupId -> GroupMemberId -> IO () +logGUpdateOwner :: DirectoryLog -> GroupId -> GroupMemberId -> IO () logGUpdateOwner st gId = logDLR st . GRUpdateOwner gId instance StrEncoding DLRTag where @@ -359,24 +500,25 @@ instance StrEncoding DirectoryLogRecord where GRUpdatePromotion_ -> GRUpdatePromotion <$> A.decimal <*> _strP GRUpdateOwner_ -> GRUpdateOwner <$> A.decimal <* A.space <*> A.decimal -instance StrEncoding GroupRegData where - strEncode GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerMemberId_, groupRegStatus_, promoted_} = +instance StrEncoding GroupReg where + strEncode GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted} = B.unwords $ - [ "group_id=" <> strEncode dbGroupId_, - "user_group_id=" <> strEncode userGroupRegId_, - "contact_id=" <> strEncode dbContactId_, - "owner_member_id=" <> strEncode dbOwnerMemberId_, - "status=" <> strEncode groupRegStatus_ + [ "group_id=" <> strEncode dbGroupId, + "user_group_id=" <> strEncode userGroupRegId, + "contact_id=" <> strEncode dbContactId, + "owner_member_id=" <> strEncode dbOwnerMemberId, + "status=" <> strEncode groupRegStatus ] - <> ["promoted=" <> strEncode promoted_ | promoted_] + <> ["promoted=" <> strEncode promoted | promoted] strP = do - dbGroupId_ <- "group_id=" *> strP_ - userGroupRegId_ <- "user_group_id=" *> strP_ - dbContactId_ <- "contact_id=" *> strP_ - dbOwnerMemberId_ <- "owner_member_id=" *> strP_ - groupRegStatus_ <- "status=" *> strP - promoted_ <- (" promoted=" *> strP) <|> pure False - pure GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerMemberId_, groupRegStatus_, promoted_} + dbGroupId <- "group_id=" *> strP_ + userGroupRegId <- "user_group_id=" *> strP_ + dbContactId <- "contact_id=" *> strP_ + dbOwnerMemberId <- "owner_member_id=" *> strP_ + groupRegStatus <- "status=" *> strP + promoted <- (" promoted=" *> strP) <|> pure False + let createdAt = UTCTime systemEpochDay 0 + pure GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted, createdAt} instance StrEncoding GroupRegStatus where strEncode = \case @@ -400,78 +542,30 @@ instance StrEncoding GroupRegStatus where "removed" -> pure GRSRemoved _ -> fail "invalid GroupRegStatus" -dataToGroupReg :: GroupRegData -> IO GroupReg -dataToGroupReg GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerMemberId_, groupRegStatus_, promoted_} = do - dbOwnerMemberId <- newTVarIO dbOwnerMemberId_ - groupRegStatus <- newTVarIO groupRegStatus_ - promoted <- newTVarIO promoted_ - pure - GroupReg - { dbGroupId = dbGroupId_, - userGroupRegId = userGroupRegId_, - dbContactId = dbContactId_, - dbOwnerMemberId, - groupRegStatus, - promoted - } +instance ToField GroupRegStatus where toField = toField . safeDecodeUtf8 . strEncode -restoreDirectoryStore :: Maybe FilePath -> IO DirectoryStore -restoreDirectoryStore = \case - Just f -> ifM (doesFileExist f) (restore f) (newFile f >>= newDirectoryStore . Just) - Nothing -> newDirectoryStore Nothing +instance FromField GroupRegStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 + +openDirectoryLog :: Maybe FilePath -> IO DirectoryLog +openDirectoryLog = \case + Just f -> DirectoryLog . Just <$> openLogFile f + Nothing -> pure $ DirectoryLog Nothing where - newFile f = do - h <- openFile f WriteMode + openLogFile f = do + h <- openFile f AppendMode hSetBuffering h LineBuffering pure h - restore f = do - grs <- readDirectoryData f - renameFile f (f <> ".bak") - h <- writeDirectoryData f grs -- compact - mkDirectoryStore h grs -emptyStoreData :: DirectoryStoreData -emptyStoreData = DirectoryStoreData [] S.empty S.empty S.empty - -newDirectoryStore :: Maybe Handle -> IO DirectoryStore -newDirectoryStore = (`mkDirectoryStore_` emptyStoreData) - -mkDirectoryStore :: Handle -> [GroupRegData] -> IO DirectoryStore -mkDirectoryStore h groups = - foldM addGroupRegData emptyStoreData groups >>= mkDirectoryStore_ (Just h) - where - addGroupRegData d gr@GroupRegData {dbGroupId_ = gId} = do - gr' <- dataToGroupReg gr - let !grs' = gr' : groupRegs_ d - pure $ case grDirectoryStatus $ groupRegStatus_ gr of - DSListed -> - let !listed = S.insert gId $ listedGroups_ d - !promoted = (if promoted_ gr then S.insert gId else id) $ promotedGroups_ d - in d {groupRegs_ = grs', listedGroups_ = listed, promotedGroups_ = promoted} - DSReserved -> - let !reserved = S.insert gId $ reservedGroups_ d - in d {groupRegs_ = grs', reservedGroups_ = reserved} - DSRegistered -> d {groupRegs_ = grs'} - DSRemoved -> d - -mkDirectoryStore_ :: Maybe Handle -> DirectoryStoreData -> IO DirectoryStore -mkDirectoryStore_ h d = do - groupRegs <- newTVarIO $ groupRegs_ d - listedGroups <- newTVarIO $ listedGroups_ d - promotedGroups <- newTVarIO $ promotedGroups_ d - reservedGroups <- newTVarIO $ reservedGroups_ d - pure DirectoryStore {groupRegs, listedGroups, promotedGroups, reservedGroups, directoryLogFile = h} - -readDirectoryData :: FilePath -> IO [GroupRegData] -readDirectoryData f = - sortOn dbGroupId_ . M.elems +readDirectoryLogData :: FilePath -> IO [GroupReg] +readDirectoryLogData f = + sortOn dbGroupId . M.elems <$> (foldM processDLR M.empty . B.lines =<< B.readFile f) where - processDLR :: Map GroupId GroupRegData -> ByteString -> IO (Map GroupId GroupRegData) + processDLR :: Map GroupId GroupReg -> ByteString -> IO (Map GroupId GroupReg) processDLR m l = case strDecode l of Left e -> m <$ putStrLn ("Error parsing log record: " <> e <> ", " <> B.unpack (B.take 80 l)) Right r -> case r of - GRCreate gr@GroupRegData {dbGroupId_ = gId} -> do + GRCreate gr@GroupReg {dbGroupId = gId} -> do when (isJust $ M.lookup gId m) $ putStrLn $ "Warning: duplicate group with ID " <> show gId <> ", group replaced." @@ -479,19 +573,12 @@ readDirectoryData f = GRDelete gId -> case M.lookup gId m of Just _ -> pure $ M.delete gId m Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", deletion ignored.") - GRUpdateStatus gId groupRegStatus_ -> case M.lookup gId m of - Just gr -> pure $ M.insert gId gr {groupRegStatus_} m + GRUpdateStatus gId groupRegStatus -> case M.lookup gId m of + Just gr -> pure $ M.insert gId gr {groupRegStatus} m Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", status update ignored.") - GRUpdatePromotion gId promoted_ -> case M.lookup gId m of - Just gr -> pure $ M.insert gId gr {promoted_} m + GRUpdatePromotion gId promoted -> case M.lookup gId m of + Just gr -> pure $ M.insert gId gr {promoted} m Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", promotion update ignored.") GRUpdateOwner gId grOwnerId -> case M.lookup gId m of - Just gr -> pure $ M.insert gId gr {dbOwnerMemberId_ = Just grOwnerId} m + Just gr -> pure $ M.insert gId gr {dbOwnerMemberId = Just grOwnerId} m Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", owner update ignored.") - -writeDirectoryData :: FilePath -> [GroupRegData] -> IO Handle -writeDirectoryData f grs = do - h <- openFile f WriteMode - hSetBuffering h LineBuffering - forM_ grs $ B.hPutStrLn h . strEncode . GRCreate - pure h diff --git a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs new file mode 100644 index 0000000000..06a1846d3f --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs @@ -0,0 +1,115 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Directory.Store.Migrate where + +import Control.Monad +import Control.Monad.Except +import qualified Data.ByteString.Char8 as B +import Data.List (find) +import Directory.Options +import Directory.Store +import Simplex.Chat (createChatDatabase) +import Simplex.Chat.Controller (ChatConfig (..), ChatDatabase (..)) +import Simplex.Chat.Options (CoreChatOpts (..)) +import Simplex.Chat.Options.DB +import Simplex.Chat.Protocol (supportedChatVRange) +import Simplex.Chat.Store.Groups (getHostMember) +import Simplex.Chat.Store.Profiles (getUsers) +import Simplex.Chat.Types +import Simplex.Messaging.Agent.Store.Common +import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Agent.Store.Interface (closeDBStore, migrateDBSchema) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..)) +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Util (whenM) +import System.Directory (doesFileExist, renamePath) +import System.Exit (exitFailure) +import System.IO (IOMode (..), withFile) + +#if defined(dbPostgres) +import Directory.Store.Postgres.Migrations +#else +import Directory.Store.SQLite.Migrations +#endif + +runDirectoryMigrations :: DirectoryOpts -> ChatConfig -> DBStore -> IO () +runDirectoryMigrations opts ChatConfig {confirmMigrations} chatStore = + migrateDBSchema + chatStore + (toDBOpts dbOptions chatSuffix False) + (Just "sx_directory_migrations") + directorySchemaMigrations + MigrationConfig {confirm, backupPath = Nothing} + >>= either (exit . ("directory migrations " <>) . show) pure + where + DirectoryOpts {coreOptions = CoreChatOpts {dbOptions, yesToUpMigrations}} = opts + confirm = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations + +checkDirectoryLog :: DirectoryOpts -> ChatConfig -> IO () +checkDirectoryLog opts cfg = + withDirectoryLog opts $ \logFile -> withChatStore opts $ \st -> do + runDirectoryMigrations opts cfg st + gs <- readDirectoryLogData logFile + withActiveUser st $ \user -> withTransaction st $ \db -> do + mapM_ (verifyGroupRegistration db user) gs + putStrLn $ show (length gs) <> " group registrations OK" + +importDirectoryLogToDB :: DirectoryOpts -> ChatConfig -> IO () +importDirectoryLogToDB opts cfg = do + withDirectoryLog opts $ \logFile -> withChatStore opts $ \st -> do + runDirectoryMigrations opts cfg st + gs <- readDirectoryLogData logFile + withActiveUser st $ \user -> withTransaction st $ \db -> do + forM_ gs $ \gr -> do + verifyGroupRegistration db user gr + insertGroupReg db gr + renamePath logFile (logFile ++ ".bak") + putStrLn $ show (length gs) <> " group registrations imported" + +exit :: String -> IO a +exit err = putStrLn ("Error: " <> err) >> exitFailure + +exportDBToDirectoryLog :: DirectoryOpts -> ChatConfig -> IO () +exportDBToDirectoryLog opts cfg = + withDirectoryLog opts $ \logFile -> withChatStore opts $ \st -> do + whenM (doesFileExist logFile) $ exit $ "directory log file " ++ logFile ++ " already exists" + runDirectoryMigrations opts cfg st + withActiveUser st $ \user -> do + gs <- withFile logFile WriteMode $ \h -> withTransaction st $ \db -> do + gs <- getAllGroupRegs_ db user + forM_ gs $ \(_, gr) -> do + verifyGroupRegistration db user gr + B.hPutStrLn h $ strEncode $ GRCreate gr + pure gs + putStrLn $ show (length gs) <> " group registrations exported" + +verifyGroupRegistration :: DB.Connection -> User -> GroupReg -> IO () +verifyGroupRegistration db user GroupReg {dbGroupId = gId, dbContactId = ctId, dbOwnerMemberId = mId} = + runExceptT (getHostMember db supportedChatVRange user gId) >>= \case + Left e -> exit $ "error loading group " <> show gId <> " host member: " <> show e + Right GroupMember {groupMemberId = mId', memberContactId = ctId'} -> do + unless (mId == Just mId') $ exit $ "bad group " <> show gId <> " host member ID: " <> show mId' + unless (Just ctId == ctId') $ exit $ "bad group " <> show gId <> " contact ID: " <> show ctId' + +withDirectoryLog :: DirectoryOpts -> (FilePath -> IO ()) -> IO () +withDirectoryLog DirectoryOpts {directoryLog} action = + maybe (exit "directory log file not specified") action directoryLog + +withChatStore :: DirectoryOpts -> (DBStore -> IO ()) -> IO () +withChatStore DirectoryOpts {coreOptions = CoreChatOpts {dbOptions, yesToUpMigrations, migrationBackupPath}} action = + createChatDatabase dbOptions migrationConfig >>= \case + Left e -> exit $ show e + Right ChatDatabase {chatStore, agentStore} -> do + action chatStore + closeDBStore chatStore + closeDBStore agentStore + where + migrationConfig = MigrationConfig (if yesToUpMigrations then MCYesUp else MCConsole) migrationBackupPath + +withActiveUser :: DBStore -> (User -> IO ()) -> IO () +withActiveUser st action = withTransaction st getUsers >>= maybe (exit "no active user") action . find activeUser diff --git a/apps/simplex-directory-service/src/Directory/Store/Postgres/Migrations.hs b/apps/simplex-directory-service/src/Directory/Store/Postgres/Migrations.hs new file mode 100644 index 0000000000..4a801fee74 --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Store/Postgres/Migrations.hs @@ -0,0 +1,52 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE QuasiQuotes #-} + +module Directory.Store.Postgres.Migrations where + +import Data.List (sortOn) +import Data.Text (Text) +import qualified Data.Text as T +import Simplex.Messaging.Agent.Store.Shared (Migration (..)) +import Text.RawString.QQ (r) + +directorySchemaMigrations :: [Migration] +directorySchemaMigrations = sortOn name $ map migration schemaMigrations + where + migration (name, up, down) = Migration {name, up, down} + +schemaMigrations :: [(String, Text, Maybe Text)] +schemaMigrations = + [ ("20250924_directory_schema", m20250924_directory_schema, Just down_m20250924_directory_schema) + ] + +m20250924_directory_schema :: Text +m20250924_directory_schema = + T.pack + [r| +CREATE TABLE sx_directory_group_regs( + group_reg_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + group_id BIGINT NOT NULL REFERENCES groups ON UPDATE RESTRICT ON DELETE CASCADE, + user_group_reg_id BIGINT NOT NULL, + contact_id BIGINT NOT NULL REFERENCES contacts(contact_id) ON UPDATE RESTRICT ON DELETE CASCADE, + owner_member_id BIGINT REFERENCES group_members(group_member_id) ON UPDATE RESTRICT ON DELETE CASCADE, + group_reg_status TEXT NOT NULL, + group_promoted SMALLINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); + +CREATE UNIQUE INDEX idx_sx_directory_group_regs_group_id ON sx_directory_group_regs(group_id); +CREATE UNIQUE INDEX idx_sx_directory_group_regs_owner_member_id ON sx_directory_group_regs(owner_member_id); +CREATE UNIQUE INDEX idx_sx_directory_group_regs_owner_contact_id_user_group_reg_id ON sx_directory_group_regs(contact_id, user_group_reg_id); + |] + +down_m20250924_directory_schema :: Text +down_m20250924_directory_schema = + T.pack + [r| +DROP INDEX idx_sx_directory_group_regs_group_id; +DROP INDEX idx_sx_directory_group_regs_owner_member_id; +DROP INDEX idx_sx_directory_group_regs_owner_contact_id_user_group_reg_id; + +DROP TABLE sx_directory_group_regs; + |] diff --git a/apps/simplex-directory-service/src/Directory/Store/SQLite/Migrations.hs b/apps/simplex-directory-service/src/Directory/Store/SQLite/Migrations.hs new file mode 100644 index 0000000000..f35f9e250a --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Store/SQLite/Migrations.hs @@ -0,0 +1,49 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE QuasiQuotes #-} + +module Directory.Store.SQLite.Migrations (directorySchemaMigrations) where + +import Data.List (sortOn) +import Database.SQLite.Simple (Query (..)) +import Database.SQLite.Simple.QQ (sql) +import Simplex.Messaging.Agent.Store.Shared (Migration (..)) + +directorySchemaMigrations :: [Migration] +directorySchemaMigrations = sortOn name $ map migration schemaMigrations + where + migration (name, up, down) = Migration {name, up = fromQuery up, down = fromQuery <$> down} + +schemaMigrations :: [(String, Query, Maybe Query)] +schemaMigrations = + [ ("20250924_directory_schema", m20250924_directory_schema, Just down_m20250924_directory_schema) + ] + +m20250924_directory_schema :: Query +m20250924_directory_schema = + [sql| +CREATE TABLE sx_directory_group_regs( + group_reg_id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL REFERENCES groups ON UPDATE RESTRICT ON DELETE CASCADE, + user_group_reg_id INTEGER NOT NULL, + contact_id INTEGER NOT NULL REFERENCES contacts(contact_id) ON UPDATE RESTRICT ON DELETE CASCADE, + owner_member_id INTEGER REFERENCES group_members(group_member_id) ON UPDATE RESTRICT ON DELETE CASCADE, + group_reg_status TEXT NOT NULL, + group_promoted INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); + +CREATE UNIQUE INDEX idx_sx_directory_group_regs_group_id ON sx_directory_group_regs(group_id); +CREATE UNIQUE INDEX idx_sx_directory_group_regs_owner_member_id ON sx_directory_group_regs(owner_member_id); +CREATE UNIQUE INDEX idx_sx_directory_group_regs_owner_contact_id_user_group_reg_id ON sx_directory_group_regs(contact_id, user_group_reg_id); + |] + +down_m20250924_directory_schema :: Query +down_m20250924_directory_schema = + [sql| +DROP INDEX idx_sx_directory_group_regs_group_id; +DROP INDEX idx_sx_directory_group_regs_owner_member_id; +DROP INDEX idx_sx_directory_group_regs_owner_contact_id_user_group_reg_id; + +DROP TABLE sx_directory_group_regs; + |] diff --git a/apps/simplex-directory-service/src/Directory/Util.hs b/apps/simplex-directory-service/src/Directory/Util.hs index 379da73003..a4b79a1bef 100644 --- a/apps/simplex-directory-service/src/Directory/Util.hs +++ b/apps/simplex-directory-service/src/Directory/Util.hs @@ -5,27 +5,27 @@ module Directory.Util where -import qualified Control.Exception as E import Control.Logger.Simple import Control.Monad.Except import Data.Text (Text) +import qualified Data.Text as T import Simplex.Chat.Controller -import Simplex.Chat.Store.Shared (StoreError (..)) import Simplex.Chat.Types import Simplex.Messaging.Agent.Store.Common (withTransaction) import qualified Simplex.Messaging.Agent.Store.DB as DB -import Simplex.Messaging.Util (tshow) +import Simplex.Messaging.Util (catchAll) vr :: ChatController -> VersionRangeChat vr ChatController {config = ChatConfig {chatVRange}} = chatVRange {-# INLINE vr #-} -withDB' :: Text -> ChatController -> (DB.Connection -> IO a) -> IO (Maybe a) +withDB' :: Text -> ChatController -> (DB.Connection -> IO a) -> IO (Either String a) withDB' cxt cc a = withDB cxt cc $ ExceptT . fmap Right . a -withDB :: Text -> ChatController -> (DB.Connection -> ExceptT StoreError IO a) -> IO (Maybe a) +withDB :: Text -> ChatController -> (DB.Connection -> ExceptT String IO a) -> IO (Either String a) withDB cxt ChatController {chatStore} action = do - r_ :: Either ChatError a <- withTransaction chatStore (runExceptT . withExceptT ChatErrorStore . action) `E.catches` handleDBErrors + r_ <- withTransaction chatStore (runExceptT . action) `catchAll` (pure . Left . show) case r_ of - Right r -> pure $ Just r - Left e -> Nothing <$ logError ("Database error: " <> cxt <> " " <> tshow e) + Left e -> logError $ "Database error: " <> cxt <> " " <> T.pack e + Right _ -> pure () + pure r_ diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index dd1dd256d0..f3bb915665 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -1465,7 +1465,7 @@ Get groups. GroupsList: Groups. - type: "groupsList" - user: [User](./TYPES.md#user) -- groups: [[GroupInfoSummary](./TYPES.md#groupinfosummary)] +- groups: [[GroupInfo](./TYPES.md#groupinfo)] ChatCmdError: Command error. - type: "chatCmdError" diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 6207b1bf64..be63c812e7 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -89,7 +89,6 @@ This file is generated automatically. - [GroupFeature](#groupfeature) - [GroupFeatureEnabled](#groupfeatureenabled) - [GroupInfo](#groupinfo) -- [GroupInfoSummary](#groupinfosummary) - [GroupLink](#grouplink) - [GroupLinkPlan](#grouplinkplan) - [GroupMember](#groupmember) @@ -2148,20 +2147,11 @@ MemberSupport: - chatItemTTL: int64? - uiThemes: [UIThemeEntityOverrides](#uithemeentityoverrides)? - customData: JSONObject? +- groupSummary: [GroupSummary](#groupsummary) - membersRequireAttention: int - viaGroupLinkUri: string? ---- - -## GroupInfoSummary - -**Record type**: -- groupInfo: [GroupInfo](#groupinfo) -- groupSummary: [GroupSummary](#groupsummary) -- groupLink: [GroupLink](#grouplink)? - - --- ## GroupLink @@ -2352,7 +2342,7 @@ Known: ## GroupSummary **Record type**: -- currentMembers: int +- currentMembers: int64 --- diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index d74f6d37dd..f8a858dc0c 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -266,7 +266,6 @@ chatTypesDocsData = (sti @GroupFeature, STEnum, "GF", [], "", ""), (sti @GroupFeatureEnabled, STEnum, "FE", [], "", ""), (sti @GroupInfo, STRecord, "", [], "", ""), - (sti @GroupInfoSummary, STRecord, "", [], "", ""), (sti @GroupLink, STRecord, "", [], "", ""), (sti @GroupLinkPlan, STUnion, "GLP", [], "", ""), (sti @GroupMember, STRecord, "", [], "", ""), @@ -453,7 +452,6 @@ deriving instance Generic GroupChatScopeInfo deriving instance Generic GroupFeature deriving instance Generic GroupFeatureEnabled deriving instance Generic GroupInfo -deriving instance Generic GroupInfoSummary deriving instance Generic GroupLink deriving instance Generic GroupLinkPlan deriving instance Generic GroupMember diff --git a/cabal.project b/cabal.project index 0452f09216..622a0d2069 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 54a2a6c9051f610b8c7533369d9e9cce81af06ad + tag: 99f40ae109fba4e39684056fc9acf96bb6cc8c26 source-repository-package type: git diff --git a/packages/simplex-chat-client/types/typescript/src/responses.ts b/packages/simplex-chat-client/types/typescript/src/responses.ts index 2ea67432f5..ea49478b15 100644 --- a/packages/simplex-chat-client/types/typescript/src/responses.ts +++ b/packages/simplex-chat-client/types/typescript/src/responses.ts @@ -244,7 +244,7 @@ export namespace CR { export interface GroupsList extends Interface { type: "groupsList" user: T.User - groups: T.GroupInfoSummary[] + groups: T.GroupInfo[] } export interface Invitation extends Interface { diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 97bc45f3ce..be204972e2 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -2444,16 +2444,11 @@ export interface GroupInfo { chatItemTTL?: number // int64 uiThemes?: UIThemeEntityOverrides customData?: object + groupSummary: GroupSummary membersRequireAttention: number // int viaGroupLinkUri?: string } -export interface GroupInfoSummary { - groupInfo: GroupInfo - groupSummary: GroupSummary - groupLink?: GroupLink -} - export interface GroupLink { userContactLinkId: number // int64 connLinkContact: CreatedConnLink @@ -2605,7 +2600,7 @@ export interface GroupShortLinkData { } export interface GroupSummary { - currentMembers: number // int + currentMembers: number // int64 } export interface GroupSupportChat { diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 1ef1b2f51f..47be471d3d 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."54a2a6c9051f610b8c7533369d9e9cce81af06ad" = "02c6hh5vffm23gz2gkn6rq2ifcyj5c9s6kz0kyk055i78gsfhp43"; + "https://github.com/simplex-chat/simplexmq.git"."99f40ae109fba4e39684056fc9acf96bb6cc8c26" = "04k7g4q0jlbg14wx47w076vk0q6dh709vw24n1q5im77y5z0v0ak"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 529873b7d3..7c53a5d705 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -118,6 +118,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20250801_via_group_link_uri Simplex.Chat.Store.Postgres.Migrations.M20250802_chat_peer_type Simplex.Chat.Store.Postgres.Migrations.M20250813_delivery_tasks + Simplex.Chat.Store.Postgres.Migrations.M20250919_group_summary else exposed-modules: Simplex.Chat.Archive @@ -259,6 +260,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20250801_via_group_link_uri Simplex.Chat.Store.SQLite.Migrations.M20250802_chat_peer_type Simplex.Chat.Store.SQLite.Migrations.M20250813_delivery_tasks + Simplex.Chat.Store.SQLite.Migrations.M20250919_group_summary other-modules: Paths_simplex_chat hs-source-dirs: @@ -462,6 +464,7 @@ executable simplex-directory-service Directory.Search Directory.Service Directory.Store + Directory.Store.Migrate Directory.Util Paths_simplex_chat ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded @@ -489,7 +492,17 @@ executable simplex-directory-service , unicode-transforms ==0.4.* default-language: Haskell2010 if flag(client_postgres) + other-modules: + Directory.Store.Postgres.Migrations + build-depends: + postgresql-simple ==0.7.* + , raw-strings-qq ==1.1.* cpp-options: -DdbPostgres + else + other-modules: + Directory.Store.SQLite.Migrations + build-depends: + sqlcipher-simple ==0.4.* if impl(ghc >= 9.6.2) build-depends: bytestring ==0.11.* @@ -550,6 +563,7 @@ test-suite simplex-chat-test Directory.Search Directory.Service Directory.Store + Directory.Store.Migrate Directory.Util Paths_simplex_chat if flag(client_postgres) @@ -603,10 +617,15 @@ test-suite simplex-chat-test , uri-bytestring >=0.3.3.1 && <0.4 default-language: Haskell2010 if flag(client_postgres) + other-modules: + Directory.Store.Postgres.Migrations build-depends: postgresql-simple ==0.7.* + , raw-strings-qq ==1.1.* cpp-options: -DdbPostgres else + other-modules: + Directory.Store.SQLite.Migrations build-depends: sqlcipher-simple ==0.4.* if impl(ghc >= 9.6.2) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 45fd264afc..0dfe526cdb 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -313,8 +313,8 @@ data ChatCommand | APIGetAppSettings (Maybe AppSettings) | APIGetChatTags UserId | APIGetChats {userId :: UserId, pendingConnections :: Bool, pagination :: PaginationByTime, query :: ChatListQuery} - | APIGetChat {chatRef :: ChatRef, contentTag :: Maybe MsgContentTag, chatPagination :: ChatPagination, search :: Maybe String} - | APIGetChatItems {chatPagination :: ChatPagination, search :: Maybe String} + | APIGetChat {chatRef :: ChatRef, contentTag :: Maybe MsgContentTag, chatPagination :: ChatPagination, search :: Maybe Text} + | APIGetChatItems {chatPagination :: ChatPagination, search :: Maybe Text} | APIGetChatItemInfo {chatRef :: ChatRef, chatItemId :: ChatItemId} | APISendMessages {sendRef :: SendRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessages :: NonEmpty ComposedMessage} | APICreateChatTag ChatTagData @@ -511,8 +511,8 @@ data ChatCommand | ClearGroup GroupName | ListMembers GroupName | ListMemberSupportChats GroupName - | APIListGroups {userId :: UserId, contactId_ :: Maybe ContactId, search :: Maybe String} - | ListGroups (Maybe ContactName) (Maybe String) + | APIListGroups {userId :: UserId, contactId_ :: Maybe ContactId, search :: Maybe Text} + | ListGroups (Maybe ContactName) (Maybe Text) | UpdateGroupNames GroupName GroupProfile | ShowGroupProfile GroupName | UpdateGroupDescription GroupName (Maybe Text) @@ -524,7 +524,7 @@ data ChatCommand | SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: Text, message :: Text} | ClearNoteFolder | LastChats (Maybe Int) -- UserId (not used in UI) - | LastMessages (Maybe ChatName) Int (Maybe String) -- UserId (not used in UI) + | LastMessages (Maybe ChatName) Int (Maybe Text) -- UserId (not used in UI) | LastChatItemId (Maybe ChatName) Int -- UserId (not used in UI) | ShowChatItem (Maybe ChatItemId) -- UserId (not used in UI) | ShowChatItemInfo ChatName Text @@ -647,7 +647,7 @@ data ChatResponse | CRChatItemTTL {user :: User, chatItemTTL :: Maybe Int64} | CRNetworkConfig {networkConfig :: NetworkConfig} | CRContactInfo {user :: User, contact :: Contact, connectionStats_ :: Maybe ConnectionStats, customUserProfile :: Maybe Profile} - | CRGroupInfo {user :: User, groupInfo :: GroupInfo, groupSummary :: GroupSummary} + | CRGroupInfo {user :: User, groupInfo :: GroupInfo} | CRGroupMemberInfo {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats_ :: Maybe ConnectionStats} | CRQueueInfo {user :: User, rcvMsgInfo :: Maybe RcvMsgInfo, queueInfo :: ServerQueueInfo} | CRContactSwitchStarted {user :: User, contact :: Contact, connectionStats :: ConnectionStats} @@ -682,7 +682,7 @@ data ChatResponse | CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest, contact_ :: Maybe Contact} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool} - | CRGroupsList {user :: User, groups :: [GroupInfoSummary]} + | CRGroupsList {user :: User, groups :: [GroupInfo]} | CRSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} | CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus | CRFileTransferStatusXFTP User AChatItem diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 41d684c114..defaf46d20 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1601,9 +1601,8 @@ processChatCommand vr nm = \case case activeConn of Just conn -> getConnQueueInfo user conn Nothing -> throwChatError $ CEContactNotActive ct - APIGroupInfo gId -> withUser $ \user -> do - (g, s) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> liftIO (getGroupSummary db user gId) - pure $ CRGroupInfo user g s + APIGroupInfo gId -> withUser $ \user -> + CRGroupInfo user <$> withFastStore (\db -> getGroupInfo db vr user gId) APIGroupMemberInfo gId gMemberId -> withUser $ \user -> do (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m) @@ -2570,7 +2569,7 @@ processChatCommand vr nm = \case let memberSupportChats = filter (isJust . supportChat) members pure $ CRMemberSupportChats user gInfo memberSupportChats APIListGroups userId contactId_ search_ -> withUserId userId $ \user -> - CRGroupsList user <$> withFastStore' (\db -> getUserGroupsWithSummary db vr user contactId_ search_) + CRGroupsList user <$> withFastStore' (\db -> getBaseGroupDetails db vr user contactId_ search_) ListGroups cName_ search_ -> withUser $ \user@User {userId} -> do ct_ <- forM cName_ $ \cName -> withFastStore $ \db -> getContactByName db vr user cName processChatCommand vr nm $ APIListGroups userId (contactId' <$> ct_) search_ @@ -2923,7 +2922,7 @@ processChatCommand vr nm = \case ShowVersion -> do -- simplexmqCommitQ makes iOS builds crash m( let versionInfo = coreVersionInfo "" - chatMigrations <- map upMigration <$> withFastStore' getCurrentMigrations + chatMigrations <- map upMigration <$> withFastStore' (getCurrentMigrations Nothing) agentMigrations <- withAgent getAgentMigrations pure $ CRVersionInfo {versionInfo, chatMigrations, agentMigrations} DebugLocks -> lift $ do @@ -4502,8 +4501,8 @@ chatCommandP = <*> (A.space *> paginationByTimeP <|> pure (PTLast 5000)) <*> (A.space *> jsonP <|> pure clqNoFilters) ), - "/_get chat " *> (APIGetChat <$> chatRefP <*> optional (" content=" *> strP) <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)), - "/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)), + "/_get chat " *> (APIGetChat <$> chatRefP <*> optional (" content=" *> strP) <* A.space <*> chatPaginationP <*> optional (" search=" *> textP)), + "/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> textP)), "/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal), "/_send " *> (APISendMessages <$> sendRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), "/_create tag " *> (APICreateChatTag <$> jsonP), @@ -4660,8 +4659,8 @@ chatCommandP = "/clear " *> char_ '@' *> (ClearContact <$> displayNameP), ("/members " <|> "/ms ") *> char_ '#' *> (ListMembers <$> displayNameP), "/member support chats #" *> (ListMemberSupportChats <$> displayNameP), - "/_groups" *> (APIListGroups <$> A.decimal <*> optional (" @" *> A.decimal) <*> optional (A.space *> stringP)), - ("/groups" <|> "/gs") *> (ListGroups <$> optional (" @" *> displayNameP) <*> optional (A.space *> stringP)), + "/_groups" *> (APIListGroups <$> A.decimal <*> optional (" @" *> A.decimal) <*> optional (A.space *> textP)), + ("/groups" <|> "/gs") *> (ListGroups <$> optional (" @" *> displayNameP) <*> optional (A.space *> textP)), "/_group_profile #" *> (APIUpdateGroupProfile <$> A.decimal <* A.space <*> jsonP), ("/group_profile " <|> "/gp ") *> char_ '#' *> (UpdateGroupNames <$> displayNameP <* A.space <*> groupProfile), ("/group_profile " <|> "/gp ") *> char_ '#' *> (ShowGroupProfile <$> displayNameP), @@ -4715,7 +4714,7 @@ chatCommandP = "/feed " *> (SendMessageBroadcast . MCText <$> msgTextP), ("/chats" <|> "/cs") *> (LastChats <$> (" all" $> Nothing <|> Just <$> (A.space *> A.decimal <|> pure 20))), ("/tail" <|> "/t") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP <*> pure Nothing), - ("/search" <|> "/?") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP <*> (Just <$> (A.space *> stringP))), + ("/search" <|> "/?") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP <*> (Just <$> (A.space *> textP))), "/last_item_id" *> (LastChatItemId <$> optional (A.space *> chatNameP) <*> (A.space *> A.decimal <|> pure 0)), "/show" *> (ShowLiveItems <$> (A.space *> onOffP <|> pure True)), "/show " *> (ShowChatItem . Just <$> A.decimal), diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 8930acf3c1..9e5bf16351 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -16,7 +16,7 @@ module Simplex.Chat.Store.Connections getConnectionEntityViaShortLink, getContactConnEntityByConnReqHash, getConnectionsToSubscribe, - unsetConnectionToSubscribe + unsetConnectionToSubscribe, ) where @@ -143,7 +143,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, diff --git a/src/Simplex/Chat/Store/ContactRequest.hs b/src/Simplex/Chat/Store/ContactRequest.hs index d9ad9ec763..02be9a40a2 100644 --- a/src/Simplex/Chat/Store/ContactRequest.hs +++ b/src/Simplex/Chat/Store/ContactRequest.hs @@ -14,9 +14,9 @@ module Simplex.Chat.Store.ContactRequest setContactAcceptedXContactId, setBusinessChatAcceptedXContactId, setRequestSharedMsgIdForContact, - setRequestSharedMsgIdForGroup + setRequestSharedMsgIdForGroup, ) - where +where import Control.Monad import Control.Monad.Except @@ -24,11 +24,11 @@ import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) import Data.Int (Int64) import Data.Time.Clock (getCurrentTime) -import Simplex.Chat.Protocol (businessChatsVersion, MsgContent) +import Simplex.Chat.Protocol (MsgContent, businessChatsVersion) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Groups -import Simplex.Chat.Store.Shared import Simplex.Chat.Store.Profiles +import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (InvitationId) @@ -47,21 +47,21 @@ import Database.SQLite.Simple.QQ (sql) #endif createOrUpdateContactRequest :: - DB.Connection - -> TVar ChaChaDRG - -> VersionRangeChat - -> User - -> Int64 - -> UserContactLink - -> Bool - -> InvitationId - -> VersionRangeChat - -> Profile - -> Maybe XContactId - -> Maybe SharedMsgId - -> Maybe (SharedMsgId, MsgContent) - -> PQSupport - -> ExceptT StoreError IO RequestStage + DB.Connection -> + TVar ChaChaDRG -> + VersionRangeChat -> + User -> + Int64 -> + UserContactLink -> + Bool -> + InvitationId -> + VersionRangeChat -> + Profile -> + Maybe XContactId -> + Maybe SharedMsgId -> + Maybe (SharedMsgId, MsgContent) -> + PQSupport -> + ExceptT StoreError IO RequestStage createOrUpdateContactRequest db gVar @@ -86,61 +86,62 @@ createOrUpdateContactRequest Just ct -> do cr <- liftIO $ getContactRequestByXContactId xContactId pure $ RSAcceptedRequest cr (REContact ct) - Nothing -> liftIO (getAcceptedBusinessChat xContactId) >>= \case - Just gInfo@GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do - clientMember <- getGroupMemberByMemberId db vr user gInfo customerId - cr <- liftIO $ getContactRequestByXContactId xContactId - pure $ RSAcceptedRequest cr (REBusinessChat gInfo clientMember) - Just GroupInfo {businessChat = Nothing} -> throwError SEInvalidBusinessChatContactRequest - -- 2) if no legacy accepted contact or business chat was found, next we try to find an existing request - Nothing -> - liftIO (getContactRequestByXContactId xContactId) >>= \case - -- 3a) if request was found, we update it - Just cr -> updateContactRequest cr - -- 3b) if no request was found, we create a new contact request - Nothing -> createContactRequest - where - getAcceptedContact :: XContactId -> IO (Maybe Contact) - getAcceptedContact xContactId = do - ct_ <- - maybeFirstRow (toContact vr user []) $ + Nothing -> + liftIO (getAcceptedBusinessChat xContactId) >>= \case + Just gInfo@GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do + clientMember <- getGroupMemberByMemberId db vr user gInfo customerId + cr <- liftIO $ getContactRequestByXContactId xContactId + pure $ RSAcceptedRequest cr (REBusinessChat gInfo clientMember) + Just GroupInfo {businessChat = Nothing} -> throwError SEInvalidBusinessChatContactRequest + -- 2) if no legacy accepted contact or business chat was found, next we try to find an existing request + Nothing -> + liftIO (getContactRequestByXContactId xContactId) >>= \case + -- 3a) if request was found, we update it + Just cr -> updateContactRequest cr + -- 3b) if no request was found, we create a new contact request + Nothing -> createContactRequest + where + getAcceptedContact :: XContactId -> IO (Maybe Contact) + getAcceptedContact xContactId = do + ct_ <- + maybeFirstRow (toContact vr user []) $ + DB.query + db + [sql| + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, + ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 + ORDER BY c.created_at DESC + LIMIT 1 + |] + (userId, xContactId) + mapM (addDirectChatTags db) ct_ + getAcceptedBusinessChat :: XContactId -> IO (Maybe GroupInfo) + getAcceptedBusinessChat xContactId = do + g_ <- + maybeFirstRow (toGroupInfo vr userContactId []) $ + DB.query + db + (groupInfoQuery <> " WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ?") + (xContactId, userId, userContactId) + mapM (addGroupChatTags db) g_ + getContactRequestByXContactId :: XContactId -> IO (Maybe UserContactRequest) + getContactRequestByXContactId xContactId = + maybeFirstRow toContactRequest $ DB.query db [sql| - SELECT - -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, - ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, - ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, - -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM contacts ct - JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id - LEFT JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 - ORDER BY c.created_at DESC - LIMIT 1 - |] - (userId, xContactId) - mapM (addDirectChatTags db) ct_ - getAcceptedBusinessChat :: XContactId -> IO (Maybe GroupInfo) - getAcceptedBusinessChat xContactId = do - g_ <- - maybeFirstRow (toGroupInfo vr userContactId []) $ - DB.query - db - (groupInfoQuery <> " WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ?") - (xContactId, userId, userContactId) - mapM (addGroupChatTags db) g_ - getContactRequestByXContactId :: XContactId -> IO (Maybe UserContactRequest) - getContactRequestByXContactId xContactId = - maybeFirstRow toContactRequest $ - DB.query - db - [sql| SELECT cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.contact_id, cr.business_group_id, cr.user_contact_link_id, @@ -154,79 +155,79 @@ createOrUpdateContactRequest AND cr.xcontact_id = ? LIMIT 1 |] - (userId, xContactId) - createContactRequest :: ExceptT StoreError IO RequestStage - createContactRequest = do - currentTs <- liftIO $ getCurrentTime - ExceptT $ withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do - liftIO $ - DB.execute - db - "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) - profileId <- liftIO $ insertedRowId db - liftIO $ - DB.execute - db - [sql| + (userId, xContactId) + createContactRequest :: ExceptT StoreError IO RequestStage + createContactRequest = do + currentTs <- liftIO $ getCurrentTime + ExceptT $ withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do + liftIO $ + DB.execute + db + "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) + profileId <- liftIO $ insertedRowId db + liftIO $ + DB.execute + db + [sql| INSERT INTO contact_requests (user_contact_link_id, agent_invitation_id, peer_chat_min_version, peer_chat_max_version, contact_profile_id, local_display_name, user_id, created_at, updated_at, xcontact_id, welcome_shared_msg_id, request_shared_msg_id, pq_support) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (uclId, Binary invId, minV, maxV, profileId, ldn, userId) - :. (currentTs, currentTs, xContactId_, welcomeMsgId_, fst <$> requestMsg_, pqSup) - ) - contactRequestId <- liftIO $ insertedRowId db - createRequestEntity ldn profileId contactRequestId currentTs - where - createRequestEntity ldn profileId contactRequestId currentTs - | businessAddress = - if isSimplexTeam && maxV < businessChatsVersion - then createContact' - else createBusinessChat - | otherwise = createContact' - where - createContact' = do - let ctUserPreferences = newContactUserPrefs user profile - liftIO $ - DB.execute - db - "INSERT INTO contacts (contact_profile_id, user_preferences, local_display_name, user_id, created_at, updated_at, chat_ts, contact_used, contact_request_id) VALUES (?,?,?,?,?,?,?,?,?)" - (profileId, ctUserPreferences, ldn, userId, currentTs, currentTs, currentTs, BI True, contactRequestId) - contactId <- liftIO $ insertedRowId db - liftIO $ - DB.execute - db - "UPDATE contact_requests SET contact_id = ? WHERE contact_request_id = ?" - (contactId, contactRequestId) - ucr <- getContactRequest db user contactRequestId - ct <- getContact db vr user contactId - pure $ RSCurrentRequest Nothing ucr (Just $ REContact ct) - createBusinessChat = do - let groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs $ preferences' user - (gInfo@GroupInfo {groupId}, clientMember) <- - createBusinessRequestGroup db vr gVar user cReqChatVRange profile profileId ldn groupPreferences - liftIO $ - DB.execute - db - "UPDATE contact_requests SET business_group_id = ? WHERE contact_request_id = ?" - (groupId, contactRequestId) - ucr <- getContactRequest db user contactRequestId - pure $ RSCurrentRequest Nothing ucr (Just $ REBusinessChat gInfo clientMember) - updateContactRequest :: UserContactRequest -> ExceptT StoreError IO RequestStage - updateContactRequest ucr@UserContactRequest {contactRequestId, contactId_, localDisplayName = oldLdn, profile = Profile {displayName = oldDisplayName}} = do - currentTs <- liftIO getCurrentTime - liftIO $ updateProfile currentTs - updateRequest currentTs - ucr' <- getContactRequest db user contactRequestId - re_ <- getRequestEntity ucr' - pure $ RSCurrentRequest (Just ucr) ucr' re_ - where - updateProfile currentTs = - DB.execute - db - [sql| + ( (uclId, Binary invId, minV, maxV, profileId, ldn, userId) + :. (currentTs, currentTs, xContactId_, welcomeMsgId_, fst <$> requestMsg_, pqSup) + ) + contactRequestId <- liftIO $ insertedRowId db + createRequestEntity ldn profileId contactRequestId currentTs + where + createRequestEntity ldn profileId contactRequestId currentTs + | businessAddress = + if isSimplexTeam && maxV < businessChatsVersion + then createContact' + else createBusinessChat + | otherwise = createContact' + where + createContact' = do + let ctUserPreferences = newContactUserPrefs user profile + liftIO $ + DB.execute + db + "INSERT INTO contacts (contact_profile_id, user_preferences, local_display_name, user_id, created_at, updated_at, chat_ts, contact_used, contact_request_id) VALUES (?,?,?,?,?,?,?,?,?)" + (profileId, ctUserPreferences, ldn, userId, currentTs, currentTs, currentTs, BI True, contactRequestId) + contactId <- liftIO $ insertedRowId db + liftIO $ + DB.execute + db + "UPDATE contact_requests SET contact_id = ? WHERE contact_request_id = ?" + (contactId, contactRequestId) + ucr <- getContactRequest db user contactRequestId + ct <- getContact db vr user contactId + pure $ RSCurrentRequest Nothing ucr (Just $ REContact ct) + createBusinessChat = do + let groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs $ preferences' user + (gInfo@GroupInfo {groupId}, clientMember) <- + createBusinessRequestGroup db vr gVar user cReqChatVRange profile profileId ldn groupPreferences + liftIO $ + DB.execute + db + "UPDATE contact_requests SET business_group_id = ? WHERE contact_request_id = ?" + (groupId, contactRequestId) + ucr <- getContactRequest db user contactRequestId + pure $ RSCurrentRequest Nothing ucr (Just $ REBusinessChat gInfo clientMember) + updateContactRequest :: UserContactRequest -> ExceptT StoreError IO RequestStage + updateContactRequest ucr@UserContactRequest {contactRequestId, contactId_, localDisplayName = oldLdn, profile = Profile {displayName = oldDisplayName}} = do + currentTs <- liftIO getCurrentTime + liftIO $ updateProfile currentTs + updateRequest currentTs + ucr' <- getContactRequest db user contactRequestId + re_ <- getRequestEntity ucr' + pure $ RSCurrentRequest (Just ucr) ucr' re_ + where + updateProfile currentTs = + DB.execute + db + [sql| UPDATE contact_profiles SET display_name = ?, full_name = ?, @@ -241,21 +242,20 @@ createOrUpdateContactRequest AND contact_request_id = ? ) |] - (displayName, fullName, shortDescr, image, contactLink, currentTs, userId, contactRequestId) - updateRequest currentTs = - if displayName == oldDisplayName - then - liftIO $ - DB.execute - db - [sql| + (displayName, fullName, shortDescr, image, contactLink, currentTs, userId, contactRequestId) + updateRequest currentTs = + if displayName == oldDisplayName + then + liftIO $ + DB.execute + db + [sql| UPDATE contact_requests SET agent_invitation_id = ?, pq_support = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, updated_at = ? WHERE user_id = ? AND contact_request_id = ? |] - (Binary invId, pqSup, minV, maxV, currentTs, userId, contactRequestId) - else - ExceptT $ withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do + (Binary invId, pqSup, minV, maxV, currentTs, userId, contactRequestId) + else ExceptT $ withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do liftIO $ do DB.execute db @@ -276,21 +276,21 @@ createOrUpdateContactRequest |] (ldn, currentTs, contactId) safeDeleteLDN db user oldLdn - getRequestEntity :: UserContactRequest -> ExceptT StoreError IO (Maybe RequestEntity) - getRequestEntity UserContactRequest {contactRequestId, contactId_, businessGroupId_} = - case (contactId_, businessGroupId_) of - (Just contactId, Nothing) -> do - ct <- getContact db vr user contactId - pure $ Just (REContact ct) - (Nothing, Just businessGroupId) -> do - gInfo <- getGroupInfo db vr user businessGroupId - case gInfo of - GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do - clientMember <- getGroupMemberByMemberId db vr user gInfo customerId - pure $ Just (REBusinessChat gInfo clientMember) - _ -> throwError SEInvalidBusinessChatContactRequest - (Nothing, Nothing) -> pure Nothing - _ -> throwError $ SEInvalidContactRequestEntity contactRequestId + getRequestEntity :: UserContactRequest -> ExceptT StoreError IO (Maybe RequestEntity) + getRequestEntity UserContactRequest {contactRequestId, contactId_, businessGroupId_} = + case (contactId_, businessGroupId_) of + (Just contactId, Nothing) -> do + ct <- getContact db vr user contactId + pure $ Just (REContact ct) + (Nothing, Just businessGroupId) -> do + gInfo <- getGroupInfo db vr user businessGroupId + case gInfo of + GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do + clientMember <- getGroupMemberByMemberId db vr user gInfo customerId + pure $ Just (REBusinessChat gInfo clientMember) + _ -> throwError SEInvalidBusinessChatContactRequest + (Nothing, Nothing) -> pure Nothing + _ -> throwError $ SEInvalidContactRequestEntity contactRequestId setContactAcceptedXContactId :: DB.Connection -> Contact -> XContactId -> IO () setContactAcceptedXContactId db Contact {contactId} xContactId = diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 7e9d7b4ae2..c57e6f691a 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -70,9 +70,7 @@ module Simplex.Chat.Store.Groups cleanupHostGroupLinkConn, deleteGroup, getUserGroupsToSubscribe, - getUserGroupDetails, - getUserGroupsWithSummary, - getGroupSummary, + getBaseGroupDetails, getContactGroupPreferences, getGroupInvitation, createNewContactMember, @@ -170,6 +168,7 @@ import Data.List (partition, sortOn) import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing) import Data.Ord (Down (..)) import Data.Text (Text) +import qualified Data.Text as T import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Messages @@ -362,6 +361,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, + groupSummary = GroupSummary 1, customData = Nothing, membersRequireAttention = 0, viaGroupLinkUri = Nothing @@ -436,6 +436,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, + groupSummary = GroupSummary 2, customData = Nothing, membersRequireAttention = 0, viaGroupLinkUri = Nothing @@ -934,65 +935,21 @@ getUserGroupsToSubscribe db user@User {userId} = do groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) rights <$> mapM (runExceptT . getGroupToSubscribe db user) groupIds -getUserGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo] -getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do - g_ <- - map (toGroupInfo vr userContactId []) - <$> DB.query - db - [sql| - SELECT - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, - g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, - g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, - mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, - mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, - mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts - FROM groups g - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members mu USING (group_id) - JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) - WHERE g.user_id = ? AND mu.contact_id = ? - AND (LOWER(gp.display_name) LIKE '%' || ? || '%' - OR LOWER(gp.full_name) LIKE '%' || ? || '%' - OR LOWER(gp.short_descr) LIKE '%' || ? || '%' - OR LOWER(gp.description) LIKE '%' || ? || '%' - ) - |] - (userId, userContactId, search, search, search, search) - mapM (addGroupChatTags db) g_ +getBaseGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe Text -> IO [GroupInfo] +getBaseGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do + map (toGroupInfo vr userContactId []) + <$> DB.query db (groupInfoQuery <> " " <> condition) (userId, userContactId, search, search, search, search) where - search = maybe "" (map toLower) search_ - -getUserGroupsWithSummary :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfoSummary] -getUserGroupsWithSummary db vr user _contactId_ search_ = do - gs <- getUserGroupDetails db vr user _contactId_ search_ - forM gs $ \g@GroupInfo {groupId} -> do - s <- getGroupSummary db user groupId - link_ <- eitherToMaybe <$> runExceptT (getGroupLink db user g) - pure $ GIS g s link_ - --- the statuses on non-current members should match memberCurrent' function -getGroupSummary :: DB.Connection -> User -> GroupId -> IO GroupSummary -getGroupSummary db User {userId} groupId = do - currentMembers_ <- - maybeFirstRow fromOnly $ - DB.query - db - [sql| - SELECT count (m.group_member_id) - FROM groups g - JOIN group_members m USING (group_id) - WHERE g.user_id = ? - AND g.group_id = ? - AND m.member_status NOT IN (?,?,?,?,?) - |] - (userId, groupId, GSMemRejected, GSMemRemoved, GSMemLeft, GSMemUnknown, GSMemInvited) - pure GroupSummary {currentMembers = fromMaybe 0 currentMembers_} + condition = + [sql| + WHERE g.user_id = ? AND mu.contact_id = ? + AND (LOWER(gp.display_name) LIKE '%' || ? || '%' + OR LOWER(gp.full_name) LIKE '%' || ? || '%' + OR LOWER(gp.short_descr) LIKE '%' || ? || '%' + OR LOWER(gp.description) LIKE '%' || ? || '%' + ) + |] + search = maybe "" (T.map toLower) search_ getContactGroupPreferences :: DB.Connection -> User -> Contact -> IO [(GroupMemberRole, FullGroupPreferences)] getContactGroupPreferences db User {userId} Contact {contactId} = do @@ -1915,7 +1872,7 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -2590,7 +2547,32 @@ createMemberContact quotaErrCounter = 0 } mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, preparedContact = Nothing, contactRequestId = Nothing, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, groupDirectInv = Nothing, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing} + pure + Contact + { contactId, + localDisplayName, + profile = memberProfile, + activeConn = Just ctConn, + viaGroup = Nothing, + contactUsed = True, + contactStatus = CSActive, + chatSettings = defaultChatSettings, + userPreferences, + mergedPreferences, + createdAt = currentTs, + updatedAt = currentTs, + chatTs = Just currentTs, + preparedContact = Nothing, + contactRequestId = Nothing, + contactGroupMemberId = Just groupMemberId, + contactGrpInvSent = False, + groupDirectInv = Nothing, + chatTags = [], + chatItemTTL = Nothing, + uiThemes = Nothing, + chatDeleted = False, + customData = Nothing + } getMemberContact :: DB.Connection -> VersionRangeChat -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) getMemberContact db vr user contactId = do diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 7eba39360f..47aabd16ee 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -1167,7 +1167,7 @@ checkContactHasItems db User {userId} Contact {contactId} = "SELECT EXISTS (SELECT 1 FROM chat_items WHERE user_id = ? AND contact_id = ?)" (userId, contactId) -getDirectChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) +getDirectChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe Text -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) getDirectChat db vr user contactId pagination search_ = do let search = fromMaybe "" search_ ct <- getContact db vr user contactId @@ -1177,18 +1177,18 @@ getDirectChat db vr user contactId pagination search_ = do CPBefore beforeId count -> (,Nothing) <$> getDirectChatBefore_ db user ct beforeId count search CPAround aroundId count -> getDirectChatAround_ db user ct aroundId count search CPInitial count -> do - unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" + unless (T.null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" getDirectChatInitial_ db user ct count -- the last items in reverse order (the last item in the conversation is the first in the returned list) -getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO (Chat 'CTDirect) +getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> Text -> IO (Chat 'CTDirect) getDirectChatLast_ db user ct count search = do ciIds <- getDirectChatItemIdsLast_ db user ct count search ts <- getCurrentTime cis <- mapM (safeGetDirectItem db user ct ts) ciIds pure $ Chat (DirectChat ct) (reverse cis) emptyChatStats -getDirectChatItemIdsLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO [ChatItemId] +getDirectChatItemIdsLast_ :: DB.Connection -> User -> Contact -> Int -> Text -> IO [ChatItemId] getDirectChatItemIdsLast_ db User {userId} Contact {contactId} count search = map fromOnly <$> DB.query @@ -1245,7 +1245,7 @@ getDirectChatItemLast db user@User {userId} contactId = do (userId, contactId) getDirectChatItem db user contactId chatItemId -getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) +getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChatAfter_ db user ct@Contact {contactId} afterId count search = do afterCI <- getDirectChatItem db user contactId afterId ciIds <- liftIO $ getDirectCIsAfter_ db user ct afterCI count search @@ -1253,7 +1253,7 @@ getDirectChatAfter_ db user ct@Contact {contactId} afterId count search = do cis <- liftIO $ mapM (safeGetDirectItem db user ct ts) ciIds pure $ Chat (DirectChat ct) cis emptyChatStats -getDirectCIsAfter_ :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> Int -> String -> IO [ChatItemId] +getDirectCIsAfter_ :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> Int -> Text -> IO [ChatItemId] getDirectCIsAfter_ db User {userId} Contact {contactId} afterCI count search = map fromOnly <$> DB.query @@ -1268,7 +1268,7 @@ getDirectCIsAfter_ db User {userId} Contact {contactId} afterCI count search = |] (userId, contactId, search, ciCreatedAt afterCI, ciCreatedAt afterCI, cChatItemId afterCI, count) -getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) +getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChatBefore_ db user ct@Contact {contactId} beforeId count search = do beforeCI <- getDirectChatItem db user contactId beforeId ciIds <- liftIO $ getDirectCIsBefore_ db user ct beforeCI count search @@ -1276,7 +1276,7 @@ getDirectChatBefore_ db user ct@Contact {contactId} beforeId count search = do cis <- liftIO $ mapM (safeGetDirectItem db user ct ts) ciIds pure $ Chat (DirectChat ct) (reverse cis) emptyChatStats -getDirectCIsBefore_ :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> Int -> String -> IO [ChatItemId] +getDirectCIsBefore_ :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> Int -> Text -> IO [ChatItemId] getDirectCIsBefore_ db User {userId} Contact {contactId} beforeCI count search = map fromOnly <$> DB.query @@ -1291,12 +1291,12 @@ getDirectCIsBefore_ db User {userId} Contact {contactId} beforeCI count search = |] (userId, contactId, search, ciCreatedAt beforeCI, ciCreatedAt beforeCI, cChatItemId beforeCI, count) -getDirectChatAround_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) +getDirectChatAround_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) getDirectChatAround_ db user ct aroundId count search = do stats <- liftIO $ getContactStats_ db user ct getDirectChatAround' db user ct aroundId count search stats -getDirectChatAround' :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) +getDirectChatAround' :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> Text -> ChatStats -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) getDirectChatAround' db user ct@Contact {contactId} aroundId count search stats = do aroundCI <- getDirectChatItem db user contactId aroundId beforeIds <- liftIO $ getDirectCIsBefore_ db user ct aroundCI count search @@ -1404,7 +1404,7 @@ getContactNavInfo_ db User {userId} Contact {contactId} afterCI = do :. (userId, contactId, ciCreatedAt afterCI, cChatItemId afterCI) ) -getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Maybe GroupChatScope -> Maybe MsgContentTag -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Maybe GroupChatScope -> Maybe MsgContentTag -> ChatPagination -> Maybe Text -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) getGroupChat db vr user groupId scope_ contentFilter pagination search_ = do let search = fromMaybe "" search_ g <- getGroupInfo db vr user groupId @@ -1415,7 +1415,7 @@ getGroupChat db vr user groupId scope_ contentFilter pagination search_ = do CPBefore beforeId count -> (,Nothing) <$> getGroupChatBefore_ db user g scopeInfo contentFilter beforeId count search CPAround aroundId count -> getGroupChatAround_ db user g scopeInfo contentFilter aroundId count search CPInitial count -> do - unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" + unless (T.null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" getGroupChatInitial_ db user g scopeInfo contentFilter count getCreateGroupChatScopeInfo :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScope -> ExceptT StoreError IO GroupChatScopeInfo @@ -1466,7 +1466,7 @@ getGroupChatScopeForItem_ db itemId = (Nothing, Nothing) -> Nothing (Nothing, Just _) -> Nothing -- shouldn't happen -getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> Int -> Text -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChatLast_ db user g scopeInfo_ contentFilter count search stats = do ciIds <- getGroupChatItemIDs db user g scopeInfo_ contentFilter GRLast count search ts <- liftIO getCurrentTime @@ -1475,7 +1475,7 @@ getGroupChatLast_ db user g scopeInfo_ contentFilter count search stats = do data GroupItemIDsRange = GRLast | GRAfter UTCTime ChatItemId | GRBefore UTCTime ChatItemId -getGroupChatItemIDs :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> GroupItemIDsRange -> Int -> String -> ExceptT StoreError IO [ChatItemId] +getGroupChatItemIDs :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> GroupItemIDsRange -> Int -> Text -> ExceptT StoreError IO [ChatItemId] getGroupChatItemIDs db User {userId} GroupInfo {groupId} scopeInfo_ contentFilter range count search = case (scopeInfo_, contentFilter) of (Nothing, Nothing) -> liftIO $ @@ -1520,7 +1520,7 @@ getGroupChatItemIDs db User {userId} GroupInfo {groupId} scopeInfo_ contentFilte orParams ts itemId = (p :. (Only ts) :. p :. (ts, itemId)) rangeQuery :: ToRow p => Query -> p -> Query -> IO [ChatItemId] rangeQuery c p ob - | null search = searchQuery "" () + | T.null search = searchQuery "" () | otherwise = searchQuery " AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' " (Only search) where searchQuery :: ToRow p' => Query -> p' -> IO [ChatItemId] @@ -1570,7 +1570,7 @@ getGroupMemberChatItemLast db user@User {userId} groupId groupMemberId = do (userId, groupId, groupMemberId) getGroupChatItem db user groupId chatItemId -getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChatAfter_ db user g@GroupInfo {groupId} scopeInfo contentFilter afterId count search = do afterCI <- getGroupChatItem db user groupId afterId let range = GRAfter (chatItemTs afterCI) (cChatItemId afterCI) @@ -1579,7 +1579,7 @@ getGroupChatAfter_ db user g@GroupInfo {groupId} scopeInfo contentFilter afterId cis <- liftIO $ mapM (safeGetGroupItem db user g ts) ciIds pure $ Chat (GroupChat g scopeInfo) cis emptyChatStats -getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChatBefore_ db user g@GroupInfo {groupId} scopeInfo contentFilter beforeId count search = do beforeCI <- getGroupChatItem db user groupId beforeId let range = GRBefore (chatItemTs beforeCI) (cChatItemId beforeCI) @@ -1588,12 +1588,12 @@ getGroupChatBefore_ db user g@GroupInfo {groupId} scopeInfo contentFilter before cis <- liftIO $ mapM (safeGetGroupItem db user g ts) ciIds pure $ Chat (GroupChat g scopeInfo) (reverse cis) emptyChatStats -getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) getGroupChatAround_ db user g scopeInfo contentFilter aroundId count search = do stats <- getGroupStats_ db user g scopeInfo getGroupChatAround' db user g scopeInfo contentFilter aroundId count search stats -getGroupChatAround' :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChatAround' :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> Text -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) getGroupChatAround' db user g scopeInfo contentFilter aroundId count search stats = do aroundCI <- getGroupCIWithReactions db user g aroundId let beforeRange = GRBefore (chatItemTs aroundCI) (cChatItemId aroundCI) @@ -1736,7 +1736,7 @@ getGroupNavInfo_ db User {userId} GroupInfo {groupId} afterCI = do :. (userId, groupId, chatItemTs afterCI, cChatItemId afterCI) ) -getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) +getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe Text -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) getLocalChat db user folderId pagination search_ = do let search = fromMaybe "" search_ nf <- getNoteFolder db user folderId @@ -1746,17 +1746,17 @@ getLocalChat db user folderId pagination search_ = do CPBefore beforeId count -> (,Nothing) <$> getLocalChatBefore_ db user nf beforeId count search CPAround aroundId count -> getLocalChatAround_ db user nf aroundId count search CPInitial count -> do - unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" + unless (T.null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" getLocalChatInitial_ db user nf count -getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO (Chat 'CTLocal) +getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> Text -> IO (Chat 'CTLocal) getLocalChatLast_ db user nf count search = do ciIds <- getLocalChatItemIdsLast_ db user nf count search ts <- getCurrentTime cis <- mapM (safeGetLocalItem db user nf ts) ciIds pure $ Chat (LocalChat nf) (reverse cis) emptyChatStats -getLocalChatItemIdsLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO [ChatItemId] +getLocalChatItemIdsLast_ :: DB.Connection -> User -> NoteFolder -> Int -> Text -> IO [ChatItemId] getLocalChatItemIdsLast_ db User {userId} NoteFolder {noteFolderId} count search = map fromOnly <$> DB.query @@ -1797,7 +1797,7 @@ safeToLocalItem currentTs itemId = \case file = Nothing } -getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal) +getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTLocal) getLocalChatAfter_ db user nf@NoteFolder {noteFolderId} afterId count search = do afterCI <- getLocalChatItem db user noteFolderId afterId ciIds <- liftIO $ getLocalCIsAfter_ db user nf afterCI count search @@ -1805,7 +1805,7 @@ getLocalChatAfter_ db user nf@NoteFolder {noteFolderId} afterId count search = d cis <- liftIO $ mapM (safeGetLocalItem db user nf ts) ciIds pure $ Chat (LocalChat nf) cis emptyChatStats -getLocalCIsAfter_ :: DB.Connection -> User -> NoteFolder -> CChatItem 'CTLocal -> Int -> String -> IO [ChatItemId] +getLocalCIsAfter_ :: DB.Connection -> User -> NoteFolder -> CChatItem 'CTLocal -> Int -> Text -> IO [ChatItemId] getLocalCIsAfter_ db User {userId} NoteFolder {noteFolderId} afterCI count search = map fromOnly <$> DB.query @@ -1820,7 +1820,7 @@ getLocalCIsAfter_ db User {userId} NoteFolder {noteFolderId} afterCI count searc |] (userId, noteFolderId, search, ciCreatedAt afterCI, ciCreatedAt afterCI, cChatItemId afterCI, count) -getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal) +getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTLocal) getLocalChatBefore_ db user nf@NoteFolder {noteFolderId} beforeId count search = do beforeCI <- getLocalChatItem db user noteFolderId beforeId ciIds <- liftIO $ getLocalCIsBefore_ db user nf beforeCI count search @@ -1828,7 +1828,7 @@ getLocalChatBefore_ db user nf@NoteFolder {noteFolderId} beforeId count search = cis <- liftIO $ mapM (safeGetLocalItem db user nf ts) ciIds pure $ Chat (LocalChat nf) (reverse cis) emptyChatStats -getLocalCIsBefore_ :: DB.Connection -> User -> NoteFolder -> CChatItem 'CTLocal -> Int -> String -> IO [ChatItemId] +getLocalCIsBefore_ :: DB.Connection -> User -> NoteFolder -> CChatItem 'CTLocal -> Int -> Text -> IO [ChatItemId] getLocalCIsBefore_ db User {userId} NoteFolder {noteFolderId} beforeCI count search = map fromOnly <$> DB.query @@ -1843,12 +1843,12 @@ getLocalCIsBefore_ db User {userId} NoteFolder {noteFolderId} beforeCI count sea |] (userId, noteFolderId, search, ciCreatedAt beforeCI, ciCreatedAt beforeCI, cChatItemId beforeCI, count) -getLocalChatAround_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) +getLocalChatAround_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) getLocalChatAround_ db user nf aroundId count search = do stats <- liftIO $ getLocalStats_ db user nf getLocalChatAround' db user nf aroundId count search stats -getLocalChatAround' :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) +getLocalChatAround' :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> Text -> ChatStats -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) getLocalChatAround' db user nf@NoteFolder {noteFolderId} aroundId count search stats = do aroundCI <- getLocalChatItem db user noteFolderId aroundId beforeIds <- liftIO $ getLocalCIsBefore_ db user nf aroundCI count search @@ -2370,7 +2370,7 @@ toGroupChatItem ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} -getAllChatItems :: DB.Connection -> VersionRangeChat -> User -> ChatPagination -> Maybe String -> ExceptT StoreError IO [AChatItem] +getAllChatItems :: DB.Connection -> VersionRangeChat -> User -> ChatPagination -> Maybe Text -> ExceptT StoreError IO [AChatItem] getAllChatItems db vr user@User {userId} pagination search_ = do itemRefs <- rights . map toChatItemRef <$> case pagination of @@ -2379,7 +2379,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do CPBefore beforeId count -> liftIO . getAllChatItemsBefore_ beforeId count . aChatItemTs =<< getAChatItem_ beforeId CPAround aroundId count -> liftIO . getAllChatItemsAround_ aroundId count . aChatItemTs =<< getAChatItem_ aroundId CPInitial count -> do - unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" + unless (T.null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" liftIO getFirstUnreadItemId_ >>= \case Just itemId -> liftIO . getAllChatItemsAround_ itemId count . aChatItemTs =<< getAChatItem_ itemId Nothing -> liftIO $ getAllChatItemsLast_ count diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 4fbed16753..282bdda1f1 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -17,6 +17,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20250729_member_contact_requests import Simplex.Chat.Store.Postgres.Migrations.M20250801_via_group_link_uri import Simplex.Chat.Store.Postgres.Migrations.M20250802_chat_peer_type import Simplex.Chat.Store.Postgres.Migrations.M20250813_delivery_tasks +import Simplex.Chat.Store.Postgres.Migrations.M20250919_group_summary import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -33,7 +34,8 @@ schemaMigrations = ("20250729_member_contact_requests", m20250729_member_contact_requests, Just down_m20250729_member_contact_requests), ("20250801_via_group_link_uri", m20250801_via_group_link_uri, Just down_m20250801_via_group_link_uri), ("20250802_chat_peer_type", m20250802_chat_peer_type, Just down_m20250802_chat_peer_type), - ("20250813_delivery_tasks", m20250813_delivery_tasks, Just down_m20250813_delivery_tasks) + ("20250813_delivery_tasks", m20250813_delivery_tasks, Just down_m20250813_delivery_tasks), + ("20250919_group_summary", m20250919_group_summary, Just down_m20250919_group_summary) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20250919_group_summary.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20250919_group_summary.hs new file mode 100644 index 0000000000..915781d2c0 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20250919_group_summary.hs @@ -0,0 +1,116 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20250919_group_summary where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20250919_group_summary :: Text +m20250919_group_summary = + T.pack + [r| +ALTER TABLE groups ADD COLUMN summary_current_members_count BIGINT NOT NULL DEFAULT 0; +CREATE INDEX idx_groups_summary_current_members_count ON groups(summary_current_members_count); + +CREATE FUNCTION is_current_member(p_status TEXT) RETURNS BOOLEAN +LANGUAGE plpgsql AS $$ +BEGIN + RETURN p_status IN ( + 'introduced', + 'intro-inv', + 'accepted', + 'announced', + 'connected', + 'complete', + 'creator' + ); +END; +$$; + +UPDATE groups g +SET summary_current_members_count = COALESCE(c.cnt, 0) +FROM ( + SELECT group_id, COUNT(group_member_id) AS cnt + FROM group_members + WHERE is_current_member(member_status) = TRUE + GROUP BY group_id +) c +WHERE g.group_id = c.group_id; + +CREATE FUNCTION on_group_members_insert_update_summary() RETURNS TRIGGER +LANGUAGE plpgsql AS $$ +BEGIN + IF is_current_member(NEW.member_status) THEN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + 1 + WHERE group_id = NEW.group_id; + END IF; + RETURN NEW; +END; +$$; + +CREATE FUNCTION on_group_members_delete_update_summary() RETURNS TRIGGER +LANGUAGE plpgsql AS $$ +BEGIN + IF is_current_member(OLD.member_status) THEN + UPDATE groups + SET summary_current_members_count = summary_current_members_count - 1 + WHERE group_id = OLD.group_id; + END IF; + RETURN OLD; +END; +$$; + +CREATE FUNCTION on_group_members_update_update_summary() RETURNS TRIGGER +LANGUAGE plpgsql AS $$ +DECLARE + was_active BOOLEAN; + is_active BOOLEAN; +BEGIN + was_active := is_current_member(OLD.member_status); + is_active := is_current_member(NEW.member_status); + + IF was_active != is_active THEN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + + (CASE WHEN is_active THEN 1 ELSE -1 END) + WHERE group_id = NEW.group_id; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER tr_group_members_insert_update_summary +AFTER INSERT ON group_members +FOR EACH ROW +EXECUTE FUNCTION on_group_members_insert_update_summary(); + +CREATE TRIGGER tr_group_members_delete_update_summary +AFTER DELETE ON group_members +FOR EACH ROW +EXECUTE FUNCTION on_group_members_delete_update_summary(); + +CREATE TRIGGER tr_group_members_update_update_summary +AFTER UPDATE ON group_members +FOR EACH ROW +EXECUTE FUNCTION on_group_members_update_update_summary(); +|] + +down_m20250919_group_summary :: Text +down_m20250919_group_summary = + T.pack + [r| +DROP TRIGGER tr_group_members_insert_update_summary ON group_members; +DROP TRIGGER tr_group_members_delete_update_summary ON group_members; +DROP TRIGGER tr_group_members_update_update_summary ON group_members; + +DROP FUNCTION on_group_members_insert_update_summary; +DROP FUNCTION on_group_members_delete_update_summary; +DROP FUNCTION on_group_members_update_update_summary; + +DROP FUNCTION is_current_member; + +DROP INDEX idx_groups_summary_current_members_count; +ALTER TABLE groups DROP COLUMN summary_current_members_count; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 695d7d393f..77dc959788 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -15,6 +15,76 @@ SET row_security = off; CREATE SCHEMA test_chat_schema; + +CREATE FUNCTION test_chat_schema.is_current_member(p_status text) RETURNS boolean + LANGUAGE plpgsql + AS $$ +BEGIN + RETURN p_status IN ( + 'introduced', + 'intro-inv', + 'accepted', + 'announced', + 'connected', + 'complete', + 'creator' + ); +END; +$$; + + + +CREATE FUNCTION test_chat_schema.on_group_members_delete_update_summary() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF is_current_member(OLD.member_status) THEN + UPDATE groups + SET summary_current_members_count = summary_current_members_count - 1 + WHERE group_id = OLD.group_id; + END IF; + RETURN OLD; +END; +$$; + + + +CREATE FUNCTION test_chat_schema.on_group_members_insert_update_summary() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF is_current_member(NEW.member_status) THEN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + 1 + WHERE group_id = NEW.group_id; + END IF; + RETURN NEW; +END; +$$; + + + +CREATE FUNCTION test_chat_schema.on_group_members_update_update_summary() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + was_active BOOLEAN; + is_active BOOLEAN; +BEGIN + was_active := is_current_member(OLD.member_status); + is_active := is_current_member(NEW.member_status); + + IF was_active != is_active THEN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + + (CASE WHEN is_active THEN 1 ELSE -1 END) + WHERE group_id = NEW.group_id; + END IF; + RETURN NEW; +END; +$$; + + SET default_table_access_method = heap; @@ -716,7 +786,8 @@ CREATE TABLE test_chat_schema.groups ( welcome_shared_msg_id bytea, request_shared_msg_id bytea, conn_link_prepared_connection smallint DEFAULT 0 NOT NULL, - via_group_link_uri bytea + via_group_link_uri bytea, + summary_current_members_count bigint DEFAULT 0 NOT NULL ); @@ -2064,6 +2135,10 @@ CREATE INDEX idx_groups_inv_queue_info ON test_chat_schema.groups USING btree (i +CREATE INDEX idx_groups_summary_current_members_count ON test_chat_schema.groups USING btree (summary_current_members_count); + + + CREATE INDEX idx_groups_via_group_link_uri_hash ON test_chat_schema.groups USING btree (user_id, via_group_link_uri_hash); @@ -2248,6 +2323,18 @@ CREATE INDEX note_folders_user_id ON test_chat_schema.note_folders USING btree ( +CREATE TRIGGER tr_group_members_delete_update_summary AFTER DELETE ON test_chat_schema.group_members FOR EACH ROW EXECUTE FUNCTION test_chat_schema.on_group_members_delete_update_summary(); + + + +CREATE TRIGGER tr_group_members_insert_update_summary AFTER INSERT ON test_chat_schema.group_members FOR EACH ROW EXECUTE FUNCTION test_chat_schema.on_group_members_insert_update_summary(); + + + +CREATE TRIGGER tr_group_members_update_update_summary AFTER UPDATE ON test_chat_schema.group_members FOR EACH ROW EXECUTE FUNCTION test_chat_schema.on_group_members_update_update_summary(); + + + ALTER TABLE ONLY test_chat_schema.calls ADD CONSTRAINT calls_chat_item_id_fkey FOREIGN KEY (chat_item_id) REFERENCES test_chat_schema.chat_items(chat_item_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index e501a7d627..e3e5453fa5 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -140,6 +140,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20250729_member_contact_requests import Simplex.Chat.Store.SQLite.Migrations.M20250801_via_group_link_uri import Simplex.Chat.Store.SQLite.Migrations.M20250802_chat_peer_type import Simplex.Chat.Store.SQLite.Migrations.M20250813_delivery_tasks +import Simplex.Chat.Store.SQLite.Migrations.M20250919_group_summary import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -279,7 +280,8 @@ schemaMigrations = ("20250729_member_contact_requests", m20250729_member_contact_requests, Just down_m20250729_member_contact_requests), ("20250801_via_group_link_uri", m20250801_via_group_link_uri, Just down_m20250801_via_group_link_uri), ("20250802_chat_peer_type", m20250802_chat_peer_type, Just down_m20250802_chat_peer_type), - ("20250813_delivery_tasks", m20250813_delivery_tasks, Just down_m20250813_delivery_tasks) + ("20250813_delivery_tasks", m20250813_delivery_tasks, Just down_m20250813_delivery_tasks), + ("20250919_group_summary", m20250919_group_summary, Just down_m20250919_group_summary) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250813_delivery_tasks.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250813_delivery_tasks.hs index 3163619477..0b70fb9dcb 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20250813_delivery_tasks.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250813_delivery_tasks.hs @@ -104,8 +104,6 @@ CREATE INDEX idx_delivery_tasks_next_for_job_scope_sender ON delivery_tasks( ); CREATE INDEX idx_delivery_tasks_created_at ON delivery_tasks(created_at); - - CREATE TABLE delivery_jobs ( delivery_job_id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, @@ -135,8 +133,6 @@ CREATE INDEX idx_delivery_jobs_next ON delivery_jobs( ); CREATE INDEX idx_delivery_jobs_created_at ON delivery_jobs(created_at); - - ALTER TABLE messages ADD COLUMN broker_ts TEXT; |] @@ -145,8 +141,6 @@ down_m20250813_delivery_tasks = [sql| ALTER TABLE messages DROP COLUMN broker_ts; - - DROP INDEX idx_delivery_jobs_group_id; DROP INDEX idx_delivery_jobs_job_scope_support_gm_id; DROP INDEX idx_delivery_jobs_single_sender_group_member_id; @@ -156,8 +150,6 @@ DROP INDEX idx_delivery_jobs_created_at; DROP TABLE delivery_jobs; - - DROP INDEX idx_delivery_tasks_group_id; DROP INDEX idx_delivery_tasks_job_scope_support_gm_id; DROP INDEX idx_delivery_tasks_sender_group_member_id; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250919_group_summary.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250919_group_summary.hs new file mode 100644 index 0000000000..0a9e7451e3 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250919_group_summary.hs @@ -0,0 +1,95 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250919_group_summary where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250919_group_summary :: Query +m20250919_group_summary = + [sql| +ALTER TABLE groups ADD COLUMN summary_current_members_count INTEGER NOT NULL DEFAULT 0; +CREATE INDEX idx_groups_summary_current_members_count ON groups(summary_current_members_count); + +CREATE TABLE group_member_status_predicates( + member_status TEXT NOT NULL PRIMARY KEY, + current_member INTEGER NOT NULL DEFAULT 0 +); + +INSERT INTO group_member_status_predicates(member_status, current_member) +VALUES + ('rejected', 0), + ('removed', 0), + ('left', 0), + ('deleted', 0), + ('unknown', 0), + ('invited', 0), + ('pending_approval', 0), + ('pending_review', 0), + ('introduced', 1), + ('intro-inv', 1), + ('accepted', 1), + ('announced', 1), + ('connected', 1), + ('complete', 1), + ('creator', 1); + +UPDATE groups +SET summary_current_members_count = c.cnt +FROM ( + SELECT m.group_id, COUNT(m.group_member_id) AS cnt + FROM group_members m + JOIN group_member_status_predicates p ON m.member_status = p.member_status + WHERE p.current_member = 1 + GROUP BY m.group_id +) AS c +WHERE groups.group_id = c.group_id; + +CREATE TRIGGER on_group_members_insert_update_summary +AFTER INSERT ON group_members +FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = NEW.member_status AND current_member = 1) +BEGIN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + 1 + WHERE group_id = NEW.group_id; +END; + +CREATE TRIGGER on_group_members_delete_update_summary +AFTER DELETE ON group_members +FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = OLD.member_status AND current_member = 1) +BEGIN + UPDATE groups + SET summary_current_members_count = summary_current_members_count - 1 + WHERE group_id = OLD.group_id; +END; + +CREATE TRIGGER on_group_members_update_update_summary +AFTER UPDATE ON group_members +FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = OLD.member_status AND current_member = 1) + != EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = NEW.member_status AND current_member = 1) +BEGIN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + + ( + CASE WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = NEW.member_status AND current_member = 1) + THEN 1 ELSE -1 END + ) + WHERE group_id = NEW.group_id; +END; +|] + +down_m20250919_group_summary :: Query +down_m20250919_group_summary = + [sql| +DROP TRIGGER on_group_members_insert_update_summary; +DROP TRIGGER on_group_members_delete_update_summary; +DROP TRIGGER on_group_members_update_update_summary; + +DROP TABLE group_member_status_predicates; + +DROP INDEX idx_groups_summary_current_members_count; +ALTER TABLE groups DROP COLUMN summary_current_members_count; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index a5a494d36d..0d36f556a7 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -48,6 +48,31 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) Query: INSERT INTO groups @@ -57,6 +82,30 @@ Query: Plan: +Query: + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, + ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 + ORDER BY c.created_at DESC + LIMIT 1 + +Plan: +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) LEFT-JOIN +USE TEMP B-TREE FOR ORDER BY + Query: SELECT -- GroupInfo @@ -65,7 +114,7 @@ Query: g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -192,6 +241,31 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) Query: INSERT INTO group_members @@ -201,6 +275,31 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) Query: INSERT INTO groups @@ -210,30 +309,6 @@ Query: Plan: -Query: - SELECT - -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, - ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, - ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, - -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM contacts ct - JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id - LEFT JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 - ORDER BY c.created_at DESC - LIMIT 1 - -Plan: -SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) -SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) -SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) LEFT-JOIN -USE TEMP B-TREE FOR ORDER BY - Query: SELECT COUNT(1) FROM chat_items i @@ -374,6 +449,31 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) Query: INSERT INTO group_members @@ -383,6 +483,31 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) Query: INSERT INTO group_members @@ -392,6 +517,31 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) Query: INSERT INTO messages ( @@ -851,6 +1001,31 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) Query: INSERT INTO groups @@ -1024,7 +1199,7 @@ Query: g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -1067,35 +1242,6 @@ SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) -Query: - SELECT - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, - g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, - g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, - mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, - mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, - mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts - FROM groups g - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members mu USING (group_id) - JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) - WHERE g.user_id = ? AND mu.contact_id = ? - AND (LOWER(gp.display_name) LIKE '%' || ? || '%' - OR LOWER(gp.full_name) LIKE '%' || ? || '%' - OR LOWER(gp.short_descr) LIKE '%' || ? || '%' - OR LOWER(gp.description) LIKE '%' || ? || '%' - ) - -Plan: -SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) -SEARCH g USING INTEGER PRIMARY KEY (rowid=?) -SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) -SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) - Query: SELECT 1 FROM group_member_intros @@ -1284,18 +1430,6 @@ Query: Plan: SEARCH files USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT count (m.group_member_id) - FROM groups g - JOIN group_members m USING (group_id) - WHERE g.user_id = ? - AND g.group_id = ? - AND m.member_status NOT IN (?,?,?,?,?) - -Plan: -SEARCH g USING INTEGER PRIMARY KEY (rowid=?) -SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=?) - Query: SELECT ct.contact_id FROM contacts ct @@ -1625,6 +1759,31 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) Query: INSERT INTO group_members @@ -1634,6 +1793,31 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) Query: INSERT INTO msg_deliveries @@ -4899,13 +5083,48 @@ Query: g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts + + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + JOIN group_members mu ON mu.group_id = g.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) + + WHERE g.user_id = ? AND mu.contact_id = ? + AND (LOWER(gp.display_name) LIKE '%' || ? || '%' + OR LOWER(gp.full_name) LIKE '%' || ? || '%' + OR LOWER(gp.short_descr) LIKE '%' || ? || '%' + OR LOWER(gp.description) LIKE '%' || ? || '%' + ) + +Plan: +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, + g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, + g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + -- GroupMember - membership + mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts + FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id JOIN group_members mu ON mu.group_id = g.group_id @@ -4925,13 +5144,14 @@ Query: g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts + FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id JOIN group_members mu ON mu.group_id = g.group_id diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 6fad0d669e..5e291add2e 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -1,8 +1,7 @@ CREATE TABLE migrations( - name TEXT NOT NULL, + name TEXT NOT NULL PRIMARY KEY, ts TEXT NOT NULL, - down TEXT, - PRIMARY KEY(name) + down TEXT ); CREATE TABLE contact_profiles( -- remote user profile @@ -156,7 +155,8 @@ CREATE TABLE groups( welcome_shared_msg_id BLOB, request_shared_msg_id BLOB, conn_link_prepared_connection INTEGER NOT NULL DEFAULT 0, - via_group_link_uri BLOB, -- received + via_group_link_uri BLOB, + summary_current_members_count INTEGER NOT NULL DEFAULT 0, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -724,6 +724,10 @@ CREATE TABLE delivery_jobs( created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')) ); +CREATE TABLE group_member_status_predicates( + member_status TEXT NOT NULL PRIMARY KEY, + current_member INTEGER NOT NULL DEFAULT 0 +); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -1184,3 +1188,38 @@ CREATE INDEX idx_delivery_jobs_next ON delivery_jobs( job_status ); CREATE INDEX idx_delivery_jobs_created_at ON delivery_jobs(created_at); +CREATE INDEX idx_groups_summary_current_members_count ON groups( + summary_current_members_count +); +CREATE TRIGGER on_group_members_insert_update_summary +AFTER INSERT ON group_members +FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = NEW.member_status AND current_member = 1) +BEGIN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + 1 + WHERE group_id = NEW.group_id; +END; +CREATE TRIGGER on_group_members_delete_update_summary +AFTER DELETE ON group_members +FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = OLD.member_status AND current_member = 1) +BEGIN + UPDATE groups + SET summary_current_members_count = summary_current_members_count - 1 + WHERE group_id = OLD.group_id; +END; +CREATE TRIGGER on_group_members_update_update_summary +AFTER UPDATE ON group_members +FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = OLD.member_status AND current_member = 1) + != EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = NEW.member_status AND current_member = 1) +BEGIN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + + ( + CASE WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = NEW.member_status AND current_member = 1) + THEN 1 ELSE -1 END + ) + WHERE group_id = NEW.group_id; +END; diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index b0a8dfd3ec..6e56452425 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -37,7 +37,7 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), AConnShortLink (..), ACreatedConnLink (..), ConnId, ConnShortLink, ConnectionRequestUri, CreatedConnLink (..), UserId, connMode) +import Simplex.Messaging.Agent.Protocol (AConnShortLink (..), AConnectionRequestUri (..), ACreatedConnLink (..), ConnId, ConnShortLink, ConnectionRequestUri, CreatedConnLink (..), UserId, connMode) import Simplex.Messaging.Agent.Store (AnyStoreError (..)) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) @@ -656,21 +656,22 @@ type PreparedGroupRow = (Maybe ConnReqContact, Maybe ShortLinkContact, BoolInt, type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe MemberId) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Int64, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupMemberRow type GroupMemberRow = (Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime) type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image) :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (uiThemes, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. userMemberRow) = +toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image) :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (uiThemes, currentMembers, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences groupProfile = GroupProfile {displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} businessChat = toBusinessChatInfo businessRow preparedGroup = toPreparedGroup preparedGroupRow - in GroupInfo {groupId, useRelays = False, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, customData, membersRequireAttention, viaGroupLinkUri} + groupSummary = GroupSummary {currentMembers} + in GroupInfo {groupId, useRelays = False, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, customData, membersRequireAttention, viaGroupLinkUri} toPreparedGroup :: PreparedGroupRow -> Maybe PreparedGroup toPreparedGroup = \case @@ -688,13 +689,14 @@ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer supportChat = case supportChatTs_ of Just chatTs -> - Just GroupSupportChat { - chatTs, - unread = supportChatUnread, - memberAttention = supportChatMemberAttention, - mentions = supportChatMentions, - lastMsgFromMemberTs = supportChatLastMsgFromMemberTs - } + Just + GroupSupportChat + { chatTs, + unread = supportChatUnread, + memberAttention = supportChatMemberAttention, + mentions = supportChatMentions, + lastMsgFromMemberTs = supportChatLastMsgFromMemberTs + } _ -> Nothing in GroupMember {..} @@ -707,7 +709,10 @@ toBusinessChatInfo (Just chatType, Just businessId, Just customerId) = Just Busi toBusinessChatInfo _ = Nothing groupInfoQuery :: Query -groupInfoQuery = +groupInfoQuery = groupInfoQueryFields <> " " <> groupInfoQueryFrom + +groupInfoQueryFields :: Query +groupInfoQueryFields = [sql| SELECT -- GroupInfo @@ -716,13 +721,18 @@ groupInfoQuery = g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts + |] + +groupInfoQueryFrom :: Query +groupInfoQueryFrom = + [sql| FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id JOIN group_members mu ON mu.group_id = g.group_id @@ -808,7 +818,8 @@ setViaGroupLinkUri db groupId connId = do DB.query db "SELECT via_contact_uri, via_contact_uri_hash FROM connections WHERE connection_id = ?" - (Only connId) :: IO [(Maybe ConnReqContact, Maybe ConnReqUriHash)] + (Only connId) :: + IO [(Maybe ConnReqContact, Maybe ConnReqUriHash)] forM_ (listToMaybe r) $ \(viaContactUri, viaContactUriHash) -> DB.execute db diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index a90d457920..999108b7f5 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -487,6 +487,7 @@ data GroupInfo = GroupInfo chatItemTTL :: Maybe Int64, uiThemes :: Maybe UIThemeEntityOverrides, customData :: Maybe CustomData, + groupSummary :: GroupSummary, membersRequireAttention :: Int, viaGroupLinkUri :: Maybe ConnReqContact } @@ -523,12 +524,9 @@ groupName' :: GroupInfo -> GroupName groupName' GroupInfo {localDisplayName = g} = g data GroupSummary = GroupSummary - { currentMembers :: Int + { currentMembers :: Int64 } - deriving (Show) - -data GroupInfoSummary = GIS {groupInfo :: GroupInfo, groupSummary :: GroupSummary, groupLink :: Maybe GroupLink} - deriving (Show) + deriving (Eq, Show) data GroupLink = GroupLink { userContactLinkId :: Int64, @@ -1240,7 +1238,9 @@ memberPending m = case memberStatus m of memberCurrentOrPending :: GroupMember -> Bool memberCurrentOrPending m = memberCurrent m || memberPending m --- update getGroupSummary if this is changed +-- *** Please note: +-- *** update getGroupSummary and SQL function used in update triggers if this is changed +-- *** memberCurrent' :: GroupMemberStatus -> Bool memberCurrent' = \case GSMemRejected -> False @@ -2079,16 +2079,14 @@ $(JQ.deriveJSON defaultJSON ''BusinessChatInfo) $(JQ.deriveJSON defaultJSON ''PreparedGroup) +$(JQ.deriveJSON defaultJSON ''GroupSummary) + $(JQ.deriveJSON defaultJSON ''GroupInfo) $(JQ.deriveJSON defaultJSON ''Group) -$(JQ.deriveJSON defaultJSON ''GroupSummary) - $(JQ.deriveJSON defaultJSON ''GroupLink) -$(JQ.deriveJSON defaultJSON ''GroupInfoSummary) - instance FromField MsgFilter where fromField = fromIntField_ msgFilterIntP instance ToField MsgFilter where toField = toField . msgFilterInt diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 015d4e6645..1f6383c1d5 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -128,7 +128,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRChatItemTTL u ttl -> ttyUser u $ viewChatItemTTL ttl CRNetworkConfig netCfg -> viewNetworkConfig netCfg CRContactInfo u ct cStats customUserProfile -> ttyUser u $ viewContactInfo ct cStats customUserProfile - CRGroupInfo u g s -> ttyUser u $ viewGroupInfo g s + CRGroupInfo u g -> ttyUser u $ viewGroupInfo g CRGroupMemberInfo u g m cStats -> ttyUser u $ viewGroupMemberInfo g m cStats CRQueueInfo _ msgInfo qInfo -> [ "last received msg: " <> maybe "none" viewJSON msgInfo, @@ -1360,13 +1360,13 @@ viewContactConnected ct userIncognitoProfile testView = Nothing -> [ttyFullContact ct <> ": contact is connected"] -viewGroupsList :: [GroupInfoSummary] -> [StyledString] +viewGroupsList :: [GroupInfo] -> [StyledString] viewGroupsList [] = ["you have no groups!", "to create: " <> highlight' "/g "] viewGroupsList gs = map groupSS $ sortOn ldn_ gs where - ldn_ :: GroupInfoSummary -> Text - ldn_ (GIS GroupInfo {localDisplayName} _ _) = T.toLower localDisplayName - groupSS (GIS g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}} GroupSummary {currentMembers} _) = + ldn_ :: GroupInfo -> Text + ldn_ GroupInfo {localDisplayName} = T.toLower localDisplayName + groupSS g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}, groupSummary = GroupSummary {currentMembers}} = case memberStatus membership of GSMemInvited -> groupInvitation' g s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> alias g @@ -1628,8 +1628,8 @@ viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, conta <> viewUITheme uiThemes <> viewCustomData customData -viewGroupInfo :: GroupInfo -> GroupSummary -> [StyledString] -viewGroupInfo GroupInfo {groupId, uiThemes, customData} s = +viewGroupInfo :: GroupInfo -> [StyledString] +viewGroupInfo GroupInfo {groupId, uiThemes, customData, groupSummary = s} = [ "group ID: " <> sShow groupId, "current members: " <> sShow (currentMembers s) ] diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 1550a7bd9b..4395ecc382 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -102,6 +102,7 @@ mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup webFolder = profileNameLimit = maxBound, captchaGenerator = Nothing, directoryLog = Just $ ps "directory_service.log", + migrateDirectoryLog = Nothing, serviceName = "SimpleX Directory", runCLI = False, searchResults = 3, @@ -275,6 +276,7 @@ testSuspendResume ps = bob <## "The group is listed in directory." superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated - only link or whitespace changes." superUser <## "The group remained listed in directory." +#if !defined(dbPostgres) -- upgrade link -- make it upgradeable first superUser #> "@'SimpleX Directory' /x /sql chat UPDATE user_contact_links SET short_link_contact = NULL" @@ -303,6 +305,7 @@ testSuspendResume ps = superUser <# "'SimpleX Directory'> > /owner 1:privacy hello there" superUser <## " Forwarded to @bob, the owner of the group ID 1 (privacy)" bob <# "'SimpleX Directory'> hello there" +#endif testDeleteGroup :: HasCallStack => TestParams -> IO () testDeleteGroup ps = @@ -971,7 +974,7 @@ testDuplicateAskConfirmation ps = cath #> "@'SimpleX Directory' /confirm 1:privacy" welcomeWithLink <- groupAccepted cath "privacy" groupNotFound bob "privacy" - completeRegistration superUser cath "privacy" "Privacy" welcomeWithLink 2 + completeRegistrationId superUser cath "privacy" "Privacy" welcomeWithLink 2 1 groupFound bob "privacy" testDuplicateProhibitRegistration :: HasCallStack => TestParams -> IO () @@ -1029,10 +1032,10 @@ testDuplicateProhibitWhenUpdated ps = cath <# "'SimpleX Directory'> The group privacy (Privacy) is already listed in the directory, please choose another name." cath ##> "/gp privacy security Security" cath <## "changed to #security (Security)" - cath <# "'SimpleX Directory'> Thank you! The group link for ID 2 (security) is added to the welcome message." + cath <# "'SimpleX Directory'> Thank you! The group link for ID 1 (security) is added to the welcome message." cath <## "You will be notified once the group is added to the directory - it may take up to 48 hours." notifySuperUser superUser cath "security" "Security" welcomeWithLink' 2 - approveRegistration superUser cath "security" 2 + approveRegistrationId superUser cath "security" 2 1 groupFound bob "security" groupFound cath "security" @@ -1051,7 +1054,7 @@ testDuplicateProhibitApproval ps = cath <# "'SimpleX Directory'> /confirm 1:privacy" cath #> "@'SimpleX Directory' /confirm 1:privacy" welcomeWithLink' <- groupAccepted cath "privacy" - updateProfileWithLink cath "privacy" welcomeWithLink' 2 + updateProfileWithLink cath "privacy" welcomeWithLink' 1 notifySuperUser superUser cath "privacy" "Privacy" welcomeWithLink' 2 groupNotFound cath "privacy" completeRegistration superUser bob "privacy" "Privacy" welcomeWithLink 1 @@ -1116,7 +1119,7 @@ testListUserGroups promote ps = checkListings ["security"] [] superUser #> "@'SimpleX Directory' /approve 1:privacy 1" superUser <# "'SimpleX Directory'> > /approve 1:privacy 1" - superUser <## " Group approved!" + superUser <## " Group approved (promoted)!" bob <# "'SimpleX Directory'> The group ID 1 (privacy) is approved and listed in directory - please moderate it!" bob <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." bob <## "" @@ -1126,7 +1129,7 @@ testListUserGroups promote ps = bob <## "/'link 1' - to view/upgrade group link." checkListings ["privacy", "security"] ["privacy"] -checkListings :: [T.Text] -> [T.Text] -> IO () +checkListings :: HasCallStack => [T.Text] -> [T.Text] -> IO () checkListings listed promoted = do threadDelay 100000 checkListing listingFileName listed @@ -1395,7 +1398,7 @@ withDirectoryOwnersGroup ps cfg dsLink createOwnersGroup webFolder test = do runDirectory :: ChatConfig -> DirectoryOpts -> IO () -> IO () runDirectory cfg opts@DirectoryOpts {directoryLog} action = do - st <- restoreDirectoryStore directoryLog + st <- openDirectoryLog directoryLog t <- forkIO $ directoryService st opts cfg threadDelay 500000 action `finally` (mapM_ hClose (directoryLogFile st) >> killThread t) diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 3f005cb915..8fd8a5976d 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -209,7 +209,8 @@ testCfg = showReceipts = False, shortLinkPresetServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001"], testView = True, - tbqSize = 16 + tbqSize = 16, + confirmMigrations = MCYesUp } testCfgSlow :: ChatConfig diff --git a/tests/PostgresSchemaDump.hs b/tests/PostgresSchemaDump.hs index 468cdb19d5..ba937b4296 100644 --- a/tests/PostgresSchemaDump.hs +++ b/tests/PostgresSchemaDump.hs @@ -48,14 +48,14 @@ postgresSchemaDumpTest migrations skipComparisonForDownMigrations testDBOpts@DBO putStrLn $ "down migration " <> name m let downMigr = fromJust $ toDownMigration m schema <- getSchema testSchemaPath - Migrations.run st $ MTRUp [m] + Migrations.run st Nothing $ MTRUp [m] schema' <- getSchema testSchemaPath schema' `shouldNotBe` schema - Migrations.run st $ MTRDown [downMigr] + Migrations.run st Nothing $ MTRDown [downMigr] unless (name m `elem` skipComparisonForDownMigrations) $ do schema'' <- getSchema testSchemaPath schema'' `shouldBe` schema - Migrations.run st $ MTRUp [m] + Migrations.run st Nothing $ MTRUp [m] schema''' <- getSchema testSchemaPath schema''' `shouldBe` schema' diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 5b6fffd7dc..0f5b5b6450 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -88,15 +88,15 @@ testSchemaMigrations = withTmpFiles $ do putStrLn $ "down migration " <> name m let downMigr = fromJust $ toDownMigration m schema <- getSchema testDB testSchema - Migrations.run st True $ MTRUp [m] + Migrations.run st Nothing True $ MTRUp [m] schema' <- getSchema testDB testSchema unless (name m `elem` skipComparisonForUpMigrations) $ schema' `shouldNotBe` schema - Migrations.run st True $ MTRDown [downMigr] + Migrations.run st Nothing True $ MTRDown [downMigr] unless (name m `elem` skipComparisonForDownMigrations) $ do schema'' <- getSchema testDB testSchema schema'' `shouldBe` schema - Migrations.run st True $ MTRUp [m] + Migrations.run st Nothing True $ MTRUp [m] schema''' <- getSchema testDB testSchema schema''' `shouldBe` schema' diff --git a/tests/Test.hs b/tests/Test.hs index 46106f0654..1b0bd34cd6 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -3,7 +3,6 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE TupleSections #-} -import APIDocs import Bots.BroadcastTests import Bots.DirectoryTests import ChatClient @@ -28,8 +27,9 @@ import Control.Exception (bracket_) import PostgresSchemaDump import Simplex.Chat.Store.Postgres.Migrations (migrations) import Simplex.Messaging.Agent.Store.Postgres.Util (createDBAndUserIfNotExists, dropAllSchemasExceptSystem, dropDatabaseAndUser) -import System.Directory (createDirectory, removePathForcibly) +import System.Directory (createDirectoryIfMissing, removePathForcibly) #else +import APIDocs import qualified Simplex.Messaging.TMap as TM import MobileTests import SchemaDump @@ -45,12 +45,12 @@ main = do #endif withGlobalLogging logCfg . hspec #if defined(dbPostgres) - . beforeAll_ (dropDatabaseAndUser testDBConnectInfo >> createDBAndUserIfNotExists testDBConnectInfo) - . afterAll_ (dropDatabaseAndUser testDBConnectInfo) + . before_ (dropDatabaseAndUser testDBConnectInfo >> createDBAndUserIfNotExists testDBConnectInfo) + . after_ (dropDatabaseAndUser testDBConnectInfo) #endif $ do #if defined(dbPostgres) - around_ (bracket_ (createDirectory "tests/tmp") (removePathForcibly "tests/tmp")) $ + around_ (bracket_ (createDirectoryIfMissing False "tests/tmp") (removePathForcibly "tests/tmp")) $ describe "Postgres schema dump" $ postgresSchemaDumpTest migrations @@ -72,7 +72,6 @@ main = do describe "Random servers" randomServersTests #if defined(dbPostgres) around testBracket - . after_ (dropAllSchemasExceptSystem testDBConnectInfo) #else around (testBracket chatQueryStats agentQueryStats) #endif