series overview added
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Migrations.MustRegister(
|
||||
// UP migration
|
||||
func(ctx context.Context, conn *bun.DB) error {
|
||||
_, err := conn.NewCreateTable().
|
||||
Model((*db.PlayoffSeriesSchedule)(nil)).
|
||||
IfNotExists().
|
||||
ForeignKey(`("series_id") REFERENCES "playoff_series" ("id") ON DELETE CASCADE`).
|
||||
ForeignKey(`("proposed_by_team_id") REFERENCES "teams" ("id")`).
|
||||
ForeignKey(`("accepted_by_team_id") REFERENCES "teams" ("id")`).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create index on series_id for faster lookups
|
||||
_, err = conn.NewCreateIndex().
|
||||
Model((*db.PlayoffSeriesSchedule)(nil)).
|
||||
Index("idx_playoff_series_schedules_series_id").
|
||||
Column("series_id").
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create index on status for filtering
|
||||
_, err = conn.NewCreateIndex().
|
||||
Model((*db.PlayoffSeriesSchedule)(nil)).
|
||||
Index("idx_playoff_series_schedules_status").
|
||||
Column("status").
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
// DOWN migration
|
||||
func(ctx context.Context, conn *bun.DB) error {
|
||||
_, err := conn.NewDropTable().
|
||||
Model((*db.PlayoffSeriesSchedule)(nil)).
|
||||
IfExists().
|
||||
Exec(ctx)
|
||||
return err
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -324,3 +324,224 @@ func AutoForfeitUnplayedFixtures(
|
||||
|
||||
return len(unplayed), nil
|
||||
}
|
||||
|
||||
// GetPlayoffSeriesByID retrieves a single playoff series with all relations needed
|
||||
// for the series detail page.
|
||||
func GetPlayoffSeriesByID(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
seriesID int,
|
||||
) (*PlayoffSeries, error) {
|
||||
series := new(PlayoffSeries)
|
||||
err := tx.NewSelect().
|
||||
Model(series).
|
||||
Where("ps.id = ?", seriesID).
|
||||
Relation("Bracket").
|
||||
Relation("Bracket.Season").
|
||||
Relation("Bracket.League").
|
||||
Relation("Bracket.Series").
|
||||
Relation("Team1").
|
||||
Relation("Team2").
|
||||
Relation("Winner").
|
||||
Relation("Loser").
|
||||
Relation("Matches", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.Order("pm.match_number ASC")
|
||||
}).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
if err.Error() == "sql: no rows in result set" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||
}
|
||||
return series, nil
|
||||
}
|
||||
|
||||
// CanScheduleSeries checks if the user is a manager of one of the teams in the series.
|
||||
// Returns (canSchedule, teamID) where teamID is the team the user manages (0 if not a manager).
|
||||
// Both teams must be assigned for scheduling to be possible.
|
||||
func CanScheduleSeries(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
series *PlayoffSeries,
|
||||
user *User,
|
||||
) (bool, int, error) {
|
||||
if user == nil || user.Player == nil {
|
||||
return false, 0, nil
|
||||
}
|
||||
if series.Team1ID == nil || series.Team2ID == nil {
|
||||
return false, 0, nil
|
||||
}
|
||||
|
||||
roster := new(TeamRoster)
|
||||
err := tx.NewSelect().
|
||||
Model(roster).
|
||||
Column("team_id", "is_manager").
|
||||
Where("team_id IN (?)", bun.In([]int{*series.Team1ID, *series.Team2ID})).
|
||||
Where("season_id = ?", series.Bracket.SeasonID).
|
||||
Where("league_id = ?", series.Bracket.LeagueID).
|
||||
Where("player_id = ?", user.Player.ID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
if err.Error() == "sql: no rows in result set" {
|
||||
return false, 0, nil
|
||||
}
|
||||
return false, 0, errors.Wrap(err, "tx.NewSelect")
|
||||
}
|
||||
if !roster.IsManager {
|
||||
return false, 0, nil
|
||||
}
|
||||
return true, roster.TeamID, nil
|
||||
}
|
||||
|
||||
// GetSeriesTeamRosters returns rosters for both teams in a series.
|
||||
// Returns map["team1"|"team2"] -> []*PlayerWithPlayStatus
|
||||
func GetSeriesTeamRosters(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
series *PlayoffSeries,
|
||||
) (map[string][]*PlayerWithPlayStatus, error) {
|
||||
if series == nil {
|
||||
return nil, errors.New("series cannot be nil")
|
||||
}
|
||||
|
||||
rosters := map[string][]*PlayerWithPlayStatus{}
|
||||
|
||||
if series.Team1ID != nil {
|
||||
team1Rosters := []*TeamRoster{}
|
||||
err := tx.NewSelect().
|
||||
Model(&team1Rosters).
|
||||
Where("tr.team_id = ?", *series.Team1ID).
|
||||
Where("tr.season_id = ?", series.Bracket.SeasonID).
|
||||
Where("tr.league_id = ?", series.Bracket.LeagueID).
|
||||
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.Relation("User")
|
||||
}).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.NewSelect team1 roster")
|
||||
}
|
||||
for _, tr := range team1Rosters {
|
||||
rosters["team1"] = append(rosters["team1"], &PlayerWithPlayStatus{
|
||||
Player: tr.Player,
|
||||
Played: false,
|
||||
IsManager: tr.IsManager,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if series.Team2ID != nil {
|
||||
team2Rosters := []*TeamRoster{}
|
||||
err := tx.NewSelect().
|
||||
Model(&team2Rosters).
|
||||
Where("tr.team_id = ?", *series.Team2ID).
|
||||
Where("tr.season_id = ?", series.Bracket.SeasonID).
|
||||
Where("tr.league_id = ?", series.Bracket.LeagueID).
|
||||
Relation("Player", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.Relation("User")
|
||||
}).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.NewSelect team2 roster")
|
||||
}
|
||||
for _, tr := range team2Rosters {
|
||||
rosters["team2"] = append(rosters["team2"], &PlayerWithPlayStatus{
|
||||
Player: tr.Player,
|
||||
Played: false,
|
||||
IsManager: tr.IsManager,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return rosters, nil
|
||||
}
|
||||
|
||||
// ComputeSeriesPreview computes standings comparison data for the two teams in a series.
|
||||
// Uses the same logic as ComputeMatchPreview but takes a series instead of a fixture.
|
||||
func ComputeSeriesPreview(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
series *PlayoffSeries,
|
||||
) (*MatchPreviewData, error) {
|
||||
if series == nil || series.Bracket == nil {
|
||||
return nil, errors.New("series and bracket cannot be nil")
|
||||
}
|
||||
|
||||
seasonID := series.Bracket.SeasonID
|
||||
leagueID := series.Bracket.LeagueID
|
||||
|
||||
// Get all teams in this season+league
|
||||
allTeams, err := GetTeamsForSeasonLeague(ctx, tx, seasonID, leagueID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetTeamsForSeasonLeague")
|
||||
}
|
||||
|
||||
// Get all allocated fixtures for the season+league
|
||||
allFixtures, err := GetAllocatedFixtures(ctx, tx, seasonID, leagueID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetAllocatedFixtures")
|
||||
}
|
||||
|
||||
// Get finalized results
|
||||
allFixtureIDs := make([]int, len(allFixtures))
|
||||
for i, f := range allFixtures {
|
||||
allFixtureIDs[i] = f.ID
|
||||
}
|
||||
allResultMap, err := GetFinalizedResultsForFixtures(ctx, tx, allFixtureIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetFinalizedResultsForFixtures")
|
||||
}
|
||||
|
||||
// Get accepted schedules for ordering recent games
|
||||
allScheduleMap, err := GetAcceptedSchedulesForFixtures(ctx, tx, allFixtureIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetAcceptedSchedulesForFixtures")
|
||||
}
|
||||
|
||||
// Compute leaderboard
|
||||
leaderboard := ComputeLeaderboard(allTeams, allFixtures, allResultMap)
|
||||
|
||||
preview := &MatchPreviewData{
|
||||
TotalTeams: len(leaderboard),
|
||||
}
|
||||
|
||||
team1ID := 0
|
||||
team2ID := 0
|
||||
if series.Team1ID != nil {
|
||||
team1ID = *series.Team1ID
|
||||
}
|
||||
if series.Team2ID != nil {
|
||||
team2ID = *series.Team2ID
|
||||
}
|
||||
|
||||
for _, entry := range leaderboard {
|
||||
if entry.Team.ID == team1ID {
|
||||
preview.HomePosition = entry.Position
|
||||
preview.HomeRecord = entry.Record
|
||||
}
|
||||
if entry.Team.ID == team2ID {
|
||||
preview.AwayPosition = entry.Position
|
||||
preview.AwayRecord = entry.Record
|
||||
}
|
||||
}
|
||||
if preview.HomeRecord == nil {
|
||||
preview.HomeRecord = &TeamRecord{}
|
||||
}
|
||||
if preview.AwayRecord == nil {
|
||||
preview.AwayRecord = &TeamRecord{}
|
||||
}
|
||||
|
||||
// Compute recent games (last 5) for each team
|
||||
if team1ID > 0 {
|
||||
preview.HomeRecentGames = ComputeRecentGames(
|
||||
team1ID, allFixtures, allResultMap, allScheduleMap, 5,
|
||||
)
|
||||
}
|
||||
if team2ID > 0 {
|
||||
preview.AwayRecentGames = ComputeRecentGames(
|
||||
team2ID, allFixtures, allResultMap, allScheduleMap, 5,
|
||||
)
|
||||
}
|
||||
|
||||
return preview, nil
|
||||
}
|
||||
|
||||
370
internal/db/playoff_schedule.go
Normal file
370
internal/db/playoff_schedule.go
Normal file
@@ -0,0 +1,370 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// PlayoffSeriesSchedule represents a schedule proposal for a playoff series.
|
||||
// Mirrors FixtureSchedule but references a series instead of a fixture.
|
||||
type PlayoffSeriesSchedule struct {
|
||||
bun.BaseModel `bun:"table:playoff_series_schedules,alias:pss"`
|
||||
|
||||
ID int `bun:"id,pk,autoincrement"`
|
||||
SeriesID int `bun:",notnull"`
|
||||
ScheduledTime *time.Time `bun:"scheduled_time"`
|
||||
ProposedByTeamID int `bun:",notnull"`
|
||||
AcceptedByTeamID *int `bun:"accepted_by_team_id"`
|
||||
Status ScheduleStatus `bun:",notnull,default:'pending'"`
|
||||
RescheduleReason *string `bun:"reschedule_reason"`
|
||||
CreatedAt int64 `bun:",notnull"`
|
||||
UpdatedAt *int64 `bun:"updated_at"`
|
||||
|
||||
Series *PlayoffSeries `bun:"rel:belongs-to,join:series_id=id"`
|
||||
ProposedBy *Team `bun:"rel:belongs-to,join:proposed_by_team_id=id"`
|
||||
AcceptedBy *Team `bun:"rel:belongs-to,join:accepted_by_team_id=id"`
|
||||
}
|
||||
|
||||
// GetCurrentSeriesSchedule returns the most recent schedule record for a series.
|
||||
// Returns nil, nil if no schedule exists.
|
||||
func GetCurrentSeriesSchedule(ctx context.Context, tx bun.Tx, seriesID int) (*PlayoffSeriesSchedule, error) {
|
||||
schedule := new(PlayoffSeriesSchedule)
|
||||
err := tx.NewSelect().
|
||||
Model(schedule).
|
||||
Where("series_id = ?", seriesID).
|
||||
Order("created_at DESC", "id DESC").
|
||||
Relation("ProposedBy").
|
||||
Relation("AcceptedBy").
|
||||
Limit(1).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
if err.Error() == "sql: no rows in result set" {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||
}
|
||||
return schedule, nil
|
||||
}
|
||||
|
||||
// GetSeriesScheduleHistory returns all schedule records for a series in chronological order
|
||||
func GetSeriesScheduleHistory(ctx context.Context, tx bun.Tx, seriesID int) ([]*PlayoffSeriesSchedule, error) {
|
||||
schedules, err := GetList[PlayoffSeriesSchedule](tx).
|
||||
Where("series_id = ?", seriesID).
|
||||
Order("created_at ASC", "id ASC").
|
||||
Relation("ProposedBy").
|
||||
Relation("AcceptedBy").
|
||||
GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetList")
|
||||
}
|
||||
return schedules, nil
|
||||
}
|
||||
|
||||
// ProposeSeriesSchedule creates a new pending schedule proposal for a series.
|
||||
// Cannot propose on cancelled or accepted schedules.
|
||||
func ProposeSeriesSchedule(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
seriesID, proposedByTeamID int,
|
||||
scheduledTime time.Time,
|
||||
audit *AuditMeta,
|
||||
) (*PlayoffSeriesSchedule, error) {
|
||||
current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetCurrentSeriesSchedule")
|
||||
}
|
||||
if current != nil {
|
||||
switch current.Status {
|
||||
case ScheduleStatusCancelled:
|
||||
return nil, BadRequest("cannot propose a new time for a cancelled series")
|
||||
case ScheduleStatusAccepted:
|
||||
return nil, BadRequest("series already has an accepted schedule; use reschedule instead")
|
||||
case ScheduleStatusPending:
|
||||
// Supersede existing pending record
|
||||
now := time.Now().Unix()
|
||||
current.Status = ScheduleStatusRescheduled
|
||||
current.UpdatedAt = &now
|
||||
err = UpdateByID(tx, current.ID, current).
|
||||
Column("status", "updated_at").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "UpdateByID")
|
||||
}
|
||||
// rejected, rescheduled, postponed, withdrawn are terminal — safe to create a new proposal
|
||||
}
|
||||
}
|
||||
|
||||
schedule := &PlayoffSeriesSchedule{
|
||||
SeriesID: seriesID,
|
||||
ScheduledTime: &scheduledTime,
|
||||
ProposedByTeamID: proposedByTeamID,
|
||||
Status: ScheduleStatusPending,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{
|
||||
Action: "series_schedule.propose",
|
||||
ResourceType: "playoff_series_schedule",
|
||||
ResourceID: seriesID,
|
||||
Details: map[string]any{
|
||||
"series_id": seriesID,
|
||||
"proposed_by": proposedByTeamID,
|
||||
"scheduled_time": scheduledTime,
|
||||
},
|
||||
}).Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Insert")
|
||||
}
|
||||
return schedule, nil
|
||||
}
|
||||
|
||||
// AcceptSeriesSchedule accepts a pending schedule proposal.
|
||||
// The acceptedByTeamID must be the other team (not the proposer).
|
||||
func AcceptSeriesSchedule(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
scheduleID, acceptedByTeamID int,
|
||||
audit *AuditMeta,
|
||||
) error {
|
||||
schedule, err := GetByID[PlayoffSeriesSchedule](tx, scheduleID).Get(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "GetByID")
|
||||
}
|
||||
if schedule.Status != ScheduleStatusPending {
|
||||
return BadRequest("schedule is not in pending status")
|
||||
}
|
||||
if schedule.ProposedByTeamID == acceptedByTeamID {
|
||||
return BadRequest("cannot accept your own proposal")
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
schedule.AcceptedByTeamID = &acceptedByTeamID
|
||||
schedule.Status = ScheduleStatusAccepted
|
||||
schedule.UpdatedAt = &now
|
||||
err = UpdateByID(tx, schedule.ID, schedule).
|
||||
Column("accepted_by_team_id", "status", "updated_at").
|
||||
WithAudit(audit, &AuditInfo{
|
||||
Action: "series_schedule.accept",
|
||||
ResourceType: "playoff_series_schedule",
|
||||
ResourceID: scheduleID,
|
||||
Details: map[string]any{
|
||||
"series_id": schedule.SeriesID,
|
||||
"accepted_by": acceptedByTeamID,
|
||||
},
|
||||
}).Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "UpdateByID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RejectSeriesSchedule rejects a pending schedule proposal.
|
||||
func RejectSeriesSchedule(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
scheduleID int,
|
||||
audit *AuditMeta,
|
||||
) error {
|
||||
schedule, err := GetByID[PlayoffSeriesSchedule](tx, scheduleID).Get(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "GetByID")
|
||||
}
|
||||
if schedule.Status != ScheduleStatusPending {
|
||||
return BadRequest("schedule is not in pending status")
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
schedule.Status = ScheduleStatusRejected
|
||||
schedule.UpdatedAt = &now
|
||||
err = UpdateByID(tx, schedule.ID, schedule).
|
||||
Column("status", "updated_at").
|
||||
WithAudit(audit, &AuditInfo{
|
||||
Action: "series_schedule.reject",
|
||||
ResourceType: "playoff_series_schedule",
|
||||
ResourceID: scheduleID,
|
||||
Details: map[string]any{
|
||||
"series_id": schedule.SeriesID,
|
||||
},
|
||||
}).Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "UpdateByID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RescheduleSeriesSchedule marks the current accepted schedule as rescheduled
|
||||
// and creates a new pending proposal with the new time.
|
||||
func RescheduleSeriesSchedule(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
seriesID, proposedByTeamID int,
|
||||
newTime time.Time,
|
||||
reason string,
|
||||
audit *AuditMeta,
|
||||
) (*PlayoffSeriesSchedule, error) {
|
||||
current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetCurrentSeriesSchedule")
|
||||
}
|
||||
if current == nil || current.Status != ScheduleStatusAccepted {
|
||||
return nil, BadRequest("no accepted schedule to reschedule")
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
current.Status = ScheduleStatusRescheduled
|
||||
current.RescheduleReason = &reason
|
||||
current.UpdatedAt = &now
|
||||
err = UpdateByID(tx, current.ID, current).
|
||||
Column("status", "reschedule_reason", "updated_at").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "UpdateByID")
|
||||
}
|
||||
|
||||
// Create new pending proposal
|
||||
schedule := &PlayoffSeriesSchedule{
|
||||
SeriesID: seriesID,
|
||||
ScheduledTime: &newTime,
|
||||
ProposedByTeamID: proposedByTeamID,
|
||||
Status: ScheduleStatusPending,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{
|
||||
Action: "series_schedule.reschedule",
|
||||
ResourceType: "playoff_series_schedule",
|
||||
ResourceID: seriesID,
|
||||
Details: map[string]any{
|
||||
"series_id": seriesID,
|
||||
"proposed_by": proposedByTeamID,
|
||||
"new_time": newTime,
|
||||
"reason": reason,
|
||||
"old_schedule_id": current.ID,
|
||||
},
|
||||
}).Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Insert")
|
||||
}
|
||||
return schedule, nil
|
||||
}
|
||||
|
||||
// PostponeSeriesSchedule marks the current accepted schedule as postponed.
|
||||
// This is a terminal state — a new proposal can be created afterwards.
|
||||
func PostponeSeriesSchedule(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
seriesID int,
|
||||
reason string,
|
||||
audit *AuditMeta,
|
||||
) error {
|
||||
current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "GetCurrentSeriesSchedule")
|
||||
}
|
||||
if current == nil || current.Status != ScheduleStatusAccepted {
|
||||
return BadRequest("no accepted schedule to postpone")
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
current.Status = ScheduleStatusPostponed
|
||||
current.RescheduleReason = &reason
|
||||
current.UpdatedAt = &now
|
||||
err = UpdateByID(tx, current.ID, current).
|
||||
Column("status", "reschedule_reason", "updated_at").
|
||||
WithAudit(audit, &AuditInfo{
|
||||
Action: "series_schedule.postpone",
|
||||
ResourceType: "playoff_series_schedule",
|
||||
ResourceID: seriesID,
|
||||
Details: map[string]any{
|
||||
"series_id": seriesID,
|
||||
"reason": reason,
|
||||
"old_schedule_id": current.ID,
|
||||
},
|
||||
}).Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "UpdateByID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithdrawSeriesSchedule allows the proposer to withdraw their pending proposal.
|
||||
// Only the team that proposed can withdraw it.
|
||||
func WithdrawSeriesSchedule(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
scheduleID, withdrawByTeamID int,
|
||||
audit *AuditMeta,
|
||||
) error {
|
||||
schedule, err := GetByID[PlayoffSeriesSchedule](tx, scheduleID).Get(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "GetByID")
|
||||
}
|
||||
if schedule.Status != ScheduleStatusPending {
|
||||
return BadRequest("schedule is not in pending status")
|
||||
}
|
||||
if schedule.ProposedByTeamID != withdrawByTeamID {
|
||||
return BadRequest("only the proposing team can withdraw their proposal")
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
schedule.Status = ScheduleStatusWithdrawn
|
||||
schedule.UpdatedAt = &now
|
||||
err = UpdateByID(tx, schedule.ID, schedule).
|
||||
Column("status", "updated_at").
|
||||
WithAudit(audit, &AuditInfo{
|
||||
Action: "series_schedule.withdraw",
|
||||
ResourceType: "playoff_series_schedule",
|
||||
ResourceID: scheduleID,
|
||||
Details: map[string]any{
|
||||
"series_id": schedule.SeriesID,
|
||||
"withdrawn_by": withdrawByTeamID,
|
||||
},
|
||||
}).Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "UpdateByID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelSeriesSchedule marks the current schedule as cancelled. This is a terminal state.
|
||||
// Requires playoffs.manage permission (moderator-level).
|
||||
func CancelSeriesSchedule(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
seriesID int,
|
||||
reason string,
|
||||
audit *AuditMeta,
|
||||
) error {
|
||||
current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "GetCurrentSeriesSchedule")
|
||||
}
|
||||
if current == nil {
|
||||
return BadRequest("no schedule to cancel")
|
||||
}
|
||||
if current.Status.IsTerminal() {
|
||||
return BadRequest("schedule is already in a terminal state")
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
current.Status = ScheduleStatusCancelled
|
||||
current.RescheduleReason = &reason
|
||||
current.UpdatedAt = &now
|
||||
err = UpdateByID(tx, current.ID, current).
|
||||
Column("status", "reschedule_reason", "updated_at").
|
||||
WithAudit(audit, &AuditInfo{
|
||||
Action: "series_schedule.cancel",
|
||||
ResourceType: "playoff_series_schedule",
|
||||
ResourceID: seriesID,
|
||||
Details: map[string]any{
|
||||
"series_id": seriesID,
|
||||
"reason": reason,
|
||||
"schedule_id": current.ID,
|
||||
},
|
||||
}).Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "UpdateByID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user