From 1a4ad8b7a5c802ef04b0e74aa9845ae14a40b392 Mon Sep 17 00:00:00 2001 From: Sainan <63328889+Sainan@users.noreply.github.com> Date: Tue, 1 Apr 2025 02:28:24 -0700 Subject: [PATCH] feat: clan applications (#1410) Reviewed-on: https://onlyg.it/OpenWF/SpaceNinjaServer/pulls/1410 Co-authored-by: Sainan <63328889+Sainan@users.noreply.github.com> Co-committed-by: Sainan <63328889+Sainan@users.noreply.github.com> --- src/controllers/api/addToGuildController.ts | 151 ++++++++++-------- .../api/confirmGuildInvitationController.ts | 69 +++++++- src/controllers/api/createGuildController.ts | 3 + .../api/getGuildContributionsController.ts | 5 +- .../api/removeFromGuildController.ts | 21 +++ src/models/guildModel.ts | 3 + src/routes/api.ts | 1 + src/services/guildService.ts | 4 +- src/types/guildTypes.ts | 26 ++- 9 files changed, 211 insertions(+), 72 deletions(-) diff --git a/src/controllers/api/addToGuildController.ts b/src/controllers/api/addToGuildController.ts index 41a6e227..ef75f551 100644 --- a/src/controllers/api/addToGuildController.ts +++ b/src/controllers/api/addToGuildController.ts @@ -3,82 +3,107 @@ import { Account } from "@/src/models/loginModel"; import { fillInInventoryDataForGuildMember, hasGuildPermission } from "@/src/services/guildService"; import { createMessage } from "@/src/services/inboxService"; import { getInventory } from "@/src/services/inventoryService"; -import { getAccountForRequest, getSuffixedName } from "@/src/services/loginService"; +import { getAccountForRequest, getAccountIdForRequest, getSuffixedName } from "@/src/services/loginService"; import { IOid } from "@/src/types/commonTypes"; import { GuildPermission, IGuildMemberClient } from "@/src/types/guildTypes"; +import { logger } from "@/src/utils/logger"; import { RequestHandler } from "express"; import { ExportFlavour } from "warframe-public-export-plus"; export const addToGuildController: RequestHandler = async (req, res) => { const payload = JSON.parse(String(req.body)) as IAddToGuildRequest; - const account = await Account.findOne({ DisplayName: payload.UserName }); - if (!account) { - res.status(400).json("Username does not exist"); - return; - } + if ("UserName" in payload) { + // Clan recruiter sending an invite - const inventory = await getInventory(account._id.toString(), "Settings"); - // TODO: Also consider GIFT_MODE_FRIENDS once friends are implemented - if (inventory.Settings?.GuildInvRestriction == "GIFT_MODE_NONE") { - res.status(400).json("Invite restricted"); - return; - } - - const guild = (await Guild.findById(payload.GuildId.$oid, "Name Ranks"))!; - const senderAccount = await getAccountForRequest(req); - if (!(await hasGuildPermission(guild, senderAccount._id.toString(), GuildPermission.Recruiter))) { - res.status(400).json("Invalid permission"); - } - - if ( - await GuildMember.exists({ - accountId: account._id, - guildId: payload.GuildId.$oid - }) - ) { - res.status(400).json("User already invited to clan"); - return; - } - - await GuildMember.insertOne({ - accountId: account._id, - guildId: payload.GuildId.$oid, - status: 2 // outgoing invite - }); - - const senderInventory = await getInventory(senderAccount._id.toString(), "ActiveAvatarImageType"); - await createMessage(account._id.toString(), [ - { - sndr: getSuffixedName(senderAccount), - msg: "/Lotus/Language/Menu/Mailbox_ClanInvite_Body", - arg: [ - { - Key: "clan", - Tag: guild.Name - } - ], - sub: "/Lotus/Language/Menu/Mailbox_ClanInvite_Title", - icon: ExportFlavour[senderInventory.ActiveAvatarImageType].icon, - contextInfo: payload.GuildId.$oid, - highPriority: true, - acceptAction: "GUILD_INVITE", - declineAction: "GUILD_INVITE", - hasAccountAction: true + const account = await Account.findOne({ DisplayName: payload.UserName }); + if (!account) { + res.status(400).json("Username does not exist"); + return; } - ]); - const member: IGuildMemberClient = { - _id: { $oid: account._id.toString() }, - DisplayName: account.DisplayName, - Rank: 7, - Status: 2 - }; - await fillInInventoryDataForGuildMember(member); - res.json({ NewMember: member }); + const inventory = await getInventory(account._id.toString(), "Settings"); + // TODO: Also consider GIFT_MODE_FRIENDS once friends are implemented + if (inventory.Settings?.GuildInvRestriction == "GIFT_MODE_NONE") { + res.status(400).json("Invite restricted"); + return; + } + + const guild = (await Guild.findById(payload.GuildId.$oid, "Name Ranks"))!; + const senderAccount = await getAccountForRequest(req); + if (!(await hasGuildPermission(guild, senderAccount._id.toString(), GuildPermission.Recruiter))) { + res.status(400).json("Invalid permission"); + } + + if ( + await GuildMember.exists({ + accountId: account._id, + guildId: payload.GuildId.$oid + }) + ) { + res.status(400).json("User already invited to clan"); + return; + } + + await GuildMember.insertOne({ + accountId: account._id, + guildId: payload.GuildId.$oid, + status: 2 // outgoing invite + }); + + const senderInventory = await getInventory(senderAccount._id.toString(), "ActiveAvatarImageType"); + await createMessage(account._id, [ + { + sndr: getSuffixedName(senderAccount), + msg: "/Lotus/Language/Menu/Mailbox_ClanInvite_Body", + arg: [ + { + Key: "clan", + Tag: guild.Name + } + ], + sub: "/Lotus/Language/Menu/Mailbox_ClanInvite_Title", + icon: ExportFlavour[senderInventory.ActiveAvatarImageType].icon, + contextInfo: payload.GuildId.$oid, + highPriority: true, + acceptAction: "GUILD_INVITE", + declineAction: "GUILD_INVITE", + hasAccountAction: true + } + ]); + + const member: IGuildMemberClient = { + _id: { $oid: account._id.toString() }, + DisplayName: account.DisplayName, + Rank: 7, + Status: 2 + }; + await fillInInventoryDataForGuildMember(member); + res.json({ NewMember: member }); + } else if ("RequestMsg" in payload) { + // Player applying to join a clan + const accountId = await getAccountIdForRequest(req); + try { + await GuildMember.insertOne({ + accountId, + guildId: payload.GuildId.$oid, + status: 1, // incoming invite + RequestMsg: payload.RequestMsg, + RequestExpiry: new Date(Date.now() + 14 * 86400 * 1000) // TOVERIFY: I can't find any good information about this with regards to live, but 2 weeks seem reasonable. + }); + } catch (e) { + // Assuming this is "E11000 duplicate key error" due to the guildId-accountId unique index. + res.status(400).send("Already requested"); + } + res.end(); + } else { + logger.error(`data provided to ${req.path}: ${String(req.body)}`); + res.status(400).end(); + } }; interface IAddToGuildRequest { - UserName: string; + UserName?: string; GuildId: IOid; + RequestMsg?: string; } diff --git a/src/controllers/api/confirmGuildInvitationController.ts b/src/controllers/api/confirmGuildInvitationController.ts index c03a4285..e65cf9db 100644 --- a/src/controllers/api/confirmGuildInvitationController.ts +++ b/src/controllers/api/confirmGuildInvitationController.ts @@ -1,18 +1,76 @@ +import { getJSONfromString } from "@/src/helpers/stringHelpers"; import { Guild, GuildMember } from "@/src/models/guildModel"; -import { deleteGuild, getGuildClient, removeDojoKeyItems } from "@/src/services/guildService"; +import { Account } from "@/src/models/loginModel"; +import { deleteGuild, getGuildClient, hasGuildPermission, removeDojoKeyItems } from "@/src/services/guildService"; import { addRecipes, combineInventoryChanges, getInventory } from "@/src/services/inventoryService"; -import { getAccountForRequest, getSuffixedName } from "@/src/services/loginService"; +import { getAccountForRequest, getAccountIdForRequest, getSuffixedName } from "@/src/services/loginService"; +import { GuildPermission } from "@/src/types/guildTypes"; import { IInventoryChanges } from "@/src/types/purchaseTypes"; import { RequestHandler } from "express"; import { Types } from "mongoose"; export const confirmGuildInvitationController: RequestHandler = async (req, res) => { + if (req.body) { + // POST request: Clan representative accepting invite(s). + const accountId = await getAccountIdForRequest(req); + const guild = (await Guild.findById(req.query.clanId as string, "Ranks RosterActivity"))!; + if (!(await hasGuildPermission(guild, accountId, GuildPermission.Recruiter))) { + res.status(400).json("Invalid permission"); + return; + } + const payload = getJSONfromString<{ userId: string }>(String(req.body)); + const filter: { accountId?: string; status: number } = { status: 1 }; + if (payload.userId != "all") { + filter.accountId = payload.userId; + } + const guildMembers = await GuildMember.find(filter); + const newMembers: string[] = []; + for (const guildMember of guildMembers) { + guildMember.status = 0; + guildMember.RequestMsg = undefined; + guildMember.RequestExpiry = undefined; + await guildMember.save(); + + // Remove other pending applications for this account + await GuildMember.deleteMany({ accountId: guildMember.accountId, status: 1 }); + + // Update inventory of new member + const inventory = await getInventory(guildMember.accountId.toString(), "GuildId Recipes"); + inventory.GuildId = new Types.ObjectId(req.query.clanId as string); + addRecipes(inventory, [ + { + ItemType: "/Lotus/Types/Keys/DojoKeyBlueprint", + ItemCount: 1 + } + ]); + await inventory.save(); + + // Add join to clan log + const account = (await Account.findOne({ _id: guildMember.accountId }))!; + guild.RosterActivity ??= []; + guild.RosterActivity.push({ + dateTime: new Date(), + entryType: 6, + details: getSuffixedName(account) + }); + + newMembers.push(account._id.toString()); + } + await guild.save(); + res.json({ + NewMembers: newMembers + }); + return; + } + + // GET request: A player accepting an invite they got in their inbox. + const account = await getAccountForRequest(req); const invitedGuildMember = await GuildMember.findOne({ accountId: account._id, guildId: req.query.clanId as string }); - if (invitedGuildMember) { + if (invitedGuildMember && invitedGuildMember.status == 2) { let inventoryChanges: IInventoryChanges = {}; // If this account is already in a guild, we need to do cleanup first. @@ -31,6 +89,10 @@ export const confirmGuildInvitationController: RequestHandler = async (req, res) invitedGuildMember.status = 0; await invitedGuildMember.save(); + // Remove pending applications for this account + await GuildMember.deleteMany({ accountId: account._id, status: 1 }); + + // Update inventory of new member const inventory = await getInventory(account._id.toString(), "GuildId LevelKeys Recipes"); inventory.GuildId = new Types.ObjectId(req.query.clanId as string); const recipeChanges = [ @@ -45,6 +107,7 @@ export const confirmGuildInvitationController: RequestHandler = async (req, res) const guild = (await Guild.findById(req.query.clanId as string))!; + // Add join to clan log guild.RosterActivity ??= []; guild.RosterActivity.push({ dateTime: new Date(), diff --git a/src/controllers/api/createGuildController.ts b/src/controllers/api/createGuildController.ts index 4d3e21c9..9b5bc768 100644 --- a/src/controllers/api/createGuildController.ts +++ b/src/controllers/api/createGuildController.ts @@ -9,6 +9,9 @@ export const createGuildController: RequestHandler = async (req, res) => { const accountId = await getAccountIdForRequest(req); const payload = getJSONfromString(String(req.body)); + // Remove pending applications for this account + await GuildMember.deleteMany({ accountId, status: 1 }); + // Create guild on database const guild = new Guild({ Name: await createUniqueClanName(payload.guildName) diff --git a/src/controllers/api/getGuildContributionsController.ts b/src/controllers/api/getGuildContributionsController.ts index 72d61cbe..c17729f7 100644 --- a/src/controllers/api/getGuildContributionsController.ts +++ b/src/controllers/api/getGuildContributionsController.ts @@ -1,6 +1,7 @@ import { GuildMember } from "@/src/models/guildModel"; import { getInventory } from "@/src/services/inventoryService"; import { getAccountIdForRequest } from "@/src/services/loginService"; +import { IGuildMemberClient } from "@/src/types/guildTypes"; import { RequestHandler } from "express"; export const getGuildContributionsController: RequestHandler = async (req, res) => { @@ -8,11 +9,11 @@ export const getGuildContributionsController: RequestHandler = async (req, res) const guildId = (await getInventory(accountId, "GuildId")).GuildId; const guildMember = (await GuildMember.findOne({ guildId, accountId: req.query.buddyId }))!; res.json({ - _id: { $oid: req.query.buddyId }, + _id: { $oid: req.query.buddyId as string }, RegularCreditsContributed: guildMember.RegularCreditsContributed, PremiumCreditsContributed: guildMember.PremiumCreditsContributed, MiscItemsContributed: guildMember.MiscItemsContributed, ConsumablesContributed: [], // ??? ShipDecorationsContributed: guildMember.ShipDecorationsContributed - }); + } satisfies Partial); }; diff --git a/src/controllers/api/removeFromGuildController.ts b/src/controllers/api/removeFromGuildController.ts index 3571e1e1..db5a2ea3 100644 --- a/src/controllers/api/removeFromGuildController.ts +++ b/src/controllers/api/removeFromGuildController.ts @@ -2,6 +2,7 @@ import { GuildMember } from "@/src/models/guildModel"; import { Inbox } from "@/src/models/inboxModel"; import { Account } from "@/src/models/loginModel"; import { deleteGuild, getGuildForRequest, hasGuildPermission, removeDojoKeyItems } from "@/src/services/guildService"; +import { createMessage } from "@/src/services/inboxService"; import { getInventory } from "@/src/services/inventoryService"; import { getAccountForRequest, getSuffixedName } from "@/src/services/loginService"; import { GuildPermission } from "@/src/types/guildTypes"; @@ -26,6 +27,26 @@ export const removeFromGuildController: RequestHandler = async (req, res) => { inventory.GuildId = undefined; removeDojoKeyItems(inventory); await inventory.save(); + } else if (guildMember.status == 1) { + // TOVERIFY: Is this inbox message actually sent on live? + await createMessage(guildMember.accountId, [ + { + sndr: "/Lotus/Language/Bosses/Ordis", + msg: "/Lotus/Language/Clan/RejectedFromClan", + sub: "/Lotus/Language/Clan/RejectedFromClanHeader", + arg: [ + { + Key: "PLAYER_NAME", + Tag: (await Account.findOne({ _id: guildMember.accountId }, "DisplayName"))!.DisplayName + }, + { + Key: "CLAN_NAME", + Tag: guild.Name + } + ] + // TOVERIFY: If this message is sent on live, is it highPriority? + } + ]); } else if (guildMember.status == 2) { // Delete the inbox message for the invite await Inbox.deleteOne({ diff --git a/src/models/guildModel.ts b/src/models/guildModel.ts index cf2d1d07..0ce72882 100644 --- a/src/models/guildModel.ts +++ b/src/models/guildModel.ts @@ -218,6 +218,8 @@ const guildMemberSchema = new Schema({ guildId: Types.ObjectId, status: { type: Number, required: true }, rank: { type: Number, default: 7 }, + RequestMsg: String, + RequestExpiry: Date, RegularCreditsContributed: Number, PremiumCreditsContributed: Number, MiscItemsContributed: { type: [typeCountSchema], default: undefined }, @@ -225,6 +227,7 @@ const guildMemberSchema = new Schema({ }); guildMemberSchema.index({ accountId: 1, guildId: 1 }, { unique: true }); +guildMemberSchema.index({ RequestExpiry: 1 }, { expireAfterSeconds: 0 }); export const GuildMember = model("GuildMember", guildMemberSchema); diff --git a/src/routes/api.ts b/src/routes/api.ts index 970bc503..2a96f768 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -188,6 +188,7 @@ apiRouter.post("/claimCompletedRecipe.php", claimCompletedRecipeController); apiRouter.post("/clearDialogueHistory.php", clearDialogueHistoryController); apiRouter.post("/clearNewEpisodeReward.php", clearNewEpisodeRewardController); apiRouter.post("/completeRandomModChallenge.php", completeRandomModChallengeController); +apiRouter.post("/confirmGuildInvitation.php", confirmGuildInvitationController); apiRouter.post("/contributeGuildClass.php", contributeGuildClassController); apiRouter.post("/contributeToDojoComponent.php", contributeToDojoComponentController); apiRouter.post("/contributeToVault.php", contributeToVaultController); diff --git a/src/services/guildService.ts b/src/services/guildService.ts index 658b0f27..1da4a370 100644 --- a/src/services/guildService.ts +++ b/src/services/guildService.ts @@ -57,7 +57,9 @@ export const getGuildClient = async (guild: TGuildDatabaseDocument, accountId: s const member: IGuildMemberClient = { _id: toOid(guildMember.accountId), Rank: guildMember.rank, - Status: guildMember.status + Status: guildMember.status, + Note: guildMember.RequestMsg, + RequestExpiry: guildMember.RequestExpiry ? toMongoDate(guildMember.RequestExpiry) : undefined }; if (guildMember.accountId.equals(accountId)) { missingEntry = false; diff --git a/src/types/guildTypes.ts b/src/types/guildTypes.ts index a5a64f87..1ff37923 100644 --- a/src/types/guildTypes.ts +++ b/src/types/guildTypes.ts @@ -89,19 +89,39 @@ export interface IGuildMemberDatabase { guildId: Types.ObjectId; status: number; rank: number; + RequestMsg?: string; + RequestExpiry?: Date; RegularCreditsContributed?: number; PremiumCreditsContributed?: number; MiscItemsContributed?: IMiscItem[]; ShipDecorationsContributed?: ITypeCount[]; } -export interface IGuildMemberClient { +interface IFriendInfo { _id: IOid; - Status: number; - Rank: number; DisplayName?: string; + PlatformNames?: string[]; + PlatformAccountId?: string; + Status: number; ActiveAvatarImageType?: string; + LastLogin?: IMongoDate; PlayerLevel?: number; + Suffix?: number; + Note?: string; + Favorite?: boolean; + NewRequest?: boolean; +} + +// GuildMemberInfo +export interface IGuildMemberClient extends IFriendInfo { + Rank: number; + Joined?: IMongoDate; + RequestExpiry?: IMongoDate; + RegularCreditsContributed?: number; + PremiumCreditsContributed?: number; + MiscItemsContributed?: IMiscItem[]; + ConsumablesContributed?: ITypeCount[]; + ShipDecorationsContributed?: ITypeCount[]; } export interface IGuildVault {