From d566a5113ddbb05359fd605d6cf6d6d819575976 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 17:06:48 +0000 Subject: [PATCH] feat(core): sync MLE roster/staff into Sprocket for franchise reads (728) - Add RosterAuthorityService to project mledb.player/team/captain into roster_slot and franchise staff/leadership appointments - Hook PlayerService MLE saves and post-commit intake to run sync - Prefer Sprocket data in FranchiseService.getPlayerFranchisesByUserId - Document source of truth and legacy mirror removal checklist Co-authored-by: Jake Bailey --- core/src/franchise/franchise.module.ts | 41 +- .../franchise/franchise/franchise.service.ts | 7 + core/src/franchise/player/player.service.ts | 43 +- .../src/franchise/roster-authority.service.ts | 491 ++++++++++++++++++ .../issue-728-sprocket-roster-authority.md | 28 + 5 files changed, 607 insertions(+), 3 deletions(-) create mode 100644 core/src/franchise/roster-authority.service.ts create mode 100644 reports/issue-728-sprocket-roster-authority.md diff --git a/core/src/franchise/franchise.module.ts b/core/src/franchise/franchise.module.ts index 14d2316c6..b05ceebdc 100644 --- a/core/src/franchise/franchise.module.ts +++ b/core/src/franchise/franchise.module.ts @@ -1,12 +1,29 @@ import {forwardRef, Module} from "@nestjs/common"; import {JwtModule} from "@nestjs/jwt"; +import {TypeOrmModule} from "@nestjs/typeorm"; import { AnalyticsModule, config, EventsModule, NotificationModule, } from "@sprocketbot/common"; +import {FranchiseLeadershipRole} from "../database/authorization/franchise_leadership_role"; +import {FranchiseLeadershipSeat} from "../database/authorization/franchise_leadership_seat"; +import {FranchiseStaffRole} from "../database/authorization/franchise_staff_role"; +import {FranchiseStaffSeat} from "../database/authorization/franchise_staff_seat"; +import {FranchiseLeadershipAppointment} from "../database/franchise/franchise_leadership_appointment"; +import {FranchiseStaffAppointment} from "../database/franchise/franchise_staff_appointment"; +import {Player} from "../database/franchise/player/player.model"; +import {RosterRole} from "../database/franchise/roster_role"; +import {RosterSlot} from "../database/franchise/roster_slot"; +import {Team} from "../database/franchise/team/team.model"; import {DatabaseModule} from "../database"; import {EloConnectorModule} from "../elo/elo-connector"; import {GameModule} from "../game"; +import {MLE_Player} from "../database/mledb/Player.model"; +import {MLE_Team} from "../database/mledb/Team.model"; +import {MLE_TeamToCaptain} from "../database/mledb/TeamToCaptain.model"; +import {LeagueToSkillGroup} from "../database/mledb-bridge/league_to_skill_group.model"; +import {PlayerToPlayer} from "../database/mledb-bridge/player_to_player.model"; +import {TeamToFranchise} from "../database/mledb-bridge/team_to_franchise.model"; import {MledbInterfaceModule} from "../mledb"; import {OrganizationModule} from "../organization/organization.module"; import {UtilModule} from "../util/util.module"; @@ -22,11 +39,32 @@ import { import {PlayerService} from "./player"; import {PlayerController} from "./player/player.controller"; import {PlayerResolver} from "./player/player.resolver"; +import {RosterAuthorityService} from "./roster-authority.service"; import {TeamService} from "./team/team.service"; +const rosterAuthorityOrm = TypeOrmModule.forFeature([ + MLE_Player, + MLE_Team, + MLE_TeamToCaptain, + TeamToFranchise, + LeagueToSkillGroup, + PlayerToPlayer, + Player, + RosterRole, + RosterSlot, + Team, + FranchiseStaffAppointment, + FranchiseLeadershipAppointment, + FranchiseStaffRole, + FranchiseStaffSeat, + FranchiseLeadershipRole, + FranchiseLeadershipSeat, +]); + @Module({ imports: [ DatabaseModule, + rosterAuthorityOrm, UtilModule, NotificationModule, EventsModule, @@ -41,6 +79,7 @@ import {TeamService} from "./team/team.service"; ], providers: [ PlayerService, + RosterAuthorityService, GameSkillGroupService, GameSkillGroupResolver, FranchiseService, @@ -49,7 +88,7 @@ import {TeamService} from "./team/team.service"; PlayerResolver, TeamService, ], - exports: [PlayerService, FranchiseService, GameSkillGroupService, TeamService], + exports: [PlayerService, FranchiseService, GameSkillGroupService, TeamService, RosterAuthorityService], controllers: [FranchiseController, GameSkillGroupController, PlayerController], }) export class FranchiseModule {} diff --git a/core/src/franchise/franchise/franchise.service.ts b/core/src/franchise/franchise/franchise.service.ts index 34c37853d..c4bc24e0e 100644 --- a/core/src/franchise/franchise/franchise.service.ts +++ b/core/src/franchise/franchise/franchise.service.ts @@ -12,6 +12,7 @@ import {FranchiseProfile} from "$db/franchise/franchise_profile/franchise_profil import {MledbPlayerService} from "../../mledb"; import {MemberService} from "../../organization"; import {PlayerService} from "../player"; +import {RosterAuthorityService} from "../roster-authority.service"; @Injectable() export class FranchiseService { @@ -23,6 +24,7 @@ export class FranchiseService { private readonly sprocketMemberService: MemberService, @InjectRepository(FranchiseProfile) private readonly franchiseProfileRepository: Repository, + private readonly rosterAuthorityService: RosterAuthorityService, ) {} async getFranchiseProfile(franchiseId: number): Promise { @@ -62,6 +64,11 @@ export class FranchiseService { } async getPlayerFranchisesByUserId(userId: number): Promise> { + const fromSprocket = await this.rosterAuthorityService.getPlayerFranchisesFromSprocket(userId); + if (fromSprocket.length > 0) { + return fromSprocket; + } + const mlePlayer = await this.mledbPlayerService.getMlePlayerBySprocketUser(userId); const playerId = mlePlayer.id; diff --git a/core/src/franchise/player/player.service.ts b/core/src/franchise/player/player.service.ts index 4e3cd821e..08ddfd873 100644 --- a/core/src/franchise/player/player.service.ts +++ b/core/src/franchise/player/player.service.ts @@ -48,6 +48,7 @@ import {PlatformService} from "../../game"; import {OrganizationService} from "../../organization"; import {MemberService} from "../../organization/member/member.service"; import {GameSkillGroupService} from "../game-skill-group"; +import {RosterAuthorityService} from "../roster-authority.service"; import type {RankdownJwtPayload} from "./player.types"; import type {CreatePlayerTuple} from "./player.types"; @@ -83,8 +84,19 @@ export class PlayerService { private readonly eloConnectorService: EloConnectorService, private readonly platformService: PlatformService, private readonly analyticsService: AnalyticsService, + private readonly rosterAuthorityService: RosterAuthorityService, ) {} + private async syncRosterAuthorityAfterMleSave(mlePlayerId: number): Promise { + try { + await this.rosterAuthorityService.syncFromMlePlayerId(mlePlayerId); + } catch (err) { + this.logger.warn( + `Roster authority sync failed for MLE player ${mlePlayerId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + async getPlayer(query: FindOneOptions): Promise { this.logger.debug(`getPlayer: ${JSON.stringify(query)}`); return this.playerRepository.findOneOrFail(query); @@ -339,6 +351,11 @@ export class PlayerService { throw new Error(`Tried updating player with MLEID: ${mleid}, but that MLEID does not yet exist.`); } await runner.commitTransaction(); + + const mleAfter = await this.mle_playerRepository.findOne({where: {mleid} }); + if (mleAfter) { + await this.syncRosterAuthorityAfterMleSave(mleAfter.id); + } } catch (e) { await runner.rollbackTransaction(); this.logger.error(e); @@ -375,9 +392,10 @@ export class PlayerService { }); if (runner) { - await runner.manager.save(player); + await runner.manager.save(MLE_Player, updatedPlayer); } else { - await this.mle_playerRepository.save(player); + await this.mle_playerRepository.save(updatedPlayer); + await this.syncRosterAuthorityAfterMleSave(updatedPlayer.id); } return updatedPlayer; @@ -434,6 +452,7 @@ export class PlayerService { await runner.manager.save(ptpBridge); } else { await this.ptpRepo.save(ptpBridge); + await this.syncRosterAuthorityAfterMleSave(player.id); } return player; @@ -788,6 +807,7 @@ export class PlayerService { }); await this.mle_playerRepository.save(newMlePlayer); + await this.syncRosterAuthorityAfterMleSave(newMlePlayer.id); this.logger.debug(`Player ${playerDelta.playerId}: Salary update complete`); } @@ -842,6 +862,7 @@ export class PlayerService { const mlePlayer = await this.getMlePlayerBySprocketPlayer(sprocPlayerId); mlePlayer.salary = salary; await this.mle_playerRepository.save(mlePlayer); + await this.syncRosterAuthorityAfterMleSave(mlePlayer.id); return mlePlayer; } @@ -872,6 +893,7 @@ export class PlayerService { } await this.mle_playerRepository.save(player); + await this.syncRosterAuthorityAfterMleSave(player.id); return player; } @@ -907,6 +929,7 @@ export class PlayerService { }); await this.mle_playerRepository.save(player); + await this.syncRosterAuthorityAfterMleSave(player.id); // Move player to Waiver Wire // TODO fix later when we abstract away from MLE @@ -954,6 +977,7 @@ export class PlayerService { }); await this.mle_playerRepository.save(player); + await this.syncRosterAuthorityAfterMleSave(player.id); return player; } @@ -1268,6 +1292,19 @@ export class PlayerService { await runner.commitTransaction(); this.logger.log(`Transaction committed successfully`); + const rlPlayers = await this.playerRepository.find({ + where: { + member: {user: {id: user.id} }, + skillGroup: {game: {id: 7} }, + }, + }); + for (const pl of rlPlayers) { + const bridge = await this.ptpRepo.findOne({where: {sprocketPlayerId: pl.id} }); + if (bridge) { + await this.syncRosterAuthorityAfterMleSave(bridge.mledPlayerId); + } + } + const result = `Successfully created/updated user with ID ${user.id}.`; this.logger.log(`=== INTAKE USER COMPLETED ===`); this.logger.log(`Result: ${result}`); @@ -1332,6 +1369,7 @@ export class PlayerService { }); mlePlayer.discordId = newAcct; await this.mle_playerRepository.save(mlePlayer); + await this.syncRosterAuthorityAfterMleSave(mlePlayer.id); // Then, follow up with Sprocket. const uaa = await this.userAuthRepository.findOneOrFail({ @@ -1351,6 +1389,7 @@ export class PlayerService { }); mlePlayer.teamName = newTeam; await this.mle_playerRepository.save(mlePlayer); + await this.syncRosterAuthorityAfterMleSave(mlePlayer.id); } async changePlayerName(mleid: number, newName: string): Promise { diff --git a/core/src/franchise/roster-authority.service.ts b/core/src/franchise/roster-authority.service.ts new file mode 100644 index 000000000..d4f433962 --- /dev/null +++ b/core/src/franchise/roster-authority.service.ts @@ -0,0 +1,491 @@ +import {Injectable, Logger} from "@nestjs/common"; +import {InjectRepository} from "@nestjs/typeorm"; +import {Repository} from "typeorm"; + +import {FranchiseLeadershipAppointment} from "$db/franchise/franchise_leadership_appointment/franchise_leadership_appointment.model"; +import {FranchiseStaffAppointment} from "$db/franchise/franchise_staff_appointment/franchise_staff_appointment.model"; +import {Player} from "$db/franchise/player/player.model"; +import {RosterRole} from "$db/franchise/roster_role/roster_role.model"; +import {RosterSlot} from "$db/franchise/roster_slot/roster_slot.model"; +import {Team} from "$db/franchise/team/team.model"; +import {FranchiseLeadershipRole} from "$db/authorization/franchise_leadership_role/franchise_leadership_role.model"; +import {FranchiseLeadershipSeat} from "$db/authorization/franchise_leadership_seat/franchise_leadership_seat.model"; +import {FranchiseStaffRole} from "$db/authorization/franchise_staff_role/franchise_staff_role.model"; +import {FranchiseStaffSeat} from "$db/authorization/franchise_staff_seat/franchise_staff_seat.model"; +import {UserAuthenticationAccount} from "$db/identity/user_authentication_account/user_authentication_account.model"; +import {UserAuthenticationAccountType} from "$db/identity/user_authentication_account/user_authentication_account_type.enum"; + +import {MLE_Player} from "../database/mledb/Player.model"; +import {MLE_Team} from "../database/mledb/Team.model"; +import {MLE_TeamToCaptain} from "../database/mledb/TeamToCaptain.model"; +import {LeagueToSkillGroup} from "../database/mledb-bridge/league_to_skill_group.model"; +import {PlayerToPlayer} from "../database/mledb-bridge/player_to_player.model"; +import {TeamToFranchise} from "../database/mledb-bridge/team_to_franchise.model"; + +/** MLE `team.name` values that mean the player is not on a franchise roster slot in Sprocket. */ +const NON_FRANCHISE_ROSTER_TEAM_NAMES = new Set([ + "FP", + "FA", + "Pend", + "Waivers", + "RFA", +]); + +const ROCKET_LEAGUE_GAME_ID = 7; + +const STAFF_ROLE_GM = "General Manager"; +const STAFF_ROLE_AGM = "Assistant General Manager"; +const STAFF_ROLE_CAPTAIN = "Captain"; +const STAFF_ROLE_PR = "PR Support"; +const LEADERSHIP_ROLE_FM = "Franchise Manager"; + +export type PlayerFranchiseRow = { + id: number; + name: string; + staffPositions: Array<{id: number; name: string;}>; +}; + +@Injectable() +export class RosterAuthorityService { + private readonly logger = new Logger(RosterAuthorityService.name); + + constructor( + @InjectRepository(Player) + private readonly playerRepository: Repository, + @InjectRepository(MLE_Player) + private readonly mlePlayerRepository: Repository, + @InjectRepository(MLE_Team) + private readonly mleTeamRepository: Repository, + @InjectRepository(MLE_TeamToCaptain) + private readonly mleTeamToCaptainRepository: Repository, + @InjectRepository(TeamToFranchise) + private readonly teamToFranchiseRepository: Repository, + @InjectRepository(LeagueToSkillGroup) + private readonly leagueToSkillGroupRepository: Repository, + @InjectRepository(PlayerToPlayer) + private readonly playerToPlayerRepository: Repository, + @InjectRepository(RosterSlot) + private readonly rosterSlotRepository: Repository, + @InjectRepository(RosterRole) + private readonly rosterRoleRepository: Repository, + @InjectRepository(Team) + private readonly teamRepository: Repository, + @InjectRepository(FranchiseStaffAppointment) + private readonly franchiseStaffAppointmentRepository: Repository, + @InjectRepository(FranchiseLeadershipAppointment) + private readonly franchiseLeadershipAppointmentRepository: Repository, + @InjectRepository(FranchiseStaffRole) + private readonly franchiseStaffRoleRepository: Repository, + @InjectRepository(FranchiseStaffSeat) + private readonly franchiseStaffSeatRepository: Repository, + @InjectRepository(FranchiseLeadershipRole) + private readonly franchiseLeadershipRoleRepository: Repository, + @InjectRepository(FranchiseLeadershipSeat) + private readonly franchiseLeadershipSeatRepository: Repository, + ) {} + + /** + * Project MLE roster / staff / captain state into Sprocket after an MLE player row changes. + * MLE writes remain a temporary legacy path; Sprocket tables are the read model for franchise APIs. + */ + async syncFromMlePlayerId(mledPlayerId: number): Promise { + const mlePlayer = await this.mlePlayerRepository.findOne({where: {id: mledPlayerId} }); + if (!mlePlayer) { + this.logger.warn(`syncFromMlePlayerId: no MLE player ${mledPlayerId}`); + return; + } + + const bridge = await this.playerToPlayerRepository.findOne({where: {mledPlayerId} }); + if (!bridge) { + this.logger.debug(`syncFromMlePlayerId: no player_to_player bridge for MLE id ${mledPlayerId}`); + return; + } + + const fullPlayer = await this.playerRepository.findOne({ + where: {id: bridge.sprocketPlayerId}, + relations: { + member: true, + skillGroup: { + game: true, + organization: true, + }, + slot: { + role: true, + team: true, + }, + }, + }); + if (!fullPlayer) { + this.logger.warn(`syncFromMlePlayerId: no Sprocket player ${bridge.sprocketPlayerId}`); + return; + } + + if (fullPlayer.skillGroup.game.id !== ROCKET_LEAGUE_GAME_ID) { + return; + } + + await this.syncRosterSlotFromMle(fullPlayer, mlePlayer); + await this.syncStaffAndCaptainFromMle(fullPlayer.member.id, mlePlayer); + } + + private async syncRosterSlotFromMle(sprocketPlayer: Player, mlePlayer: MLE_Player): Promise { + const teamName = mlePlayer.teamName?.trim() ?? ""; + if (!teamName || NON_FRANCHISE_ROSTER_TEAM_NAMES.has(teamName)) { + await this.clearPlayerRosterSlot(sprocketPlayer.id); + return; + } + + const tf = await this.teamToFranchiseRepository.findOne({where: {team: teamName} }); + if (!tf) { + this.logger.warn( + `syncRosterSlotFromMle: no team_to_franchise for MLE team "${teamName}"; clearing roster slot for player ${sprocketPlayer.id}`, + ); + await this.clearPlayerRosterSlot(sprocketPlayer.id); + return; + } + + const leagueRow = await this.leagueToSkillGroupRepository.findOne({where: {league: mlePlayer.league} }); + if (!leagueRow) { + this.logger.warn( + `syncRosterSlotFromMle: no league_to_skill_group for league ${mlePlayer.league}; skipping slot for player ${sprocketPlayer.id}`, + ); + return; + } + + const orgId = sprocketPlayer.skillGroup.organizationId; + const franchiseTeam = await this.teamRepository.findOne({ + where: { + franchise: {id: tf.franchiseId}, + skillGroup: {id: leagueRow.skillGroupId, organization: {id: orgId} }, + }, + }); + if (!franchiseTeam) { + this.logger.warn( + `syncRosterSlotFromMle: no sprocket.team for franchise ${tf.franchiseId}, skillGroup ${leagueRow.skillGroupId}, org ${orgId}`, + ); + return; + } + + const roleCode = mlePlayer.role?.trim(); + if (!roleCode || roleCode === "NONE") { + await this.clearPlayerRosterSlot(sprocketPlayer.id); + return; + } + + const rosterRole = await this.rosterRoleRepository.findOne({ + where: { + code: roleCode, + skillGroup: {id: leagueRow.skillGroupId}, + organization: {id: orgId}, + }, + }); + if (!rosterRole) { + this.logger.warn( + `syncRosterSlotFromMle: no roster_role code=${roleCode} skillGroup=${leagueRow.skillGroupId} org=${orgId}`, + ); + return; + } + + const targetSlot = await this.rosterSlotRepository.findOne({ + where: { + team: {id: franchiseTeam.id}, + role: {id: rosterRole.id}, + }, + relations: {player: true}, + }); + if (!targetSlot) { + this.logger.warn( + `syncRosterSlotFromMle: no roster_slot for team ${franchiseTeam.id} role ${rosterRole.code}`, + ); + return; + } + + if (targetSlot.player && targetSlot.player.id !== sprocketPlayer.id) { + this.logger.warn( + `syncRosterSlotFromMle: slot team ${franchiseTeam.id} role ${rosterRole.code} already held by player ${targetSlot.player.id}`, + ); + return; + } + + await this.detachPlayerFromOtherSlots(sprocketPlayer.id, targetSlot.id); + + if (!targetSlot.player || targetSlot.player.id !== sprocketPlayer.id) { + await this.rosterSlotRepository.update( + {id: targetSlot.id}, + {player: {id: sprocketPlayer.id} as Player}, + ); + } + } + + private async detachPlayerFromOtherSlots(sprocketPlayerId: number, keepSlotId: number): Promise { + const occupied = await this.rosterSlotRepository.find({ + where: {player: {id: sprocketPlayerId} }, + }); + for (const slot of occupied) { + if (slot.id === keepSlotId) continue; + await this.rosterSlotRepository.update({id: slot.id}, {player: null}); + } + } + + private async clearPlayerRosterSlot(sprocketPlayerId: number): Promise { + const occupied = await this.rosterSlotRepository.find({ + where: {player: {id: sprocketPlayerId} }, + }); + for (const slot of occupied) { + await this.rosterSlotRepository.update({id: slot.id}, {player: null}); + } + } + + private async syncStaffAndCaptainFromMle(memberId: number, mlePlayer: MLE_Player): Promise { + await this.clearFranchiseStaffForMember(memberId); + + const staffTeams = await this.mleTeamRepository.find({ + where: [ + {franchiseManagerId: mlePlayer.id}, + {generalManagerId: mlePlayer.id}, + {doublesAssistantGeneralManagerId: mlePlayer.id}, + {standardAssistantGeneralManagerId: mlePlayer.id}, + {prSupportId: mlePlayer.id}, + ], + }); + + const fmRole = await this.franchiseLeadershipRoleRepository.findOne({where: {name: LEADERSHIP_ROLE_FM} }); + const gmStaffRole = await this.franchiseStaffRoleRepository.findOne({ + where: {name: STAFF_ROLE_GM, game: {id: ROCKET_LEAGUE_GAME_ID} }, + }); + const agmStaffRole = await this.franchiseStaffRoleRepository.findOne({ + where: {name: STAFF_ROLE_AGM, game: {id: ROCKET_LEAGUE_GAME_ID} }, + }); + const captainStaffRole = await this.franchiseStaffRoleRepository.findOne({ + where: {name: STAFF_ROLE_CAPTAIN, game: {id: ROCKET_LEAGUE_GAME_ID} }, + }); + const prStaffRole = await this.franchiseStaffRoleRepository.findOne({ + where: {name: STAFF_ROLE_PR, game: {id: ROCKET_LEAGUE_GAME_ID} }, + }); + + const agmSeats = agmStaffRole + ? await this.franchiseStaffSeatRepository.find({ + where: {role: {id: agmStaffRole.id} }, + order: {id: "ASC"}, + }) + : []; + + for (const mleTeam of staffTeams) { + const bridge = await this.teamToFranchiseRepository.findOne({where: {team: mleTeam.name} }); + if (!bridge) { + this.logger.warn(`syncStaffAndCaptainFromMle: no franchise bridge for staff team "${mleTeam.name}"`); + continue; + } + const franchiseId = bridge.franchiseId; + + if (fmRole && mleTeam.franchiseManagerId === mlePlayer.id) { + const seat = await this.franchiseLeadershipSeatRepository.findOne({where: {role: {id: fmRole.id} } }); + if (seat) { + await this.franchiseLeadershipAppointmentRepository.save( + this.franchiseLeadershipAppointmentRepository.create({ + franchise: {id: franchiseId} as never, + member: {id: memberId} as never, + seat, + }), + ); + } + } + if (gmStaffRole && mleTeam.generalManagerId === mlePlayer.id) { + const seat = await this.franchiseStaffSeatRepository.findOne({where: {role: {id: gmStaffRole.id} } }); + if (seat) { + await this.franchiseStaffAppointmentRepository.save( + this.franchiseStaffAppointmentRepository.create({ + franchise: {id: franchiseId} as never, + member: {id: memberId} as never, + seat, + }), + ); + } + } + if (agmStaffRole && agmSeats.length > 0) { + if (mleTeam.doublesAssistantGeneralManagerId === mlePlayer.id) { + await this.franchiseStaffAppointmentRepository.save( + this.franchiseStaffAppointmentRepository.create({ + franchise: {id: franchiseId} as never, + member: {id: memberId} as never, + seat: agmSeats[0], + }), + ); + } + if (mleTeam.standardAssistantGeneralManagerId === mlePlayer.id) { + const seat = agmSeats.length > 1 ? agmSeats[1] : agmSeats[0]; + await this.franchiseStaffAppointmentRepository.save( + this.franchiseStaffAppointmentRepository.create({ + franchise: {id: franchiseId} as never, + member: {id: memberId} as never, + seat, + }), + ); + } + } + if (prStaffRole && mleTeam.prSupportId === mlePlayer.id) { + const seat = await this.franchiseStaffSeatRepository.findOne({where: {role: {id: prStaffRole.id} } }); + if (seat) { + await this.franchiseStaffAppointmentRepository.save( + this.franchiseStaffAppointmentRepository.create({ + franchise: {id: franchiseId} as never, + member: {id: memberId} as never, + seat, + }), + ); + } + } + } + + const captainRows = await this.mleTeamToCaptainRepository.find({where: {playerId: mlePlayer.id} }); + for (const cap of captainRows) { + const bridge = await this.teamToFranchiseRepository.findOne({where: {team: cap.teamName} }); + if (!bridge || !captainStaffRole) continue; + const seat = await this.franchiseStaffSeatRepository.findOne({where: {role: {id: captainStaffRole.id} } }); + if (!seat) continue; + await this.franchiseStaffAppointmentRepository.save( + this.franchiseStaffAppointmentRepository.create({ + franchise: {id: bridge.franchiseId} as never, + member: {id: memberId} as never, + seat, + }), + ); + } + } + + private async clearFranchiseStaffForMember(memberId: number): Promise { + await this.franchiseStaffAppointmentRepository + .createQueryBuilder() + .delete() + .from(FranchiseStaffAppointment) + .where('"memberId" = :memberId', {memberId}) + .andWhere( + `"seatId" IN (SELECT id FROM sprocket.franchise_staff_seat WHERE "roleId" IN ` + + `(SELECT id FROM sprocket.franchise_staff_role WHERE "gameId" = :gid))`, + {gid: ROCKET_LEAGUE_GAME_ID}, + ) + .execute(); + + const fmRole = await this.franchiseLeadershipRoleRepository.findOne({where: {name: LEADERSHIP_ROLE_FM} }); + if (fmRole) { + await this.franchiseLeadershipAppointmentRepository + .createQueryBuilder() + .delete() + .from(FranchiseLeadershipAppointment) + .where('"memberId" = :memberId', {memberId}) + .andWhere( + `"seatId" IN (SELECT id FROM sprocket.franchise_leadership_seat WHERE "roleId" = :roleId)`, + {roleId: fmRole.id}, + ) + .execute(); + } + } + + /** + * Franchises and staff roles for a user from Sprocket (roster_slot + appointments), populated by syncFromMlePlayerId. + */ + async getPlayerFranchisesFromSprocket(userId: number): Promise { + const players = await this.playerRepository.find({ + where: { + member: {user: {id: userId} }, + skillGroup: {game: {id: ROCKET_LEAGUE_GAME_ID} }, + }, + relations: { + member: true, + skillGroup: {game: true}, + slot: { + team: { + franchise: {profile: true}, + }, + }, + }, + }); + + if (players.length === 0) { + return []; + } + + const memberId = players[0].member.id; + + const staffAppts = await this.franchiseStaffAppointmentRepository.find({ + where: {member: {id: memberId} }, + relations: { + franchise: {profile: true}, + seat: {role: true}, + }, + }); + + const leadershipAppts = await this.franchiseLeadershipAppointmentRepository.find({ + where: {member: {id: memberId} }, + relations: { + franchise: {profile: true}, + seat: {role: true}, + }, + }); + + type Entry = {id: number; name: string; staffPositions: Map;}; + const byFranchise = new Map(); + + const ensure = (franchiseId: number, title: string): Entry => { + let e = byFranchise.get(franchiseId); + if (!e) { + e = {id: franchiseId, name: title, staffPositions: new Map()}; + byFranchise.set(franchiseId, e); + } + return e; + }; + + for (const p of players) { + const fr = p.slot?.team?.franchise; + const title = fr?.profile?.title; + if (fr && title) { + ensure(fr.id, title); + } + } + + for (const a of staffAppts) { + const title = a.franchise.profile?.title ?? ""; + const entry = ensure(a.franchise.id, title); + const code = this.staffRoleNameToCode(a.seat.role.name); + if (code) { + entry.staffPositions.set(code, {id: a.seat.role.id, name: code}); + } + } + + for (const a of leadershipAppts) { + if (a.seat.role.name !== LEADERSHIP_ROLE_FM) continue; + const title = a.franchise.profile?.title ?? ""; + const entry = ensure(a.franchise.id, title); + entry.staffPositions.set("FM", {id: a.seat.role.id, name: "FM"}); + } + + return [...byFranchise.values()].map(v => ({ + id: v.id, + name: v.name, + staffPositions: [...v.staffPositions.values()], + })); + } + + private staffRoleNameToCode(name: string): "GM" | "AGM" | "CAP" | "PR" | null { + if (name === STAFF_ROLE_GM) return "GM"; + if (name === STAFF_ROLE_AGM) return "AGM"; + if (name === STAFF_ROLE_CAPTAIN) return "CAP"; + if (name === STAFF_ROLE_PR || name === "PR_SUPPORT") return "PR"; + return null; + } + + async getLeagueSuspendedFromMle(userId: number): Promise { + const row = await this.mlePlayerRepository + .createQueryBuilder("p") + .innerJoin( + UserAuthenticationAccount, + "uaa", + "uaa.accountId = p.discord_id AND uaa.userId = :userId AND uaa.accountType = :atype", + {userId, atype: UserAuthenticationAccountType.DISCORD}, + ) + .select("p.suspended", "suspended") + .getRawOne<{suspended: boolean;}>(); + + return row?.suspended === true; + } +} diff --git a/reports/issue-728-sprocket-roster-authority.md b/reports/issue-728-sprocket-roster-authority.md new file mode 100644 index 000000000..51596291d --- /dev/null +++ b/reports/issue-728-sprocket-roster-authority.md @@ -0,0 +1,28 @@ +# Issue 728 — Sprocket as roster / staff / suspension read model + +## Source of truth (current) + +For **Rocket League** (`game.id === 7`), franchise-facing reads should use **Sprocket** first: + +- **Active roster slot:** `sprocket.roster_slot` (player ↔ team ↔ `roster_role`), populated from MLE `player.team_name`, `player.league`, `player.role` via `RosterAuthorityService.syncFromMlePlayerId`. +- **Franchise staff / captain / FM:** `sprocket.franchise_staff_appointment`, `sprocket.franchise_leadership_appointment`, keyed by `member` and franchise, populated from MLE `team` staff columns and `mledb.team_to_captain`. +- **League suspension (lookup):** `mledb.player.suspended` remains the legacy field; `RosterAuthorityService.getLeagueSuspendedFromMle` exposes it for callers that need it until a first-class Sprocket column exists. + +`FranchiseService.getPlayerFranchisesByUserId` uses `getPlayerFranchisesFromSprocket` when any Sprocket data exists; otherwise it falls back to the previous MLE-only path (backfill / cold start). + +## Temporary legacy mirror + +**MLE** (`mledb.player`, `mledb.team`, `mledb.team_to_captain`) is still written by existing flows (e.g. `PlayerService` rank moves, `forcePlayerToTeam`, bot-era paths). After each relevant `mledb.player` save, core runs `RosterAuthorityService.syncFromMlePlayerId` so Sprocket stays aligned. + +**Removal checklist (when bot commands are retired):** + +1. Stop writing MLE roster/staff/captain fields; drive `roster_slot` and appointments from Sprocket-native mutations only. +2. Drop the post-save `syncFromMlePlayerId` hooks from `PlayerService` once MLE is no longer authoritative. +3. Remove the MLE fallback branch in `FranchiseService.getPlayerFranchisesByUserId`. +4. Add a Sprocket-native suspension field (or extend member restrictions) and migrate `suspended` reads off MLE. + +## Relevant code + +- `core/src/franchise/roster-authority.service.ts` — sync + Sprocket franchise aggregation +- `core/src/franchise/player/player.service.ts` — triggers sync after MLE player saves +- `core/src/franchise/franchise/franchise.service.ts` — prefers Sprocket for `GetPlayerFranchises`