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 }