diff --git a/core/migrations/1773000000000-DropMledbPlayerAccountTrackerUnique.ts b/core/migrations/1773000000000-DropMledbPlayerAccountTrackerUnique.ts new file mode 100644 index 000000000..b08a8142f --- /dev/null +++ b/core/migrations/1773000000000-DropMledbPlayerAccountTrackerUnique.ts @@ -0,0 +1,22 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +/** + * mledb.player_account had a unique constraint on tracker. Multiple platform rows + * often omit or share tracker values during Sprocket-primary mirroring; uniqueness + * on tracker is not required for identity (platform + platform_id is authoritative). + */ +export class DropMledbPlayerAccountTrackerUnique1773000000000 implements MigrationInterface { + name = "DropMledbPlayerAccountTrackerUnique1773000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'DROP INDEX IF EXISTS "mledb"."player_account_tracker_unique"', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'CREATE UNIQUE INDEX "player_account_tracker_unique" ON "mledb"."player_account" ("tracker")', + ); + } +} diff --git a/core/src/database/mledb/PlayerAccount.model.ts b/core/src/database/mledb/PlayerAccount.model.ts index 6807088b5..def619f7e 100644 --- a/core/src/database/mledb/PlayerAccount.model.ts +++ b/core/src/database/mledb/PlayerAccount.model.ts @@ -7,7 +7,6 @@ import {MLE_Player} from "./Player.model"; @Index("player_account_pkey", ["id"], {unique: true}) @Index("player_account_platform_id_platform_unique", ["platform", "platformId"], {unique: true}) -@Index("player_account_tracker_unique", ["tracker"], {unique: true}) @Entity("player_account", {schema: "mledb"}) export class MLE_PlayerAccount { @PrimaryGeneratedColumn({type: "integer", name: "id"}) diff --git a/core/src/franchise/player/player.service.spec.ts b/core/src/franchise/player/player.service.spec.ts index 5d4926cc3..9d97455bc 100644 --- a/core/src/franchise/player/player.service.spec.ts +++ b/core/src/franchise/player/player.service.spec.ts @@ -23,7 +23,9 @@ import {Organization} from "../../database/organization/organization/organizatio import {EloConnectorService} from "../../elo/elo-connector"; import {PlatformService} from "../../game"; import {OrganizationService} from "../../organization"; +import {MemberPlatformAccountService} from "../../organization/member-platform-account"; import {MemberService} from "../../organization/member/member.service"; +import {MledbPlayerAccountService} from "../../mledb"; import {GameSkillGroupService} from "../game-skill-group"; import {PlayerService} from "./player.service"; import {OperationError} from "./player.types"; @@ -224,6 +226,18 @@ describe("PlayerService", () => { send: jest.fn(), }, }, + { + provide: MemberPlatformAccountService, + useValue: { + upsertMemberPlatformAccount: jest.fn(), + }, + }, + { + provide: MledbPlayerAccountService, + useValue: { + createOrUpdatePlayerAccount: jest.fn(), + }, + }, ], }).compile(); diff --git a/core/src/franchise/player/player.service.ts b/core/src/franchise/player/player.service.ts index 4e3cd821e..20dccbb88 100644 --- a/core/src/franchise/player/player.service.ts +++ b/core/src/franchise/player/player.service.ts @@ -30,10 +30,12 @@ import {UserAuthenticationAccount} from "../../database/identity/user_authentica import {UserAuthenticationAccountType} from "../../database/identity/user_authentication_account/user_authentication_account_type.enum"; import {UserProfile} from "../../database/identity/user_profile/user_profile.model"; import { - League, LeagueOrdinals, ModePreference, Role, Timezone, + League, LeagueOrdinals, ModePreference, MLE_Platform, Role, Timezone, } from "../../database/mledb"; import {MLE_Player} from "../../database/mledb/Player.model"; import {PlayerToPlayer} from "../../database/mledb-bridge/player_to_player.model"; +import {GameSkillGroup} from "../../database/franchise/game_skill_group/game_skill_group.model"; +import {Platform} from "../../database/game/platform/platform.model"; import {Member} from "../../database/organization/member/member.model"; import {MemberProfile} from "../../database/organization/member_profile/member_profile.model"; import {Organization} from "../../database/organization/organization/organization.model"; @@ -45,6 +47,8 @@ import { SkillGroupDelta, } from "../../elo/elo-connector"; import {PlatformService} from "../../game"; +import {MledbPlayerAccountService} from "../../mledb"; +import {MemberPlatformAccountService} from "../../organization/member-platform-account"; import {OrganizationService} from "../../organization"; import {MemberService} from "../../organization/member/member.service"; import {GameSkillGroupService} from "../game-skill-group"; @@ -83,6 +87,10 @@ export class PlayerService { private readonly eloConnectorService: EloConnectorService, private readonly platformService: PlatformService, private readonly analyticsService: AnalyticsService, + @Inject(forwardRef(() => MemberPlatformAccountService)) + private readonly memberPlatformAccountService: MemberPlatformAccountService, + @Inject(forwardRef(() => MledbPlayerAccountService)) + private readonly mledbPlayerAccountService: MledbPlayerAccountService, ) {} async getPlayer(query: FindOneOptions): Promise { @@ -277,6 +285,64 @@ export class PlayerService { ); } + /** + * Persist Rocket League Steam IDs on the Sprocket member; mirror to mledb.player_account when an MLE_Player bridge exists (cutover). + */ + private async linkRocketLeagueSteamIdsForPlayer( + member: Member, + player: Player, + steamIds: string[] | undefined, + runner: QueryRunner, + updatedByUserId: number, + ): Promise { + const uniq = [...new Set((steamIds ?? []).map(id => id.trim()).filter(id => id.length > 0))]; + if (uniq.length === 0) return; + + const skillGroup = await this.skillGroupService.getGameSkillGroup({ + where: {id: player.skillGroupId}, + relations: {game: true}, + }); + if (skillGroup.game.id !== 7) { + this.logger.warn( + `Steam account IDs supplied for non-Rocket-League skill group ${skillGroup.id}; skipping platform link`, + ); + return; + } + + let steamPlatform: Platform; + try { + steamPlatform = await this.platformService.getPlatformByCode(MLE_Platform.STEAM, runner.manager); + } catch { + steamPlatform = await this.platformService.createPlatform(MLE_Platform.STEAM); + } + + const bridge = await runner.manager.findOne(PlayerToPlayer, { + where: {sprocketPlayerId: player.id}, + }); + const mlePlayer = bridge + ? await runner.manager.findOne(MLE_Player, {where: {id: bridge.mledPlayerId} }) + : null; + + for (const steamId of uniq) { + await this.memberPlatformAccountService.upsertMemberPlatformAccount( + member, + steamPlatform.id, + steamId, + runner.manager, + ); + if (mlePlayer) { + await this.mledbPlayerAccountService.createOrUpdatePlayerAccount( + updatedByUserId, + MLE_Platform.STEAM, + steamId, + steamId, + mlePlayer, + runner.manager, + ); + } + } + } + /* !! Using repositories due to circular dependency issues. Will fix after extended repositories are added, probably. !! */ async updatePlayer( mleid: number, @@ -1240,6 +1306,14 @@ export class PlayerService { const player = await this.createPlayer(member.id, pt.gameSkillGroupId, pt.salary, runner); this.logger.log(`Created player: id=${player.id}, skillGroupId=${pt.gameSkillGroupId}, salary=${pt.salary}`); + await this.linkRocketLeagueSteamIdsForPlayer( + member, + player, + pt.accountSteamIds, + runner, + member.userId, + ); + const skillGroup = await this.skillGroupService.getGameSkillGroupById( pt.gameSkillGroupId, {relations: {profile: true} }, diff --git a/core/src/franchise/player/player.types.ts b/core/src/franchise/player/player.types.ts index b023e6c73..f9ec65a5d 100644 --- a/core/src/franchise/player/player.types.ts +++ b/core/src/franchise/player/player.types.ts @@ -19,6 +19,10 @@ export class CreatePlayerTuple { @Field(() => Float) salary: number; + + /** Steam platform IDs for Rocket League; optional. Written to Sprocket and mirrored to mledb.player_account during cutover. */ + @Field(() => [String], {nullable: true}) + accountSteamIds?: string[]; } export const IntakeSchema = z diff --git a/core/src/identity/auth/oauth/strategies/discord.strategy.ts b/core/src/identity/auth/oauth/strategies/discord.strategy.ts index 44194639a..8e32466ec 100644 --- a/core/src/identity/auth/oauth/strategies/discord.strategy.ts +++ b/core/src/identity/auth/oauth/strategies/discord.strategy.ts @@ -59,11 +59,6 @@ export class DiscordStrategy extends PassportStrategy(Strategy, "discord") { profile: Profile, done: Done, ): Promise { - const mledbPlayer = await this.mledbPlayerService - .getPlayerByDiscordId(profile.id) - .catch(() => null); - if (!mledbPlayer) throw new Error("User is not associated with MLE"); - const userByDiscordId = await this.identityService .getUserByAuthAccount(UserAuthenticationAccountType.DISCORD, profile.id) .catch(() => undefined); @@ -75,6 +70,12 @@ export class DiscordStrategy extends PassportStrategy(Strategy, "discord") { // TODO: Do we want to actually do this? Theoretically, if a user changes their email, that's a "new user" if we go by email. Hence ^ if (!user) user = await this.userService.getUser({where: {email: profile.email} }); + // New-account import still keys off legacy MLEDB until full cutover; returning users authenticate via Sprocket alone. + let mledbPlayer = await this.mledbPlayerService + .getPlayerByDiscordId(profile.id) + .catch(() => null); + if (!user && !mledbPlayer) throw new Error("User is not associated with MLE"); + // If no users returned from query, create a new one if (!user) { const userProfile: Omit = { @@ -113,42 +114,38 @@ export class DiscordStrategy extends PassportStrategy(Strategy, "discord") { await this.userService.addAuthenticationAccounts(user.id, [authAcct]); } + if (!mledbPlayer) { + mledbPlayer = await this.mledbPlayerService + .getPlayerByDiscordId(profile.id) + .catch(() => null); + } + let member = await this.memberService .getMember({where: {user: {id: user.id} } }) .catch(() => null); if (!member) { + const displayName = mledbPlayer?.name ?? profile.username; member = await this.memberService.createMember( - {name: mledbPlayer.name}, + {name: displayName}, MLE_ORGANIZATION_ID, user.id, ); } - const mledbPlayerAccounts = await this.mledbPlayerAccountService.getPlayerAccounts({ - where: {player: {id: mledbPlayer.id} }, - }); + if (mledbPlayer) { + const mledbPlayerAccounts = await this.mledbPlayerAccountService.getPlayerAccounts({ + where: {player: {id: mledbPlayer.id} }, + }); - for (const mledbPlayerAccount of mledbPlayerAccounts) { - if (!mledbPlayerAccount.platformId) continue; + for (const mledbPlayerAccount of mledbPlayerAccounts) { + if (!mledbPlayerAccount.platformId) continue; - const platformAccount = await this.memberPlatformAccountService - .getMemberPlatformAccount({ - where: { - member: {id: member.id}, - platform: {code: mledbPlayerAccount.platform}, - platformAccountId: mledbPlayerAccount.platformId, - }, - relations: ["member", "platform"], - }) - .catch(() => null); - - if (!platformAccount) { const platform = await this.platformService .getPlatformByCode(mledbPlayerAccount.platform) .catch(async () => this.platformService.createPlatform(mledbPlayerAccount.platform)); - await this.memberPlatformAccountService.createMemberPlatformAccount( + await this.memberPlatformAccountService.upsertMemberPlatformAccount( member, platform.id, mledbPlayerAccount.platformId, @@ -156,20 +153,22 @@ export class DiscordStrategy extends PassportStrategy(Strategy, "discord") { } } - if (!["PREMIER", "MASTER", "CHAMPION", "ACADEMY", "FOUNDATION"].includes(mledbPlayer.league)) throw new Error("Player does not belong to a league"); + if (mledbPlayer) { + if (!["PREMIER", "MASTER", "CHAMPION", "ACADEMY", "FOUNDATION"].includes(mledbPlayer.league)) throw new Error("Player does not belong to a league"); - const skillGroup = await this.skillGroupService.getGameSkillGroup({ - where: { - profile: { - code: `${mledbPlayer.league[0]}L`, + const skillGroup = await this.skillGroupService.getGameSkillGroup({ + where: { + profile: { + code: `${mledbPlayer.league[0]}L`, + }, }, - }, - relations: ["profile"], - }); - const player = await this.playerService - .getPlayer({where: {member: {id: member.id} } }) - .catch(() => null); - if (!player) await this.playerService.createPlayer(member, skillGroup.id, mledbPlayer.salary); + relations: ["profile"], + }); + const player = await this.playerService + .getPlayer({where: {member: {id: member.id} } }) + .catch(() => null); + if (!player) await this.playerService.createPlayer(member, skillGroup.id, mledbPlayer.salary); + } done("", user); return user; diff --git a/core/src/mledb/mledb-player-account/mledb-player-account.service.ts b/core/src/mledb/mledb-player-account/mledb-player-account.service.ts index 484276897..a4441d12f 100644 --- a/core/src/mledb/mledb-player-account/mledb-player-account.service.ts +++ b/core/src/mledb/mledb-player-account/mledb-player-account.service.ts @@ -6,6 +6,12 @@ import {EntityManager, Repository} from "typeorm"; import type {MLE_Platform, MLE_Player} from "../../database/mledb"; import {MLE_PlayerAccount} from "../../database/mledb"; +/** + * Legacy mirror of `sprocket.member_platform_account` into `mledb.player_account` for cutover. + * Fields written: platform, platform_id, tracker (often same as platform_id for Steam), updated_by, player_id. + * Removal plan: stop calling `createOrUpdatePlayerAccount` from write paths; then drop this service and + * `mledb.player_account` writes once reporting and imports read only from Sprocket. + */ @Injectable() export class MledbPlayerAccountService { constructor(@InjectRepository(MLE_PlayerAccount) diff --git a/core/src/mledb/mledb-player/mledb-player.service.ts b/core/src/mledb/mledb-player/mledb-player.service.ts index 48656b6b1..6970588c1 100644 --- a/core/src/mledb/mledb-player/mledb-player.service.ts +++ b/core/src/mledb/mledb-player/mledb-player.service.ts @@ -8,6 +8,7 @@ import {Repository} from "typeorm"; import type {Player} from "$db/franchise/player/player.model"; import type {User} from "$db/identity/user/user.model"; import {UserAuthenticationAccountType} from "$db/identity/user_authentication_account/user_authentication_account_type.enum"; +import {MemberPlatformAccount} from "$db/organization/member_platform_account/member_platform_account.model"; import type {MLE_Platform} from "../../database/mledb"; import { @@ -17,8 +18,9 @@ import { MLE_Team, MLE_TeamToCaptain, } from "../../database/mledb"; -import {GameService} from "../../game"; +import {GameService, PlatformService} from "../../game"; import {UserService} from "../../identity"; +import {MemberPlatformAccountService} from "../../organization/member-platform-account"; @Injectable() export class MledbPlayerService { @@ -33,10 +35,86 @@ export class MledbPlayerService { @InjectRepository(MLE_Team) private readonly teamRepo: Repository, @InjectRepository(MLE_TeamToCaptain) private readonly teamToCaptainRepo: Repository, + @InjectRepository(MemberPlatformAccount) + private readonly memberPlatformAccountRepository: Repository, @Inject(forwardRef(() => UserService)) private readonly userService: UserService, @Inject(forwardRef(() => GameService)) private readonly gameService: GameService, + @Inject(forwardRef(() => PlatformService)) private readonly platformService: PlatformService, + @Inject(forwardRef(() => MemberPlatformAccountService)) + private readonly memberPlatformAccountService: MemberPlatformAccountService, ) {} + /** + * Resolve the Sprocket user id for a platform account. Prefer sprocket.member_platform_account + * (source of truth); fall back to mledb.player_account + Discord crosswalk during cutover. + */ + private async resolveSprocketUserIdForPlatformAccount( + platform: MLE_Platform, + platformId: string, + ): Promise { + const mpa = await this.memberPlatformAccountRepository.findOne({ + where: { + platformAccountId: platformId, + platform: {code: platform}, + }, + relations: { + member: {user: true}, + platform: true, + }, + }); + if (mpa?.member?.user?.id) { + return mpa.member.user.id; + } + + const playerAccount = await this.playerAccountRepository.findOne({ + where: {platform, platformId}, + relations: {player: true}, + }); + if (!playerAccount?.player?.discordId) { + throw new Error(`No Sprocket or legacy MLEDB link for platform account (${platform} | ${platformId})`); + } + + const user = await this.userService.getUser({ + where: { + user: { + authenticationAccounts: { + accountId: playerAccount.player.discordId, + accountType: UserAuthenticationAccountType.DISCORD, + }, + }, + }, + relations: {user: true}, + }); + if (!user) { + throw new Error(`No sprocket user found (${platform} | ${platformId})`); + } + + const mleMember = await this.userService.getUserById(user.id, { + relations: {members: {organization: true} }, + }); + const defaultOrgMember = mleMember.members?.find( + m => m.organizationId === config.defaultOrganizationId, + ); + if (defaultOrgMember) { + try { + const plat = await this.platformService.getPlatformByCode(platform); + await this.memberPlatformAccountService.upsertMemberPlatformAccount( + defaultOrgMember, + plat.id, + platformId, + ); + } catch (e) { + this.logger.warn( + `Backfill member_platform_account failed (${platform}|${platformId}): ${ + e instanceof Error ? e.message : String(e) + }`, + ); + } + } + + return user.id; + } + async getPlayerByDiscordId(id: string): Promise { const players = await this.playerRepository.find({where: {discordId: id} }); return players[0]; @@ -51,11 +129,19 @@ export class MledbPlayerService { } async getPlayerByPlatformId(platform: MLE_Platform, platformId: string): Promise { - const playerAccount = await this.playerAccountRepository.findOneOrFail({ - where: {platform, platformId}, - relations: {player: true}, - }); - return playerAccount.player; + try { + const userId = await this.resolveSprocketUserIdForPlatformAccount(platform, platformId); + return await this.getMlePlayerBySprocketUser(userId); + } catch { + const playerAccount = await this.playerAccountRepository.findOne({ + where: {platform, platformId}, + relations: {player: true}, + }); + if (!playerAccount?.player) { + throw new Error(`No player found for platform account (${platform} | ${platformId})`); + } + return playerAccount.player; + } } async getMlePlayerBySprocketUser(userId: number): Promise { @@ -114,86 +200,39 @@ export class MledbPlayerService { platform: MLE_Platform, platformId: string, ): Promise { - const playerAccount = await this.playerAccountRepository.findOneOrFail({ - where: {platform, platformId}, - relations: {player: true}, - }); - const {discordId} = playerAccount.player; - - if (!discordId) { - throw new Error(`No discord account found for player ${playerAccount.player.name}`); - } - - const user = await this.userService.getUser({ - where: { - user: { - authenticationAccounts: { - accountId: discordId, - accountType: UserAuthenticationAccountType.DISCORD, - }, - }, - }, + const userId = await this.resolveSprocketUserIdForPlatformAccount(platform, platformId); + return this.userService.getUserById(userId, { relations: { - user: { - authenticationAccounts: true, - members: { - players: { - skillGroup: { - game: true, - }, + authenticationAccounts: true, + members: { + players: { + skillGroup: { + game: true, }, }, }, }, }); - if (!user) { - throw new Error(`No sprocket user found (${platform} | ${platformId})`); - } - - return user; } async getSprocketPlayerByPlatformInformation( platform: MLE_Platform, platformId: string, ): Promise { - const playerAccount = await this.playerAccountRepository.findOneOrFail({ - where: {platform, platformId}, - relations: {player: true}, - }); - const {discordId} = playerAccount.player; - - if (!discordId) { - throw new Error(`No discord account found for player ${playerAccount.player.name}`); - } - - const user = await this.userService.getUser({ - where: { - user: { - authenticationAccounts: { - accountId: discordId, - accountType: UserAuthenticationAccountType.DISCORD, - }, - }, - }, + const userId = await this.resolveSprocketUserIdForPlatformAccount(platform, platformId); + const user = await this.userService.getUserById(userId, { relations: { - user: { - authenticationAccounts: true, - members: { - players: { - skillGroup: { - game: true, - }, + authenticationAccounts: true, + members: { + players: { + skillGroup: { + game: true, }, }, }, }, }); - if (!user) { - throw new Error(`No sprocket user found (${platform} | ${platformId})`); - } - const member = user.members.find(m => m.organizationId === config.defaultOrganizationId); if (!member) { throw new Error(`Member not found in MLE for user ${user.id}`); diff --git a/core/src/organization/member-platform-account/member-platform-account.service.ts b/core/src/organization/member-platform-account/member-platform-account.service.ts index 2a3a7ebfc..620edc442 100644 --- a/core/src/organization/member-platform-account/member-platform-account.service.ts +++ b/core/src/organization/member-platform-account/member-platform-account.service.ts @@ -46,4 +46,28 @@ export class MemberPlatformAccountService { return memberPlatformAccount; } + + /** + * Idempotent link: same member + platform + platform account id is a no-op. + */ + async upsertMemberPlatformAccount( + member: Member, + platformId: number, + platformAccountId: string, + manager?: EntityManager, + ): Promise { + const repo = manager ? manager.getRepository(MemberPlatformAccount) : this.memberPlatformAccountRepository; + const existing = await repo.findOne({ + where: { + member: {id: member.id}, + platform: {id: platformId}, + platformAccountId, + }, + relations: {member: true, platform: true}, + }); + if (existing) { + return existing; + } + return this.createMemberPlatformAccount(member, platformId, platformAccountId, manager); + } } diff --git a/core/src/organization/member/member.mod.resolver.ts b/core/src/organization/member/member.mod.resolver.ts index 8e404174d..4c9c3c2ce 100644 --- a/core/src/organization/member/member.mod.resolver.ts +++ b/core/src/organization/member/member.mod.resolver.ts @@ -65,7 +65,7 @@ export class MemberModResolver { manager, ); - // as well as the user's MLE_Player in the MLEDB schema + // Temporary legacy mirror for mledb.player_account (cutover); Sprocket row above is authoritative. const mle_player: MLE_Player = await this.mledbPlayerService.getMlePlayerBySprocketUser(userId); await this.mledbPlayerAccountService.createOrUpdatePlayerAccount( cu.userId, diff --git a/core/src/organization/member/member.service.ts b/core/src/organization/member/member.service.ts index 5f7dc5e38..5bc3e7d5b 100644 --- a/core/src/organization/member/member.service.ts +++ b/core/src/organization/member/member.service.ts @@ -10,12 +10,13 @@ import {DataSource, Repository} from "typeorm"; import type {IrrelevantFields} from "../../database"; import type {Franchise} from "../../database/franchise/franchise/franchise.model"; import {UserAuthenticationAccount} from "../../database/identity/user_authentication_account"; -import {MLE_Player, MLE_PlayerAccount} from "../../database/mledb"; +import {MLE_Platform, MLE_Player, MLE_PlayerAccount} from "../../database/mledb"; import {Member} from "../../database/organization/member/member.model"; import {MemberPlatformAccount} from "../../database/organization/member_platform_account"; import {MemberProfile} from "../../database/organization/member_profile/member_profile.model"; import {PlayerService} from "../../franchise/player/player.service"; import {UserService} from "../../identity/user/user.service"; +import {MledbPlayerAccountService} from "../../mledb"; import {MemberPubSub} from "../constants"; import {OrganizationService} from "../organization"; @@ -192,6 +193,8 @@ export class MemberFixService { @InjectRepository(MLE_Player) private playerRepository: Repository, @InjectRepository(MLE_PlayerAccount) private playerAccountRepository: Repository, + @Inject(forwardRef(() => MledbPlayerAccountService)) + private readonly mledbPlayerAccountService: MledbPlayerAccountService, ) {} async updateMemberAndPlayerIds(sprocketUserId: number, platformId: string) { @@ -241,6 +244,24 @@ export class MemberFixService { {player: {id: mlePlayerId} }, ); + const mpa = await manager.findOne(MemberPlatformAccount, { + where: { + platformAccountId: platformId, + member: {id: memberId}, + }, + relations: {platform: true}, + }); + if (mpa?.platform?.code && MLE_Platform[mpa.platform.code as keyof typeof MLE_Platform]) { + await this.mledbPlayerAccountService.createOrUpdatePlayerAccount( + sprocketUserId, + MLE_Platform[mpa.platform.code as keyof typeof MLE_Platform], + platformId, + platformId, + player, + manager, + ); + } + return { success: true, updatedMemberId: memberId,