diff --git a/dDatabase/GameDatabase/GameDatabase.h b/dDatabase/GameDatabase/GameDatabase.h index c8a0904af..59d36387d 100644 --- a/dDatabase/GameDatabase/GameDatabase.h +++ b/dDatabase/GameDatabase/GameDatabase.h @@ -55,6 +55,7 @@ #include "IApiKeys.h" #include "IUgcLookup.h" #include "IPermissionGrants.h" +#include "IGuilds.h" #ifdef _DEBUG # define DLU_SQL_TRY_CATCH_RETHROW(x) do { try { x; } catch (std::exception& ex) { LOG("SQL Error: %s", ex.what()); throw; } } while(0) @@ -68,7 +69,7 @@ class GameDatabase : public IPropertyContents, public IProperty, public IPetNames, public ICharXml, public IMigrationHistory, public IUgc, public IFriends, public ICharInfo, public IAccounts, public IActivityLog, public IAccountsRewardCodes, public IIgnoreList, - public IBehaviors, public IUgcModularBuild, public IAuditLog, public IDashboardStats, public IAccountEmails, public IDashboardMaintenance, public IEconomyLedger, public IDashboardAdmin, public IServerConfig, public IScheduledTasks, public ICharacterSnapshots, public IAccountNotes, public IServerHealth, public IRelatedData, public IChatLog, public IAccountStrikes, public ISlashCommands, public IModeration, public IServerOperations, public IPlayerPositions, public IAiSuggestions, public ILiveOps, public IFeaturedProperties, public IMessageCaptures, public IContraband, public IPropertyRent, public IPropertyReputation, public IBbbAutosave, public IServerTraffic, public IApiKeys, public IUgcLookup, public IPermissionGrants { + public IBehaviors, public IUgcModularBuild, public IAuditLog, public IDashboardStats, public IAccountEmails, public IDashboardMaintenance, public IEconomyLedger, public IDashboardAdmin, public IServerConfig, public IScheduledTasks, public ICharacterSnapshots, public IAccountNotes, public IServerHealth, public IRelatedData, public IChatLog, public IAccountStrikes, public ISlashCommands, public IModeration, public IServerOperations, public IPlayerPositions, public IAiSuggestions, public ILiveOps, public IFeaturedProperties, public IMessageCaptures, public IContraband, public IPropertyRent, public IPropertyReputation, public IBbbAutosave, public IServerTraffic, public IApiKeys, public IUgcLookup, public IPermissionGrants, public IGuilds { public: virtual ~GameDatabase() = default; // TODO: These should be made private. diff --git a/dDatabase/GameDatabase/ITables/IGuilds.h b/dDatabase/GameDatabase/ITables/IGuilds.h new file mode 100644 index 000000000..371c1b45e --- /dev/null +++ b/dDatabase/GameDatabase/ITables/IGuilds.h @@ -0,0 +1,100 @@ +#ifndef __IGUILDS__H__ +#define __IGUILDS__H__ + +#include +#include +#include +#include + +#include "dCommonVars.h" + +/** + * Guilds (docs/Guilds.md). The chat server owns them: it is the only writer apart from the dashboard. A character is in + * at most one guild and has at most one pending invite. The leader is the member with rank 1; there is no leader column. + */ +class IGuilds { +public: + // guilds.name_status, the way pet_names.approved counts + enum NameStatus : int32_t { + NAME_PENDING = 1, // waits for a moderator; other players see no guild name + NAME_APPROVED = 2, + }; + + struct Guild { + int64_t id{}; + std::string name; + int32_t nameStatus{}; + LWOOBJID founderId{}; + int64_t createdAt{}; + }; + + struct Member { + LWOOBJID characterId{}; + int64_t guildId{}; + uint8_t rank{}; // 1 leader, 2 officer, 3 veteran, 4 recruit (the client's names) + int64_t joinedAt{}; + std::string name; // charinfo.name; empty when the character is gone + }; + + struct Invite { + LWOOBJID characterId{}; // who is invited + int64_t guildId{}; + LWOOBJID inviterId{}; + int64_t createdAt{}; + }; + + // One line of a guild's history + struct Event { + uint64_t id{}; + int64_t guildId{}; + int64_t time{}; + std::string kind; // created, joined, left, kicked, rank, leader, renamed, name_approved, name_rejected, disbanded + LWOOBJID characterId{}; // who it was about (0: the guild itself) + std::string characterName; + std::string actor; // the character or dashboard user who did it + std::string detail; + }; + + struct GuildSummary { + Guild guild; + uint32_t memberCount{}; + LWOOBJID leaderId{}; + std::string leaderName; + }; + + struct GuildPage { + uint32_t total{}; // every guild (or every pending one) + uint32_t filtered{}; // matching the search + std::vector guilds; + }; + + // Adds a guild (guild.id is not used) and returns its id + virtual int64_t InsertGuild(const Guild& guild) = 0; + virtual std::optional GetGuild(int64_t guildId) = 0; + // Names are unique without regard to case + virtual std::optional GetGuildByName(const std::string& name) = 0; + virtual void SetGuildName(int64_t guildId, const std::string& name, int32_t nameStatus) = 0; + // Removes the guild, its members and its invites. Its events stay. + virtual void DeleteGuild(int64_t guildId) = 0; + + virtual void AddGuildMember(const Member& member) = 0; + virtual void RemoveGuildMember(LWOOBJID characterId) = 0; + virtual void SetGuildMemberRank(LWOOBJID characterId, uint8_t rank) = 0; + virtual std::optional GetGuildMember(LWOOBJID characterId) = 0; + // Ordered by rank, then who joined first + virtual std::vector GetGuildMembers(int64_t guildId) = 0; + + // Replaces the character's invite + virtual void SetGuildInvite(const Invite& invite) = 0; + virtual std::optional GetGuildInvite(LWOOBJID characterId) = 0; + virtual void DeleteGuildInvite(LWOOBJID characterId) = 0; + + virtual uint64_t InsertGuildEvent(const Event& event) = 0; + // Newest first; guildId 0: every guild's + virtual std::vector GetGuildEvents(int64_t guildId, uint32_t limit) = 0; + + // Guilds for the dashboard, newest first. search matches the name, or the id when it is a number. + virtual GuildPage GetGuildPage(uint32_t start, uint32_t length, const std::string& search, bool pendingOnly) = 0; +}; + +#endif //!__IGUILDS__H__ diff --git a/dDatabase/GameDatabase/MySQL/MySQLDatabase.cpp b/dDatabase/GameDatabase/MySQL/MySQLDatabase.cpp index 6cbedc304..903d8ccfa 100644 --- a/dDatabase/GameDatabase/MySQL/MySQLDatabase.cpp +++ b/dDatabase/GameDatabase/MySQL/MySQLDatabase.cpp @@ -113,5 +113,7 @@ void MySQLDatabase::DeleteCharacter(const LWOOBJID characterId) { ExecuteDelete("DELETE FROM ugc_modular_build WHERE character_id=?;", characterId); ExecuteDelete("DELETE FROM pet_names WHERE owner_id=?;", characterId); ExecuteDelete("DELETE FROM player_positions WHERE character_id=?;", characterId); + ExecuteDelete("DELETE FROM guild_members WHERE character_id=?;", characterId); + ExecuteDelete("DELETE FROM guild_invites WHERE character_id=? OR inviter_id=?;", characterId, characterId); ExecuteDelete("DELETE FROM charinfo WHERE id=? LIMIT 1;", characterId); } diff --git a/dDatabase/GameDatabase/MySQL/MySQLDatabase.h b/dDatabase/GameDatabase/MySQL/MySQLDatabase.h index d43c9aad7..a797531b4 100644 --- a/dDatabase/GameDatabase/MySQL/MySQLDatabase.h +++ b/dDatabase/GameDatabase/MySQL/MySQLDatabase.h @@ -227,6 +227,23 @@ public: std::vector GetUgcPlacements(const std::vector& ugcIds) override; std::vector GetUgcMail(const std::vector& subkeys, const LOT modelItemLot) override; + // IGuilds + int64_t InsertGuild(const Guild& guild) override; + std::optional GetGuild(int64_t guildId) override; + std::optional GetGuildByName(const std::string& name) override; + void SetGuildName(int64_t guildId, const std::string& name, int32_t nameStatus) override; + void DeleteGuild(int64_t guildId) override; + void AddGuildMember(const Member& member) override; + void RemoveGuildMember(LWOOBJID characterId) override; + void SetGuildMemberRank(LWOOBJID characterId, uint8_t rank) override; + std::optional GetGuildMember(LWOOBJID characterId) override; + std::vector GetGuildMembers(int64_t guildId) override; + void SetGuildInvite(const Invite& invite) override; + std::optional GetGuildInvite(LWOOBJID characterId) override; + void DeleteGuildInvite(LWOOBJID characterId) override; + uint64_t InsertGuildEvent(const Event& event) override; + std::vector GetGuildEvents(int64_t guildId, uint32_t limit) override; + GuildPage GetGuildPage(uint32_t start, uint32_t length, const std::string& search, bool pendingOnly) override; // IPermissionGrants uint64_t InsertPermissionGrant(const Grant& grant) override; std::optional GetPermissionGrant(uint64_t id) override; diff --git a/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt b/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt index 3f10bff30..b159f4ab2 100644 --- a/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt +++ b/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt @@ -13,6 +13,7 @@ set(DDATABASES_DATABASES_MYSQL_TABLES_SOURCES "AccountStrikes.cpp" "ApiKeys.cpp" "PermissionGrants.cpp" + "Guilds.cpp" "Moderation.cpp" "ServerHealth.cpp" "PlayerPositions.cpp" diff --git a/dDatabase/GameDatabase/MySQL/Tables/Guilds.cpp b/dDatabase/GameDatabase/MySQL/Tables/Guilds.cpp new file mode 100644 index 000000000..55aac35fc --- /dev/null +++ b/dDatabase/GameDatabase/MySQL/Tables/Guilds.cpp @@ -0,0 +1,174 @@ +#include "MySQLDatabase.h" + +#include "GeneralUtils.h" + +namespace { + template IGuilds::Guild ReadGuild(Result& result) { + IGuilds::Guild guild; + guild.id = result->getInt64("id"); + guild.name = result->getString("name").c_str(); + guild.nameStatus = result->getInt("name_status"); + guild.founderId = result->getInt64("founder_id"); + guild.createdAt = result->getInt64("created_at"); + return guild; + } + + template IGuilds::Member ReadMember(Result& result) { + IGuilds::Member member; + member.characterId = result->getInt64("character_id"); + member.guildId = result->getInt64("guild_id"); + member.rank = static_cast(result->getUInt("guild_rank")); + member.joinedAt = result->getInt64("joined_at"); + member.name = result->isNull("name") ? "" : result->getString("name").c_str(); + return member; + } + + template IGuilds::Event ReadEvent(Result& result) { + IGuilds::Event event; + event.id = result->getUInt64("id"); + event.guildId = result->getInt64("guild_id"); + event.time = result->getInt64("time"); + event.kind = result->getString("kind").c_str(); + event.characterId = result->getInt64("character_id"); + event.characterName = result->getString("character_name").c_str(); + event.actor = result->getString("actor").c_str(); + event.detail = result->getString("detail").c_str(); + return event; + } + + constexpr const char* MEMBER_SELECT = "SELECT m.character_id, m.guild_id, m.guild_rank, m.joined_at, c.name " + "FROM guild_members m LEFT JOIN charinfo c ON c.id = m.character_id "; + + // The member count and leader (lowest rank, first to join) of the guild in the outer query as g + constexpr const char* SUMMARY_SELECT = "SELECT g.*, " + "(SELECT COUNT(*) FROM guild_members m WHERE m.guild_id = g.id) AS member_count, " + "(SELECT m.character_id FROM guild_members m WHERE m.guild_id = g.id ORDER BY m.guild_rank, m.joined_at, m.character_id LIMIT 1) AS leader_id " + "FROM guilds g"; +} + +int64_t MySQLDatabase::InsertGuild(const Guild& guild) { + ExecuteInsert("INSERT INTO guilds (name, name_status, founder_id, created_at) VALUES (?, ?, ?, ?);", + guild.name, guild.nameStatus, guild.founderId, guild.createdAt); + auto result = ExecuteSelect("SELECT LAST_INSERT_ID() AS id;"); + return result->next() ? result->getInt64("id") : 0; +} + +std::optional MySQLDatabase::GetGuild(int64_t guildId) { + auto result = ExecuteSelect("SELECT * FROM guilds WHERE id = ?;", guildId); + if (!result->next()) return std::nullopt; + return ReadGuild(result); +} + +std::optional MySQLDatabase::GetGuildByName(const std::string& name) { + auto result = ExecuteSelect("SELECT * FROM guilds WHERE LOWER(name) = LOWER(?) LIMIT 1;", name); + if (!result->next()) return std::nullopt; + return ReadGuild(result); +} + +void MySQLDatabase::SetGuildName(int64_t guildId, const std::string& name, int32_t nameStatus) { + ExecuteUpdate("UPDATE guilds SET name = ?, name_status = ? WHERE id = ?;", name, nameStatus, guildId); +} + +void MySQLDatabase::DeleteGuild(int64_t guildId) { + ExecuteDelete("DELETE FROM guild_members WHERE guild_id = ?;", guildId); + ExecuteDelete("DELETE FROM guild_invites WHERE guild_id = ?;", guildId); + ExecuteDelete("DELETE FROM guilds WHERE id = ?;", guildId); +} + +void MySQLDatabase::AddGuildMember(const Member& member) { + ExecuteInsert("INSERT INTO guild_members (character_id, guild_id, guild_rank, joined_at) VALUES (?, ?, ?, ?);", + member.characterId, member.guildId, static_cast(member.rank), member.joinedAt); +} + +void MySQLDatabase::RemoveGuildMember(LWOOBJID characterId) { + ExecuteDelete("DELETE FROM guild_members WHERE character_id = ?;", characterId); +} + +void MySQLDatabase::SetGuildMemberRank(LWOOBJID characterId, uint8_t rank) { + ExecuteUpdate("UPDATE guild_members SET guild_rank = ? WHERE character_id = ?;", static_cast(rank), characterId); +} + +std::optional MySQLDatabase::GetGuildMember(LWOOBJID characterId) { + auto result = ExecuteSelect(std::string(MEMBER_SELECT) + "WHERE m.character_id = ?;", characterId); + if (!result->next()) return std::nullopt; + return ReadMember(result); +} + +std::vector MySQLDatabase::GetGuildMembers(int64_t guildId) { + std::vector members; + auto result = ExecuteSelect(std::string(MEMBER_SELECT) + "WHERE m.guild_id = ? ORDER BY m.guild_rank, m.joined_at, m.character_id;", guildId); + while (result->next()) members.push_back(ReadMember(result)); + return members; +} + +void MySQLDatabase::SetGuildInvite(const Invite& invite) { + ExecuteInsert("REPLACE INTO guild_invites (character_id, guild_id, inviter_id, created_at) VALUES (?, ?, ?, ?);", + invite.characterId, invite.guildId, invite.inviterId, invite.createdAt); +} + +std::optional MySQLDatabase::GetGuildInvite(LWOOBJID characterId) { + auto result = ExecuteSelect("SELECT * FROM guild_invites WHERE character_id = ?;", characterId); + if (!result->next()) return std::nullopt; + Invite invite; + invite.characterId = result->getInt64("character_id"); + invite.guildId = result->getInt64("guild_id"); + invite.inviterId = result->getInt64("inviter_id"); + invite.createdAt = result->getInt64("created_at"); + return invite; +} + +void MySQLDatabase::DeleteGuildInvite(LWOOBJID characterId) { + ExecuteDelete("DELETE FROM guild_invites WHERE character_id = ?;", characterId); +} + +uint64_t MySQLDatabase::InsertGuildEvent(const Event& event) { + ExecuteInsert("INSERT INTO guild_events (guild_id, time, kind, character_id, character_name, actor, detail) VALUES (?, ?, ?, ?, ?, ?, ?);", + event.guildId, event.time, event.kind, event.characterId, event.characterName, event.actor, event.detail); + auto result = ExecuteSelect("SELECT LAST_INSERT_ID() AS id;"); + return result->next() ? result->getUInt64("id") : 0; +} + +std::vector MySQLDatabase::GetGuildEvents(int64_t guildId, uint32_t limit) { + std::vector events; + auto result = guildId == 0 + ? ExecuteSelect("SELECT * FROM guild_events ORDER BY id DESC LIMIT ?;", limit) + : ExecuteSelect("SELECT * FROM guild_events WHERE guild_id = ? ORDER BY id DESC LIMIT ?;", guildId, limit); + while (result->next()) events.push_back(ReadEvent(result)); + return events; +} + +IGuilds::GuildPage MySQLDatabase::GetGuildPage(uint32_t start, uint32_t length, const std::string& search, bool pendingOnly) { + GuildPage page; + const std::string pending = pendingOnly ? " WHERE g.name_status = 1" : ""; + { + auto result = ExecuteSelect("SELECT COUNT(*) AS count FROM guilds g" + pending + ";"); + page.total = result->next() ? result->getUInt("count") : 0; + } + + // A number in the search box also matches the id (-1 never does) + const int64_t searchId = GeneralUtils::TryParse(search).value_or(-1); + const std::string searchFilter = "(g.name LIKE CONCAT('%', ?, '%') OR g.id = ?)"; + const std::string where = search.empty() ? pending : (pendingOnly ? pending + " AND " : std::string(" WHERE ")) + searchFilter; + if (search.empty()) { + page.filtered = page.total; + } else { + auto result = ExecuteSelect("SELECT COUNT(*) AS count FROM guilds g" + where + ";", search, searchId); + page.filtered = result->next() ? result->getUInt("count") : 0; + } + + const std::string query = std::string(SUMMARY_SELECT) + where + " ORDER BY g.id DESC LIMIT ?, ?;"; + auto result = search.empty() ? ExecuteSelect(query, start, length) : ExecuteSelect(query, search, searchId, start, length); + while (result->next()) { + GuildSummary summary; + summary.guild = ReadGuild(result); + summary.memberCount = result->getUInt("member_count"); + summary.leaderId = result->isNull("leader_id") ? 0 : result->getInt64("leader_id"); + page.guilds.push_back(std::move(summary)); + } + for (auto& summary : page.guilds) { + if (summary.leaderId == 0) continue; + auto name = ExecuteSelect("SELECT name FROM charinfo WHERE id = ?;", summary.leaderId); + if (name->next()) summary.leaderName = name->getString("name").c_str(); + } + return page; +} diff --git a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.cpp b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.cpp index ef773094d..645998e0b 100644 --- a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.cpp +++ b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.cpp @@ -77,5 +77,7 @@ void SQLiteDatabase::DeleteCharacter(const LWOOBJID characterId) { ExecuteDelete("DELETE FROM ugc_modular_build WHERE character_id=?;", characterId); ExecuteDelete("DELETE FROM pet_names WHERE owner_id=?;", characterId); ExecuteDelete("DELETE FROM player_positions WHERE character_id=?;", characterId); + ExecuteDelete("DELETE FROM guild_members WHERE character_id=?;", characterId); + ExecuteDelete("DELETE FROM guild_invites WHERE character_id=? OR inviter_id=?;", characterId, characterId); ExecuteDelete("DELETE FROM charinfo WHERE id=?;", characterId); } diff --git a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h index 03a3fbf85..76562362c 100644 --- a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h +++ b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h @@ -211,6 +211,23 @@ public: std::vector GetUgcPlacements(const std::vector& ugcIds) override; std::vector GetUgcMail(const std::vector& subkeys, const LOT modelItemLot) override; + // IGuilds + int64_t InsertGuild(const Guild& guild) override; + std::optional GetGuild(int64_t guildId) override; + std::optional GetGuildByName(const std::string& name) override; + void SetGuildName(int64_t guildId, const std::string& name, int32_t nameStatus) override; + void DeleteGuild(int64_t guildId) override; + void AddGuildMember(const Member& member) override; + void RemoveGuildMember(LWOOBJID characterId) override; + void SetGuildMemberRank(LWOOBJID characterId, uint8_t rank) override; + std::optional GetGuildMember(LWOOBJID characterId) override; + std::vector GetGuildMembers(int64_t guildId) override; + void SetGuildInvite(const Invite& invite) override; + std::optional GetGuildInvite(LWOOBJID characterId) override; + void DeleteGuildInvite(LWOOBJID characterId) override; + uint64_t InsertGuildEvent(const Event& event) override; + std::vector GetGuildEvents(int64_t guildId, uint32_t limit) override; + GuildPage GetGuildPage(uint32_t start, uint32_t length, const std::string& search, bool pendingOnly) override; // IPermissionGrants uint64_t InsertPermissionGrant(const Grant& grant) override; std::optional GetPermissionGrant(uint64_t id) override; diff --git a/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt b/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt index bd739cf50..2207301bf 100644 --- a/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt +++ b/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt @@ -13,6 +13,7 @@ set(DDATABASES_DATABASES_SQLITE_TABLES_SOURCES "AccountStrikes.cpp" "ApiKeys.cpp" "PermissionGrants.cpp" + "Guilds.cpp" "Moderation.cpp" "ServerHealth.cpp" "PlayerPositions.cpp" diff --git a/dDatabase/GameDatabase/SQLite/Tables/Guilds.cpp b/dDatabase/GameDatabase/SQLite/Tables/Guilds.cpp new file mode 100644 index 000000000..6bde48f09 --- /dev/null +++ b/dDatabase/GameDatabase/SQLite/Tables/Guilds.cpp @@ -0,0 +1,174 @@ +#include "SQLiteDatabase.h" + +#include "GeneralUtils.h" + +namespace { + IGuilds::Guild ReadGuild(CppSQLite3Query& result) { + IGuilds::Guild guild; + guild.id = result.getInt64Field("id"); + guild.name = result.getStringField("name"); + guild.nameStatus = result.getIntField("name_status"); + guild.founderId = result.getInt64Field("founder_id"); + guild.createdAt = result.getInt64Field("created_at"); + return guild; + } + + IGuilds::Member ReadMember(CppSQLite3Query& result) { + IGuilds::Member member; + member.characterId = result.getInt64Field("character_id"); + member.guildId = result.getInt64Field("guild_id"); + member.rank = static_cast(result.getIntField("guild_rank")); + member.joinedAt = result.getInt64Field("joined_at"); + member.name = result.getStringField("name", ""); + return member; + } + + IGuilds::Event ReadEvent(CppSQLite3Query& result) { + IGuilds::Event event; + event.id = static_cast(result.getInt64Field("id")); + event.guildId = result.getInt64Field("guild_id"); + event.time = result.getInt64Field("time"); + event.kind = result.getStringField("kind"); + event.characterId = result.getInt64Field("character_id"); + event.characterName = result.getStringField("character_name"); + event.actor = result.getStringField("actor"); + event.detail = result.getStringField("detail"); + return event; + } + + constexpr const char* MEMBER_SELECT = "SELECT m.character_id, m.guild_id, m.guild_rank, m.joined_at, c.name " + "FROM guild_members m LEFT JOIN charinfo c ON c.id = m.character_id "; + + // The member count and leader (lowest rank, first to join) of the guild in the outer query as g + constexpr const char* SUMMARY_SELECT = "SELECT g.*, " + "(SELECT COUNT(*) FROM guild_members m WHERE m.guild_id = g.id) AS member_count, " + "(SELECT m.character_id FROM guild_members m WHERE m.guild_id = g.id ORDER BY m.guild_rank, m.joined_at, m.character_id LIMIT 1) AS leader_id " + "FROM guilds g"; +} + +int64_t SQLiteDatabase::InsertGuild(const Guild& guild) { + ExecuteInsert("INSERT INTO guilds (name, name_status, founder_id, created_at) VALUES (?, ?, ?, ?);", + guild.name, guild.nameStatus, guild.founderId, guild.createdAt); + auto [_, result] = ExecuteSelect("SELECT last_insert_rowid() AS id;"); + return result.eof() ? 0 : result.getInt64Field("id"); +} + +std::optional SQLiteDatabase::GetGuild(int64_t guildId) { + auto [_, result] = ExecuteSelect("SELECT * FROM guilds WHERE id = ?;", guildId); + if (result.eof()) return std::nullopt; + return ReadGuild(result); +} + +std::optional SQLiteDatabase::GetGuildByName(const std::string& name) { + auto [_, result] = ExecuteSelect("SELECT * FROM guilds WHERE LOWER(name) = LOWER(?) LIMIT 1;", name); + if (result.eof()) return std::nullopt; + return ReadGuild(result); +} + +void SQLiteDatabase::SetGuildName(int64_t guildId, const std::string& name, int32_t nameStatus) { + ExecuteUpdate("UPDATE guilds SET name = ?, name_status = ? WHERE id = ?;", name, nameStatus, guildId); +} + +void SQLiteDatabase::DeleteGuild(int64_t guildId) { + ExecuteDelete("DELETE FROM guild_members WHERE guild_id = ?;", guildId); + ExecuteDelete("DELETE FROM guild_invites WHERE guild_id = ?;", guildId); + ExecuteDelete("DELETE FROM guilds WHERE id = ?;", guildId); +} + +void SQLiteDatabase::AddGuildMember(const Member& member) { + ExecuteInsert("INSERT INTO guild_members (character_id, guild_id, guild_rank, joined_at) VALUES (?, ?, ?, ?);", + member.characterId, member.guildId, static_cast(member.rank), member.joinedAt); +} + +void SQLiteDatabase::RemoveGuildMember(LWOOBJID characterId) { + ExecuteDelete("DELETE FROM guild_members WHERE character_id = ?;", characterId); +} + +void SQLiteDatabase::SetGuildMemberRank(LWOOBJID characterId, uint8_t rank) { + ExecuteUpdate("UPDATE guild_members SET guild_rank = ? WHERE character_id = ?;", static_cast(rank), characterId); +} + +std::optional SQLiteDatabase::GetGuildMember(LWOOBJID characterId) { + auto [_, result] = ExecuteSelect(std::string(MEMBER_SELECT) + "WHERE m.character_id = ?;", characterId); + if (result.eof()) return std::nullopt; + return ReadMember(result); +} + +std::vector SQLiteDatabase::GetGuildMembers(int64_t guildId) { + std::vector members; + auto [_, result] = ExecuteSelect(std::string(MEMBER_SELECT) + "WHERE m.guild_id = ? ORDER BY m.guild_rank, m.joined_at, m.character_id;", guildId); + for (; !result.eof(); result.nextRow()) members.push_back(ReadMember(result)); + return members; +} + +void SQLiteDatabase::SetGuildInvite(const Invite& invite) { + ExecuteInsert("REPLACE INTO guild_invites (character_id, guild_id, inviter_id, created_at) VALUES (?, ?, ?, ?);", + invite.characterId, invite.guildId, invite.inviterId, invite.createdAt); +} + +std::optional SQLiteDatabase::GetGuildInvite(LWOOBJID characterId) { + auto [_, result] = ExecuteSelect("SELECT * FROM guild_invites WHERE character_id = ?;", characterId); + if (result.eof()) return std::nullopt; + Invite invite; + invite.characterId = result.getInt64Field("character_id"); + invite.guildId = result.getInt64Field("guild_id"); + invite.inviterId = result.getInt64Field("inviter_id"); + invite.createdAt = result.getInt64Field("created_at"); + return invite; +} + +void SQLiteDatabase::DeleteGuildInvite(LWOOBJID characterId) { + ExecuteDelete("DELETE FROM guild_invites WHERE character_id = ?;", characterId); +} + +uint64_t SQLiteDatabase::InsertGuildEvent(const Event& event) { + ExecuteInsert("INSERT INTO guild_events (guild_id, time, kind, character_id, character_name, actor, detail) VALUES (?, ?, ?, ?, ?, ?, ?);", + event.guildId, event.time, event.kind, event.characterId, event.characterName, event.actor, event.detail); + auto [_, result] = ExecuteSelect("SELECT last_insert_rowid() AS id;"); + return result.eof() ? 0 : static_cast(result.getInt64Field("id")); +} + +std::vector SQLiteDatabase::GetGuildEvents(int64_t guildId, uint32_t limit) { + std::vector events; + auto [_, result] = guildId == 0 + ? ExecuteSelect("SELECT * FROM guild_events ORDER BY id DESC LIMIT ?;", limit) + : ExecuteSelect("SELECT * FROM guild_events WHERE guild_id = ? ORDER BY id DESC LIMIT ?;", guildId, limit); + for (; !result.eof(); result.nextRow()) events.push_back(ReadEvent(result)); + return events; +} + +IGuilds::GuildPage SQLiteDatabase::GetGuildPage(uint32_t start, uint32_t length, const std::string& search, bool pendingOnly) { + GuildPage page; + const std::string pending = pendingOnly ? " WHERE g.name_status = 1" : ""; + { + auto [_, result] = ExecuteSelect("SELECT COUNT(*) AS count FROM guilds g" + pending + ";"); + page.total = result.eof() ? 0 : static_cast(result.getIntField("count")); + } + + // A number in the search box also matches the id (-1 never does) + const int64_t searchId = GeneralUtils::TryParse(search).value_or(-1); + const std::string searchFilter = "(g.name LIKE '%' || ? || '%' OR g.id = ?)"; + const std::string where = search.empty() ? pending : (pendingOnly ? pending + " AND " : std::string(" WHERE ")) + searchFilter; + if (search.empty()) { + page.filtered = page.total; + } else { + auto [_, result] = ExecuteSelect("SELECT COUNT(*) AS count FROM guilds g" + where + ";", search, searchId); + page.filtered = result.eof() ? 0 : static_cast(result.getIntField("count")); + } + + const std::string query = std::string(SUMMARY_SELECT) + where + " ORDER BY g.id DESC LIMIT ? OFFSET ?;"; + auto [_, result] = search.empty() ? ExecuteSelect(query, length, start) : ExecuteSelect(query, search, searchId, length, start); + for (; !result.eof(); result.nextRow()) { + GuildSummary summary; + summary.guild = ReadGuild(result); + summary.memberCount = static_cast(result.getIntField("member_count")); + summary.leaderId = result.getInt64Field("leader_id", 0); + page.guilds.push_back(std::move(summary)); + } + for (auto& summary : page.guilds) { + if (summary.leaderId == 0) continue; + auto [__, name] = ExecuteSelect("SELECT name FROM charinfo WHERE id = ?;", summary.leaderId); + if (!name.eof()) summary.leaderName = name.getStringField("name"); + } + return page; +} diff --git a/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h b/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h index a94db8c5b..a7c98f74d 100644 --- a/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h +++ b/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h @@ -214,6 +214,22 @@ class TestSQLDatabase : public GameDatabase { std::vector GetAccountNotes(uint32_t accountId) override { return {}; } std::optional GetAccountNote(uint64_t id) override { return {}; } void DeleteAccountNote(uint64_t id) override {} + int64_t InsertGuild(const Guild& guild) override { return 0; } + std::optional GetGuild(int64_t guildId) override { return {}; } + std::optional GetGuildByName(const std::string& name) override { return {}; } + void SetGuildName(int64_t guildId, const std::string& name, int32_t nameStatus) override {} + void DeleteGuild(int64_t guildId) override {} + void AddGuildMember(const Member& member) override {} + void RemoveGuildMember(LWOOBJID characterId) override {} + void SetGuildMemberRank(LWOOBJID characterId, uint8_t rank) override {} + std::optional GetGuildMember(LWOOBJID characterId) override { return {}; } + std::vector GetGuildMembers(int64_t guildId) override { return {}; } + void SetGuildInvite(const Invite& invite) override {} + std::optional GetGuildInvite(LWOOBJID characterId) override { return {}; } + void DeleteGuildInvite(LWOOBJID characterId) override {} + uint64_t InsertGuildEvent(const Event& event) override { return 0; } + std::vector GetGuildEvents(int64_t guildId, uint32_t limit) override { return {}; } + GuildPage GetGuildPage(uint32_t start, uint32_t length, const std::string& search, bool pendingOnly) override { return {}; } uint64_t InsertPermissionGrant(const Grant& grant) override { return 0; } std::optional GetPermissionGrant(uint64_t id) override { return {}; } std::vector GetPermissionGrants(const std::string& targetType, int64_t targetId) override { return {}; } diff --git a/migrations/dlu/mysql/97_guilds.sql b/migrations/dlu/mysql/97_guilds.sql new file mode 100644 index 000000000..bb59874f1 --- /dev/null +++ b/migrations/dlu/mysql/97_guilds.sql @@ -0,0 +1,40 @@ +/* Guilds (docs/Guilds.md), owned by the chat server. The leader is the member with guild_rank 1 (1 leader, 2 officer, + 3 veteran, 4 recruit). name_status: 1 = the name waits for moderation, 2 = approved (as pet_names.approved). + A character is in at most one guild (guild_members) and has at most one pending invite (guild_invites). + guild_events is each guild's history; it is kept when a guild is disbanded. */ +CREATE TABLE IF NOT EXISTS guilds ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(64) NOT NULL, + name_status INT NOT NULL DEFAULT 1, + founder_id BIGINT NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + UNIQUE INDEX guilds_name (name) +); + +CREATE TABLE IF NOT EXISTS guild_members ( + character_id BIGINT NOT NULL PRIMARY KEY, + guild_id BIGINT NOT NULL, + guild_rank TINYINT UNSIGNED NOT NULL, + joined_at BIGINT NOT NULL, + INDEX guild_members_guild (guild_id) +); + +CREATE TABLE IF NOT EXISTS guild_invites ( + character_id BIGINT NOT NULL PRIMARY KEY, + guild_id BIGINT NOT NULL, + inviter_id BIGINT NOT NULL, + created_at BIGINT NOT NULL, + INDEX guild_invites_guild (guild_id) +); + +CREATE TABLE IF NOT EXISTS guild_events ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + guild_id BIGINT NOT NULL, + time BIGINT NOT NULL, + kind VARCHAR(24) NOT NULL, + character_id BIGINT NOT NULL DEFAULT 0, + character_name VARCHAR(35) NOT NULL DEFAULT '', + actor VARCHAR(64) NOT NULL DEFAULT '', + detail VARCHAR(255) NOT NULL DEFAULT '', + INDEX guild_events_guild (guild_id, id) +); diff --git a/migrations/dlu/sqlite/80_guilds.sql b/migrations/dlu/sqlite/80_guilds.sql new file mode 100644 index 000000000..05de2165c --- /dev/null +++ b/migrations/dlu/sqlite/80_guilds.sql @@ -0,0 +1,36 @@ +/* Guilds, owned by the chat server. See the MySQL migration. */ +CREATE TABLE IF NOT EXISTS guilds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL COLLATE NOCASE UNIQUE, + name_status INTEGER NOT NULL DEFAULT 1, + founder_id BIGINT NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS guild_members ( + character_id BIGINT NOT NULL PRIMARY KEY, + guild_id BIGINT NOT NULL, + guild_rank INTEGER NOT NULL, + joined_at BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS guild_members_guild ON guild_members (guild_id); + +CREATE TABLE IF NOT EXISTS guild_invites ( + character_id BIGINT NOT NULL PRIMARY KEY, + guild_id BIGINT NOT NULL, + inviter_id BIGINT NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS guild_invites_guild ON guild_invites (guild_id); + +CREATE TABLE IF NOT EXISTS guild_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id BIGINT NOT NULL, + time BIGINT NOT NULL, + kind TEXT NOT NULL, + character_id BIGINT NOT NULL DEFAULT 0, + character_name TEXT NOT NULL DEFAULT '', + actor TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS guild_events_guild ON guild_events (guild_id, id); diff --git a/tests/dDatabaseTests/DatabaseParityTests.cpp b/tests/dDatabaseTests/DatabaseParityTests.cpp index 55080d10e..956c18bdc 100644 --- a/tests/dDatabaseTests/DatabaseParityTests.cpp +++ b/tests/dDatabaseTests/DatabaseParityTests.cpp @@ -65,6 +65,12 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IAccountEmails::AccountToken, accountId, data NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IAccountNotes::AccountNote, id, accountId, kind, text, actor, createdAt); NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IAccountStrikes::Strike, id, accountId, characterId, source, subject, reason, givenById, givenBy, createdAt, revokedAt, revokedBy, revokeReason); NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IPermissionGrants::Grant, id, targetType, targetId, kind, name, deny, expiresAt, note, grantedAt, grantedById, grantedBy, revokedAt, revokedBy); +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IGuilds::Guild, id, name, nameStatus, founderId, createdAt); +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IGuilds::Member, characterId, guildId, rank, joinedAt, name); +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IGuilds::Invite, characterId, guildId, inviterId, createdAt); +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IGuilds::Event, id, guildId, time, kind, characterId, characterName, actor, detail); +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IGuilds::GuildSummary, guild, memberCount, leaderId, leaderName); +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IGuilds::GuildPage, total, filtered, guilds); NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IApiKeys::ApiKey, id, accountId, name, note, keyHash, keyPrefix, permissions, readOnly, allowedIps, allowedPaths, rateLimit, dailyQuota, createdAt, createdBy, issuedAt, expiresAt, revokedAt, revokedBy, lastUsedAt, lastIp, requestCount, quotaDay, dayCount); NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ICharacterSnapshots::CharacterSnapshot, id, characterId, takenAt, reason, actor, size, hash, compressed); NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IChatLog::ChatMessage, id, time, channel, senderId, senderName, accountId, recipientId, recipientName, zoneId, instanceId, cloneId, message, blocked); @@ -1289,6 +1295,63 @@ TEST_F(ParitySeeded, PermissionGrants) { }); } +TEST_F(ParitySeeded, Guilds) { + constexpr LWOOBJID GONE = 1152921510000900001LL; // no charinfo row + Both("InsertGuild", [](GameDatabase& db) { + return json{ db.InsertGuild({ 0, "Brick Builders", IGuilds::NAME_APPROVED, CHAR_ALICE, 1700000000 }), + db.InsertGuild({ 0, "Maelstrom Hunters", IGuilds::NAME_PENDING, CHAR_BOB, 1700000100 }), + db.InsertGuild({ 0, "Empty", IGuilds::NAME_APPROVED, CHAR_GM, 1700000200 }) }; + }); + Both("GetGuild", [](GameDatabase& db) { return json{ db.GetGuild(1), db.GetGuild(2), db.GetGuild(99) }; }); + Both("GetGuildByName", [](GameDatabase& db) { + return json{ db.GetGuildByName("Brick Builders"), db.GetGuildByName("brick BUILDERS"), db.GetGuildByName("Brick"), db.GetGuildByName("") }; + }); + Both("AddGuildMember", [&](GameDatabase& db) { + db.AddGuildMember({ CHAR_ALICE, 1, 1, 1700000000 }); + db.AddGuildMember({ CHAR_GM, 1, 4, 1700000300 }); + db.AddGuildMember({ GONE, 1, 4, 1700000250 }); + db.AddGuildMember({ CHAR_BOB, 2, 1, 1700000100 }); + }); + Both("GetGuildMember", [&](GameDatabase& db) { return json{ db.GetGuildMember(CHAR_ALICE), db.GetGuildMember(GONE), db.GetGuildMember(CHAR_ALICE2) }; }); + Both("GetGuildMembers", [](GameDatabase& db) { return json{ db.GetGuildMembers(1), db.GetGuildMembers(2), db.GetGuildMembers(3) }; }); + Both("SetGuildMemberRank", [&](GameDatabase& db) { db.SetGuildMemberRank(CHAR_GM, 2); return db.GetGuildMembers(1); }); + Both("GetGuildPage", [](GameDatabase& db) { + return json{ db.GetGuildPage(0, 10, "", false), db.GetGuildPage(0, 1, "", false), db.GetGuildPage(1, 10, "", false), + db.GetGuildPage(0, 10, "", true), db.GetGuildPage(0, 10, "hunt", false), db.GetGuildPage(0, 10, "1", false), + db.GetGuildPage(0, 10, "brick", true) }; + }); + Both("SetGuildName", [](GameDatabase& db) { + db.SetGuildName(2, "Hunters", IGuilds::NAME_APPROVED); + return json{ db.GetGuild(2), db.GetGuildByName("Maelstrom Hunters"), db.GetGuildPage(0, 10, "", true) }; + }); + Both("SetGuildInvite", [&](GameDatabase& db) { + db.SetGuildInvite({ CHAR_ALICE2, 1, CHAR_ALICE, 1700000400 }); + const auto first = db.GetGuildInvite(CHAR_ALICE2); + db.SetGuildInvite({ CHAR_ALICE2, 2, CHAR_BOB, 1700000500 }); + return json{ first, db.GetGuildInvite(CHAR_ALICE2), db.GetGuildInvite(CHAR_BOB) }; + }); + Both("DeleteGuildInvite", [&](GameDatabase& db) { db.DeleteGuildInvite(CHAR_ALICE2); return db.GetGuildInvite(CHAR_ALICE2); }); + Both("RemoveGuildMember", [&](GameDatabase& db) { db.RemoveGuildMember(GONE); return json{ db.GetGuildMember(GONE), db.GetGuildMembers(1) }; }); + Both("InsertGuildEvent", [&](GameDatabase& db) { + json ids = json::array(); + ids.push_back(db.InsertGuildEvent({ 0, 1, 1700000000, "created", CHAR_ALICE, "Alice", "Alice", "Brick Builders" })); + ids.push_back(db.InsertGuildEvent({ 0, 2, 1700000100, "created", CHAR_BOB, "Bob", "Bob", "" })); + ids.push_back(db.InsertGuildEvent({ 0, 1, 1700000300, "joined", CHAR_GM, "GameMaster", "Alice", "" })); + return ids; + }); + Both("GetGuildEvents", [](GameDatabase& db) { return json{ db.GetGuildEvents(1, 10), db.GetGuildEvents(0, 2), db.GetGuildEvents(3, 10) }; }); + Both("DeleteGuild", [&](GameDatabase& db) { + db.SetGuildInvite({ CHAR_ALICE2, 1, CHAR_ALICE, 1700000600 }); + db.DeleteGuild(1); + return json{ db.GetGuild(1), db.GetGuildMembers(1), db.GetGuildMember(CHAR_ALICE), db.GetGuildInvite(CHAR_ALICE2), db.GetGuildEvents(1, 10), db.GetGuildPage(0, 10, "", false) }; + }); + Both("Guild name unique", [](GameDatabase& db) { + bool threw = false; + try { db.InsertGuild({ 0, "hunters", IGuilds::NAME_APPROVED, CHAR_ALICE, 1700000700 }); } catch (...) { threw = true; } + return threw; + }); +} + TEST_F(ParitySeeded, ApiKeys) { Both("InsertApiKey", [](GameDatabase& db) { json ids = json::array();