diff --git a/database/migrations/20260927120000-add-player-clan-joindate.js b/database/migrations/20260927120000-add-player-clan-joindate.js new file mode 100644 index 000000000..dedd110ea --- /dev/null +++ b/database/migrations/20260927120000-add-player-clan-joindate.js @@ -0,0 +1,67 @@ +/** + * Migration: Add clan_joindate field to players + * + * Issue #1003: Player data should hold the date when the player joined the clan. + * - Players currently in a clan get 01-09-2026 as their join date + * - Players without a clan get null + */ + +const DEFAULT_JOIN_DATE = new Date(Date.UTC(2026, 8, 1)); // "2026-09-01T00:00:00.000Z" + +/** + * @param db {import('mongodb').Db} + * @param client {import('mongodb').MongoClient} + */ +module.exports.up = async (db, client) => { + const session = client.startSession(); + + try { + await session.withTransaction(async () => { + const players = db.collection('players'); + + const inClan = await players.updateMany( + { clan_joindate: { $exists: false }, clan_id: { $ne: null } }, + { $set: { clan_joindate: DEFAULT_JOIN_DATE } }, + { session }, + ); + + const withoutClan = await players.updateMany( + { clan_joindate: { $exists: false } }, + { $set: { clan_joindate: null } }, + { session }, + ); + + console.log( + `[migrate-mongo] Set clan_joindate for ${inClan.modifiedCount} players in a clan and ${withoutClan.modifiedCount} players without a clan`, + ); + }); + } finally { + await session.endSession(); + } +}; + +/** + * @param db {import('mongodb').Db} + * @param client {import('mongodb').MongoClient} + */ +module.exports.down = async (db, client) => { + const session = client.startSession(); + + try { + await session.withTransaction(async () => { + const players = db.collection('players'); + + const result = await players.updateMany( + { clan_joindate: { $exists: true } }, + { $unset: { clan_joindate: '' } }, + { session }, + ); + + console.log( + `[migrate-mongo] Removed clan_joindate from ${result.modifiedCount} players`, + ); + }); + } finally { + await session.endSession(); + } +}; diff --git a/src/__tests__/clan/join/JoinService/handleJoinRequest.test.ts b/src/__tests__/clan/join/JoinService/handleJoinRequest.test.ts index 6bdea4316..480ee3627 100644 --- a/src/__tests__/clan/join/JoinService/handleJoinRequest.test.ts +++ b/src/__tests__/clan/join/JoinService/handleJoinRequest.test.ts @@ -79,6 +79,20 @@ describe('JoinService.handleJoinRequest() test suite', () => { expect(clanDto.name).toBe(openClan.name); }); + it('Should set clan_joindate of the joined player to the current date', async () => { + const joinToCreate = joinBuilder.setClanId(openClan._id).build(); + + const before = Date.now(); + await joinService.handleJoinRequest(joinToCreate.clan_id, player._id); + const after = Date.now(); + + const playerInDB = await playerModel.findById(player._id); + const joinTime = playerInDB.clan_joindate.getTime(); + + expect(joinTime).toBeGreaterThanOrEqual(before); + expect(joinTime).toBeLessThanOrEqual(after); + }); + it('Should throw NotFoundException if clan with that _id does not exists', async () => { const nonExisting_id = getNonExisting_id(); const joinToCreate = joinBuilder.setClanId(nonExisting_id).build(); diff --git a/src/__tests__/clan/join/JoinService/leaveClan.test.ts b/src/__tests__/clan/join/JoinService/leaveClan.test.ts index 93b5515ab..d9f9a20c3 100644 --- a/src/__tests__/clan/join/JoinService/leaveClan.test.ts +++ b/src/__tests__/clan/join/JoinService/leaveClan.test.ts @@ -56,6 +56,19 @@ describe('JoinService.leaveClan() test suite', () => { expect(leftPlayer.clan_id).toBeNull(); }); + it('Should clear clan_joindate of the player who left', async () => { + await playerModel.updateOne( + { _id: player._id }, + { clan_joindate: new Date(Date.UTC(2026, 8, 1)) }, + ); + + await joinService.leaveClan(player._id); + + const leftPlayer = await playerModel.findById(player._id); + + expect(leftPlayer.clan_joindate).toBeNull(); + }); + it('Should throw NotFoundException if clan does not exists', async () => { const nonExisting_id = getNonExisting_id(); await playerModel.updateOne( diff --git a/src/__tests__/player/data/player/playerBuilder.ts b/src/__tests__/player/data/player/playerBuilder.ts index 3e6dafcab..70eaf80a8 100644 --- a/src/__tests__/player/data/player/playerBuilder.ts +++ b/src/__tests__/player/data/player/playerBuilder.ts @@ -44,6 +44,7 @@ export default class PlayerBuilder { clanRole_id: null, _id: undefined, environment: Environment.TEACHING_DEMO, + clan_joindate: null, }; build(): Player { diff --git a/src/__tests__/player/data/player/playerDtoBuilder.ts b/src/__tests__/player/data/player/playerDtoBuilder.ts index 3129a642d..be1d40dbd 100644 --- a/src/__tests__/player/data/player/playerDtoBuilder.ts +++ b/src/__tests__/player/data/player/playerDtoBuilder.ts @@ -44,6 +44,7 @@ export default class PlayerDtoBuilder implements IDataBuilder { battleCharacter_ids: [], Clan: undefined, CustomCharacter: [], + clan_joindate: null, }; build(): PlayerDto { diff --git a/src/box/accountClaimer/testerAccount.service.ts b/src/box/accountClaimer/testerAccount.service.ts index 407722a48..f2e23ef9a 100644 --- a/src/box/accountClaimer/testerAccount.service.ts +++ b/src/box/accountClaimer/testerAccount.service.ts @@ -119,6 +119,7 @@ export class TesterAccountService { { clan_id: clanWithLeastPlayers._id, clanRole_id: leaderRole._id, + clan_joindate: new Date(), }, ); diff --git a/src/clan/clan.service.ts b/src/clan/clan.service.ts index e9c1ef4e0..a952e198d 100644 --- a/src/clan/clan.service.ts +++ b/src/clan/clan.service.ts @@ -123,7 +123,11 @@ export class ClanService { const [, playerErrors] = await this.playerService.updateOneById( player_id, - { clan_id: clan._id, clanRole_id: leaderRole?._id }, + { + clan_id: clan._id, + clanRole_id: leaderRole?._id, + clan_joindate: new Date(), + }, { session }, ); if (playerErrors) return await cancelTransaction(session, playerErrors); @@ -432,7 +436,7 @@ export class ClanService { for (const player of clan.Player) { const [, upErrors] = await this.playerService.updateOneById( player._id, - { clan_id: null }, + { clan_id: null, clan_joindate: null }, { session }, ); if (upErrors) { diff --git a/src/clan/join/join.service.ts b/src/clan/join/join.service.ts index f78451e4e..0a46869be 100644 --- a/src/clan/join/join.service.ts +++ b/src/clan/join/join.service.ts @@ -28,7 +28,6 @@ import { cancelTransaction, } from '../../common/function/Transactions'; import ClanNotifier from '../clan.notifier'; - @Injectable() export class JoinService { private readonly logger = new Logger(JoinService.name); @@ -178,6 +177,7 @@ export class JoinService { { _id: player_id }, { clan_id: null, + clan_joindate: null, }, { session }, ); @@ -252,6 +252,7 @@ export class JoinService { { _id: player_id }, { clan_id: null, + clan_joindate: null, }, { session }, ); // update clan_id for the requested player; @@ -315,6 +316,7 @@ export class JoinService { { clan_id, clanRole_id: memberRole._id, + clan_joindate: new Date(), }, { session }, ); diff --git a/src/player/dto/player.dto.ts b/src/player/dto/player.dto.ts index b7edf7fa1..8b37aee28 100644 --- a/src/player/dto/player.dto.ts +++ b/src/player/dto/player.dto.ts @@ -125,6 +125,13 @@ export class PlayerDto { @Expose() clan_id: string; + /** + * Date when the player joined their current clan. + * @example "2026-09-01T12:00:00.000Z" + */ + @Expose() + clan_joindate?: Date; + /** * Player's clan object */ diff --git a/src/player/schemas/player.schema.ts b/src/player/schemas/player.schema.ts index da1399bc9..5679410ee 100644 --- a/src/player/schemas/player.schema.ts +++ b/src/player/schemas/player.schema.ts @@ -79,6 +79,9 @@ export class Player { @Prop({ type: MongooseSchema.Types.ObjectId, ref: ModelName.CLAN }) clan_id?: string; + @Prop({ type: Date, default: null }) + clan_joindate?: Date; + @ExtractField() @Prop({ type: [ObjectId], default: [] }) battleCharacter_ids?: string[] | ObjectId[];