Files
oslstats/internal/db/fixture_schedule.go
2026-02-21 14:19:46 +11:00

427 lines
13 KiB
Go

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
}