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 }