added fixture scheduling
This commit is contained in:
@@ -28,6 +28,35 @@ type Fixture struct {
|
||||
League *League `bun:"rel:belongs-to,join:league_id=id"`
|
||||
HomeTeam *Team `bun:"rel:belongs-to,join:home_team_id=id"`
|
||||
AwayTeam *Team `bun:"rel:belongs-to,join:away_team_id=id"`
|
||||
|
||||
Schedules []*FixtureSchedule `bun:"rel:has-many,join:id=fixture_id"`
|
||||
}
|
||||
|
||||
// CanSchedule checks if the user is a manager of one of the teams in the fixture.
|
||||
// Returns (canSchedule, teamID) where teamID is the team the user manages (0 if not a manager).
|
||||
func (f *Fixture) CanSchedule(ctx context.Context, tx bun.Tx, user *User) (bool, int, error) {
|
||||
if user == nil || user.Player == 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{f.HomeTeamID, f.AwayTeamID})).
|
||||
Where("season_id = ?", f.SeasonID).
|
||||
Where("league_id = ?", f.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
|
||||
}
|
||||
|
||||
func NewFixture(ctx context.Context, tx bun.Tx, seasonShortName, leagueShortName string,
|
||||
|
||||
426
internal/db/fixture_schedule.go
Normal file
426
internal/db/fixture_schedule.go
Normal file
@@ -0,0 +1,426 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// ScheduleStatus represents the current status of a fixture schedule proposal
|
||||
type ScheduleStatus string
|
||||
|
||||
const (
|
||||
ScheduleStatusPending ScheduleStatus = "pending"
|
||||
ScheduleStatusAccepted ScheduleStatus = "accepted"
|
||||
ScheduleStatusRejected ScheduleStatus = "rejected"
|
||||
ScheduleStatusRescheduled ScheduleStatus = "rescheduled"
|
||||
ScheduleStatusPostponed ScheduleStatus = "postponed"
|
||||
ScheduleStatusCancelled ScheduleStatus = "cancelled"
|
||||
ScheduleStatusWithdrawn ScheduleStatus = "withdrawn"
|
||||
)
|
||||
|
||||
// IsTerminal returns true if the status is a terminal (immutable) state
|
||||
func (s ScheduleStatus) IsTerminal() bool {
|
||||
switch s {
|
||||
case ScheduleStatusRejected, ScheduleStatusRescheduled,
|
||||
ScheduleStatusPostponed, ScheduleStatusCancelled,
|
||||
ScheduleStatusWithdrawn:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RescheduleReason represents the predefined reasons for rescheduling or postponing
|
||||
type RescheduleReason string
|
||||
|
||||
const (
|
||||
ReasonMutuallyAgreed RescheduleReason = "Mutually Agreed"
|
||||
ReasonTeamUnavailable RescheduleReason = "Team Unavailable"
|
||||
ReasonTeamNoShow RescheduleReason = "Team No-show"
|
||||
)
|
||||
|
||||
type FixtureSchedule struct {
|
||||
bun.BaseModel `bun:"table:fixture_schedules,alias:fs"`
|
||||
|
||||
ID int `bun:"id,pk,autoincrement"`
|
||||
FixtureID 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"`
|
||||
|
||||
Fixture *Fixture `bun:"rel:belongs-to,join:fixture_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"`
|
||||
}
|
||||
|
||||
// GetAcceptedSchedulesForFixtures returns the accepted schedule for each fixture in the given list.
|
||||
// Returns a map of fixtureID -> *FixtureSchedule (only accepted schedules are included).
|
||||
func GetAcceptedSchedulesForFixtures(ctx context.Context, tx bun.Tx, fixtureIDs []int) (map[int]*FixtureSchedule, error) {
|
||||
if len(fixtureIDs) == 0 {
|
||||
return map[int]*FixtureSchedule{}, nil
|
||||
}
|
||||
schedules, err := GetList[FixtureSchedule](tx).
|
||||
Where("fixture_id IN (?)", bun.In(fixtureIDs)).
|
||||
Where("status = ?", ScheduleStatusAccepted).
|
||||
GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetList")
|
||||
}
|
||||
result := make(map[int]*FixtureSchedule, len(schedules))
|
||||
for _, s := range schedules {
|
||||
// If multiple accepted exist (shouldn't happen), keep the most recent
|
||||
existing, ok := result[s.FixtureID]
|
||||
if !ok || s.CreatedAt > existing.CreatedAt {
|
||||
result[s.FixtureID] = s
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetFixtureScheduleHistory returns all schedule records for a fixture in chronological order
|
||||
func GetFixtureScheduleHistory(ctx context.Context, tx bun.Tx, fixtureID int) ([]*FixtureSchedule, error) {
|
||||
schedules, err := GetList[FixtureSchedule](tx).
|
||||
Where("fixture_id = ?", fixtureID).
|
||||
Order("created_at ASC", "id ASC").
|
||||
Relation("ProposedBy").
|
||||
Relation("AcceptedBy").
|
||||
GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetList")
|
||||
}
|
||||
return schedules, nil
|
||||
}
|
||||
|
||||
// GetCurrentFixtureSchedule returns the most recent schedule record for a fixture.
|
||||
// Returns nil, nil if no schedule exists.
|
||||
func GetCurrentFixtureSchedule(ctx context.Context, tx bun.Tx, fixtureID int) (*FixtureSchedule, error) {
|
||||
schedule := new(FixtureSchedule)
|
||||
err := tx.NewSelect().
|
||||
Model(schedule).
|
||||
Where("fixture_id = ?", fixtureID).
|
||||
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
|
||||
}
|
||||
|
||||
// ProposeFixtureSchedule creates a new pending schedule proposal for a fixture.
|
||||
// If there is an existing pending record with no time (postponed placeholder), it will be
|
||||
// superseded. Cannot propose on cancelled or accepted schedules.
|
||||
func ProposeFixtureSchedule(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
fixtureID, proposedByTeamID int,
|
||||
scheduledTime time.Time,
|
||||
audit *AuditMeta,
|
||||
) (*FixtureSchedule, error) {
|
||||
current, err := GetCurrentFixtureSchedule(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetCurrentFixtureSchedule")
|
||||
}
|
||||
if current != nil {
|
||||
switch current.Status {
|
||||
case ScheduleStatusCancelled:
|
||||
return nil, BadRequest("cannot propose a new time for a cancelled fixture")
|
||||
case ScheduleStatusAccepted:
|
||||
return nil, BadRequest("fixture already has an accepted schedule; use reschedule instead")
|
||||
case ScheduleStatusPending:
|
||||
// Supersede existing pending record (e.g., postponed placeholder or old proposal)
|
||||
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 are terminal — safe to create a new proposal
|
||||
}
|
||||
}
|
||||
|
||||
schedule := &FixtureSchedule{
|
||||
FixtureID: fixtureID,
|
||||
ScheduledTime: &scheduledTime,
|
||||
ProposedByTeamID: proposedByTeamID,
|
||||
Status: ScheduleStatusPending,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{
|
||||
Action: "fixture_schedule.propose",
|
||||
ResourceType: "fixture_schedule",
|
||||
ResourceID: fixtureID,
|
||||
Details: map[string]any{
|
||||
"fixture_id": fixtureID,
|
||||
"proposed_by": proposedByTeamID,
|
||||
"scheduled_time": scheduledTime,
|
||||
},
|
||||
}).Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Insert")
|
||||
}
|
||||
return schedule, nil
|
||||
}
|
||||
|
||||
// AcceptFixtureSchedule accepts a pending schedule proposal.
|
||||
// The acceptedByTeamID must be the other team (not the proposer).
|
||||
func AcceptFixtureSchedule(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
scheduleID, acceptedByTeamID int,
|
||||
audit *AuditMeta,
|
||||
) error {
|
||||
schedule, err := GetByID[FixtureSchedule](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: "fixture_schedule.accept",
|
||||
ResourceType: "fixture_schedule",
|
||||
ResourceID: scheduleID,
|
||||
Details: map[string]any{
|
||||
"fixture_id": schedule.FixtureID,
|
||||
"accepted_by": acceptedByTeamID,
|
||||
},
|
||||
}).Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "UpdateByID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RejectFixtureSchedule rejects a pending schedule proposal.
|
||||
func RejectFixtureSchedule(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
scheduleID int,
|
||||
audit *AuditMeta,
|
||||
) error {
|
||||
schedule, err := GetByID[FixtureSchedule](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: "fixture_schedule.reject",
|
||||
ResourceType: "fixture_schedule",
|
||||
ResourceID: scheduleID,
|
||||
Details: map[string]any{
|
||||
"fixture_id": schedule.FixtureID,
|
||||
},
|
||||
}).Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "UpdateByID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RescheduleFixtureSchedule marks the current accepted schedule as rescheduled and creates
|
||||
// a new pending proposal with the new time.
|
||||
func RescheduleFixtureSchedule(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
fixtureID, proposedByTeamID int,
|
||||
newTime time.Time,
|
||||
reason string,
|
||||
audit *AuditMeta,
|
||||
) (*FixtureSchedule, error) {
|
||||
current, err := GetCurrentFixtureSchedule(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetCurrentFixtureSchedule")
|
||||
}
|
||||
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 := &FixtureSchedule{
|
||||
FixtureID: fixtureID,
|
||||
ScheduledTime: &newTime,
|
||||
ProposedByTeamID: proposedByTeamID,
|
||||
Status: ScheduleStatusPending,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{
|
||||
Action: "fixture_schedule.reschedule",
|
||||
ResourceType: "fixture_schedule",
|
||||
ResourceID: fixtureID,
|
||||
Details: map[string]any{
|
||||
"fixture_id": fixtureID,
|
||||
"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
|
||||
}
|
||||
|
||||
// PostponeFixtureSchedule marks the current accepted schedule as postponed.
|
||||
// This is a terminal state — a new proposal can be created afterwards.
|
||||
func PostponeFixtureSchedule(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
fixtureID int,
|
||||
reason string,
|
||||
audit *AuditMeta,
|
||||
) error {
|
||||
current, err := GetCurrentFixtureSchedule(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "GetCurrentFixtureSchedule")
|
||||
}
|
||||
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: "fixture_schedule.postpone",
|
||||
ResourceType: "fixture_schedule",
|
||||
ResourceID: fixtureID,
|
||||
Details: map[string]any{
|
||||
"fixture_id": fixtureID,
|
||||
"reason": reason,
|
||||
"old_schedule_id": current.ID,
|
||||
},
|
||||
}).Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "UpdateByID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithdrawFixtureSchedule allows the proposer to withdraw their pending proposal.
|
||||
// Only the team that proposed can withdraw it.
|
||||
func WithdrawFixtureSchedule(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
scheduleID, withdrawByTeamID int,
|
||||
audit *AuditMeta,
|
||||
) error {
|
||||
schedule, err := GetByID[FixtureSchedule](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: "fixture_schedule.withdraw",
|
||||
ResourceType: "fixture_schedule",
|
||||
ResourceID: scheduleID,
|
||||
Details: map[string]any{
|
||||
"fixture_id": schedule.FixtureID,
|
||||
"withdrawn_by": withdrawByTeamID,
|
||||
},
|
||||
}).Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "UpdateByID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelFixtureSchedule marks the current schedule as cancelled. This is a terminal state.
|
||||
// Requires fixtures.manage permission (moderator-level).
|
||||
func CancelFixtureSchedule(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
fixtureID int,
|
||||
reason string,
|
||||
audit *AuditMeta,
|
||||
) error {
|
||||
current, err := GetCurrentFixtureSchedule(ctx, tx, fixtureID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "GetCurrentFixtureSchedule")
|
||||
}
|
||||
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: "fixture_schedule.cancel",
|
||||
ResourceType: "fixture_schedule",
|
||||
ResourceID: fixtureID,
|
||||
Details: map[string]any{
|
||||
"fixture_id": fixtureID,
|
||||
"reason": reason,
|
||||
"schedule_id": current.ID,
|
||||
},
|
||||
}).Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "UpdateByID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
58
internal/db/migrations/20260221103653_fixture_schedules.go
Normal file
58
internal/db/migrations/20260221103653_fixture_schedules.go
Normal file
@@ -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.FixtureSchedule)(nil)).
|
||||
IfNotExists().
|
||||
ForeignKey(`("fixture_id") REFERENCES "fixtures" ("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 fixture_id for faster lookups
|
||||
_, err = conn.NewCreateIndex().
|
||||
Model((*db.FixtureSchedule)(nil)).
|
||||
Index("idx_fixture_schedules_fixture_id").
|
||||
Column("fixture_id").
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create index on status for filtering
|
||||
_, err = conn.NewCreateIndex().
|
||||
Model((*db.FixtureSchedule)(nil)).
|
||||
Index("idx_fixture_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.FixtureSchedule)(nil)).
|
||||
IfExists().
|
||||
Exec(ctx)
|
||||
return err
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,13 @@ type Player struct {
|
||||
User *User `bun:"rel:belongs-to,join:user_id=id" json:"-"`
|
||||
}
|
||||
|
||||
func (p *Player) DisplayName() string {
|
||||
if p.User != nil {
|
||||
return p.User.Username
|
||||
}
|
||||
return p.Name
|
||||
}
|
||||
|
||||
// NewPlayer creates a new player in the database. If there is an existing user with the same
|
||||
// discordID, it will automatically link that user to the player
|
||||
func NewPlayer(ctx context.Context, tx bun.Tx, discordID string, audit *AuditMeta) (*Player, error) {
|
||||
|
||||
Reference in New Issue
Block a user